diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt index 5c3db23ed4..7d0a532cfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarImage.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStoryItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStoryItem.kt index a55423bb39..5fdef7cb30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStoryItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStoryItem.kt @@ -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 {} + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt index ba74835877..ff676fcdad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsFragment.kt @@ -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()?.dismiss() ?: super.onToolbarNavigationClicked() - } + override fun onRepliesAndReactionsToggle(enabled: Boolean) { + viewModel.setRepliesAndReactionsEnabled(enabled) + } - inner class RecipientEventListener : RecipientViewHolder.EventListener { - 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 { + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt index 93e4acbbd1..ba51c9400d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsViewModel.kt @@ -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() } diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index a6eb1dbbd9..6705214a15 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -203,7 +203,8 @@ object Dialogs { onDismissRequest: () -> Unit = {} ) { Dialog( - onDismissRequest = onDismissRequest + onDismissRequest = onDismissRequest, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) ) { Surface( modifier = Modifier.size(100.dp),