mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 00:59:49 +01:00
Convert private story settings fragment to compose.
This commit is contained in:
committed by
jeffrey-signal
parent
24c8501985
commit
55040091af
@@ -18,6 +18,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
|
||||
@Composable
|
||||
@@ -26,6 +27,21 @@ fun AvatarImage(
|
||||
modifier: Modifier = Modifier,
|
||||
useProfile: Boolean = true,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
AvatarImage(
|
||||
recipientId = recipient.id,
|
||||
modifier = modifier,
|
||||
useProfile = useProfile,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AvatarImage(
|
||||
recipientId: RecipientId,
|
||||
modifier: Modifier = Modifier,
|
||||
useProfile: Boolean = true,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
@@ -34,7 +50,7 @@ fun AvatarImage(
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val avatarImageState by rememberRecipientField(recipient) {
|
||||
val avatarImageState by rememberRecipientField(recipientId) {
|
||||
AvatarImageState(
|
||||
getDisplayName(context),
|
||||
this,
|
||||
|
||||
@@ -2,6 +2,22 @@ package org.thoughtcrime.securesms.stories.settings.custom
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
@@ -111,4 +127,32 @@ object PrivateStoryItem {
|
||||
label.text = if (model.privateStoryItemData.isUnknown) context.getString(R.string.MessageRecord_unknown) else model.privateStoryItemData.name
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddViewer(onClick: () -> Unit) {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(text = stringResource(R.string.PrivateStorySettingsFragment__add_viewer))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_plus_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
.padding(10.dp)
|
||||
)
|
||||
},
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun AddViewerPreview() {
|
||||
Previews.Preview {
|
||||
PrivateStoryItem.AddViewer {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,61 @@
|
||||
package org.thoughtcrime.securesms.stories.settings.custom
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.DialogFragmentDisplayManager
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.rememberRecipientField
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel
|
||||
import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import java.util.UUID
|
||||
|
||||
class PrivateStorySettingsFragment : DSLSettingsFragment(
|
||||
menuId = R.menu.story_private_menu
|
||||
) {
|
||||
|
||||
private val progressDisplayManager = DialogFragmentDisplayManager { ProgressCardDialogFragment.create() }
|
||||
/**
|
||||
* Settings fragment for private stories.
|
||||
*/
|
||||
class PrivateStorySettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: PrivateStorySettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
@@ -47,99 +71,55 @@ class PrivateStorySettingsFragment : DSLSettingsFragment(
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(RecipientMappingModel.RecipientIdMappingModel::class.java, LayoutFactory({ RecipientViewHolder(it, RecipientEventListener()) }, R.layout.stories_recipient_item))
|
||||
PrivateStoryItem.register(adapter)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.observeAsState()
|
||||
val callback = remember { DefaultPrivateStorySettingsScreenCallback(viewModel, distributionListId) }
|
||||
|
||||
val toolbar: Toolbar = requireView().findViewById(R.id.toolbar)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.isActionInProgress) {
|
||||
progressDisplayManager.show(viewLifecycleOwner, childFragmentManager)
|
||||
} else {
|
||||
progressDisplayManager.hide()
|
||||
}
|
||||
|
||||
toolbar.title = state.privateStory?.name
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: PrivateStorySettingsState): DSLConfiguration {
|
||||
if (state.privateStory == null) {
|
||||
return configure { }
|
||||
}
|
||||
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.MyStorySettingsFragment__who_can_view_this_story)
|
||||
customPref(
|
||||
PrivateStoryItem.AddViewerModel(
|
||||
onClick = {
|
||||
findNavController().safeNavigate(PrivateStorySettingsFragmentDirections.actionPrivateStorySettingsToEditStoryViewers(distributionListId))
|
||||
}
|
||||
state?.let { currentState ->
|
||||
SignalTheme {
|
||||
PrivateStorySettingsScreen(
|
||||
state = currentState,
|
||||
callback = callback
|
||||
)
|
||||
)
|
||||
|
||||
state.privateStory.members.forEach {
|
||||
customPref(RecipientMappingModel.RecipientIdMappingModel(it))
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
sectionHeaderPref(R.string.MyStorySettingsFragment__replies_amp_reactions)
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.MyStorySettingsFragment__allow_replies_amp_reactions),
|
||||
summary = DSLSettingsText.from(R.string.MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply),
|
||||
isChecked = state.areRepliesAndReactionsEnabled,
|
||||
onClick = {
|
||||
viewModel.setRepliesAndReactionsEnabled(!state.areRepliesAndReactionsEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.PrivateStorySettingsFragment__delete_custom_story, DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_alert_primary))),
|
||||
onClick = {
|
||||
val privateStoryName = viewModel.state.value?.privateStory?.name
|
||||
handleDeletePrivateStory(privateStoryName)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_edit) {
|
||||
inner class DefaultPrivateStorySettingsScreenCallback(
|
||||
private val viewModel: PrivateStorySettingsViewModel,
|
||||
private val distributionListId: DistributionListId
|
||||
) : PrivateStorySettingsScreenCallback {
|
||||
override fun onNavigationClick() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
override fun onEditClick() {
|
||||
val action = PrivateStorySettingsFragmentDirections.actionPrivateStorySettingsToEditStoryNameFragment(distributionListId, viewModel.getName())
|
||||
findNavController().navigate(action)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRemoveRecipient(recipient: Recipient) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.PrivateStorySettingsFragment__remove_s, recipient.getDisplayName(requireContext())))
|
||||
.setMessage(R.string.PrivateStorySettingsFragment__this_person_will_no_longer)
|
||||
.setPositiveButton(R.string.PrivateStorySettingsFragment__remove) { _, _ -> viewModel.remove(recipient) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun handleDeletePrivateStory(privateStoryName: String?) {
|
||||
val name = privateStoryName ?: return
|
||||
|
||||
StoryDialogs.deleteDistributionList(requireContext(), name) {
|
||||
viewModel.delete().subscribe { findNavController().popBackStack() }
|
||||
override fun onAddViewerClick() {
|
||||
findNavController().safeNavigate(PrivateStorySettingsFragmentDirections.actionPrivateStorySettingsToEditStoryViewers(distributionListId))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onToolbarNavigationClicked() {
|
||||
findListener<WrapperDialogFragment>()?.dismiss() ?: super.onToolbarNavigationClicked()
|
||||
}
|
||||
override fun onRepliesAndReactionsToggle(enabled: Boolean) {
|
||||
viewModel.setRepliesAndReactionsEnabled(enabled)
|
||||
}
|
||||
|
||||
inner class RecipientEventListener : RecipientViewHolder.EventListener<RecipientMappingModel.RecipientIdMappingModel> {
|
||||
override fun onClick(recipient: Recipient) {
|
||||
handleRemoveRecipient(recipient)
|
||||
override fun onDeleteStoryClick(storyName: String) {
|
||||
StoryDialogs.deleteDistributionList(requireContext(), storyName) {
|
||||
viewModel.delete().subscribe { findNavController().popBackStack() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoveRecipientClick(recipientId: RecipientId, displayName: String) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.PrivateStorySettingsFragment__remove_s, displayName))
|
||||
.setMessage(R.string.PrivateStorySettingsFragment__this_person_will_no_longer)
|
||||
.setPositiveButton(R.string.PrivateStorySettingsFragment__remove) { _, _ -> viewModel.remove(recipientId) }
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,3 +137,158 @@ class PrivateStorySettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivateStorySettingsScreen(
|
||||
state: PrivateStorySettingsState,
|
||||
callback: PrivateStorySettingsScreenCallback
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = state.privateStory?.name.orEmpty(),
|
||||
onNavigationClick = callback::onNavigationClick,
|
||||
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24),
|
||||
actions = {
|
||||
IconButton(onClick = callback::onEditClick) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_edit_24),
|
||||
contentDescription = stringResource(R.string.EditPrivateStoryNameFragment__edit_story_name)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
if (state.privateStory != null) {
|
||||
item {
|
||||
Texts.SectionHeader(text = stringResource(R.string.MyStorySettingsFragment__who_can_view_this_story))
|
||||
}
|
||||
|
||||
item {
|
||||
PrivateStoryItem.AddViewer(onClick = callback::onAddViewerClick)
|
||||
}
|
||||
|
||||
items(state.privateStory.members, key = { it }) { member ->
|
||||
RecipientRow(member, callback::onRemoveRecipientClick, modifier = Modifier.animateItem())
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Texts.SectionHeader(text = stringResource(R.string.MyStorySettingsFragment__replies_amp_reactions))
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
text = stringResource(R.string.MyStorySettingsFragment__allow_replies_amp_reactions),
|
||||
label = stringResource(R.string.MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply),
|
||||
checked = state.areRepliesAndReactionsEnabled,
|
||||
onCheckChanged = { callback.onRepliesAndReactionsToggle(it) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = stringResource(R.string.PrivateStorySettingsFragment__delete_custom_story),
|
||||
foregroundTint = colorResource(R.color.signal_colorError),
|
||||
onClick = { callback.onDeleteStoryClick(state.privateStory.name) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isActionInProgress) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientRow(recipientId: RecipientId, onClick: (RecipientId, String) -> Unit, modifier: Modifier = Modifier) {
|
||||
val displayName by rememberDisplayName(recipientId)
|
||||
val callback = remember(displayName) { { onClick(recipientId, displayName) } }
|
||||
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(text = displayName)
|
||||
},
|
||||
icon = {
|
||||
AvatarImage(
|
||||
recipientId = recipientId,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
)
|
||||
},
|
||||
onClick = callback,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberDisplayName(recipientId: RecipientId): State<String> {
|
||||
if (LocalInspectionMode.current) {
|
||||
return remember { mutableStateOf("Recipient ${recipientId.toLong()}") }
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
return rememberRecipientField(recipientId) { getDisplayName(context) }
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PrivateStorySettingsScreenPreview() {
|
||||
Previews.Preview {
|
||||
PrivateStorySettingsScreen(
|
||||
state = PrivateStorySettingsState(
|
||||
privateStory = DistributionListRecord(
|
||||
id = DistributionListId.from(1L),
|
||||
name = "Close Friends",
|
||||
distributionId = DistributionId.from(UUID.randomUUID()),
|
||||
allowsReplies = true,
|
||||
rawMembers = listOf(
|
||||
RecipientId.from(1L),
|
||||
RecipientId.from(2L),
|
||||
RecipientId.from(3L)
|
||||
),
|
||||
members = listOf(
|
||||
RecipientId.from(1L),
|
||||
RecipientId.from(2L),
|
||||
RecipientId.from(3L)
|
||||
),
|
||||
deletedAtTimestamp = 0L,
|
||||
isUnknown = false,
|
||||
privacyMode = DistributionListPrivacyMode.ONLY_WITH
|
||||
),
|
||||
areRepliesAndReactionsEnabled = true,
|
||||
isActionInProgress = false
|
||||
),
|
||||
callback = PrivateStorySettingsScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface PrivateStorySettingsScreenCallback {
|
||||
fun onNavigationClick()
|
||||
fun onEditClick()
|
||||
fun onAddViewerClick()
|
||||
fun onRepliesAndReactionsToggle(enabled: Boolean)
|
||||
fun onDeleteStoryClick(storyName: String)
|
||||
fun onRemoveRecipientClick(recipientId: RecipientId, displayName: String)
|
||||
|
||||
object Empty : PrivateStorySettingsScreenCallback {
|
||||
override fun onNavigationClick() = Unit
|
||||
override fun onEditClick() = Unit
|
||||
override fun onAddViewerClick() = Unit
|
||||
override fun onRepliesAndReactionsToggle(enabled: Boolean) = Unit
|
||||
override fun onDeleteStoryClick(storyName: String) = Unit
|
||||
override fun onRemoveRecipientClick(recipientId: RecipientId, displayName: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class PrivateStorySettingsViewModel(private val distributionListId: DistributionListId, private val repository: PrivateStorySettingsRepository) : ViewModel() {
|
||||
@@ -38,8 +38,8 @@ class PrivateStorySettingsViewModel(private val distributionListId: Distribution
|
||||
return store.state.privateStory?.name ?: ""
|
||||
}
|
||||
|
||||
fun remove(recipient: Recipient) {
|
||||
disposables += repository.removeMember(store.state.privateStory!!, recipient.id)
|
||||
fun remove(recipientId: RecipientId) {
|
||||
disposables += repository.removeMember(store.state.privateStory!!, recipientId)
|
||||
.subscribe {
|
||||
refresh()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user