Convert private story settings fragment to compose.

This commit is contained in:
Alex Hart
2025-10-29 13:02:23 -03:00
committed by jeffrey-signal
parent 24c8501985
commit 55040091af
5 changed files with 301 additions and 105 deletions

View File

@@ -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,

View File

@@ -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 {}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}