From ecddf3408349adb1f3f240f0dcc520e6219b77dc Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 19 Aug 2025 13:17:30 -0300 Subject: [PATCH] Convert AddAllowedMembersFragment to compose. --- .../profiles/AddAllowedMembersFragment.kt | 332 +++++++++++++----- .../profiles/AddAllowedMembersViewModel.kt | 16 + .../profiles/NotificationProfileComponents.kt | 149 ++++++++ .../securesms/main/MainToolbar.kt | 1 + .../layout/fragment_add_allowed_members.xml | 83 ----- .../app_settings_with_change_number.xml | 3 +- .../ui/compose/CircularProgressWrapper.kt | 124 +++++++ .../ui/compose}/CircularRevealModifiers.kt | 2 +- .../java/org/signal/core/ui/compose/Rows.kt | 10 + 9 files changed, 542 insertions(+), 178 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfileComponents.kt delete mode 100644 app/src/main/res/layout/fragment_add_allowed_members.xml create mode 100644 core-ui/src/main/java/org/signal/core/ui/compose/CircularProgressWrapper.kt rename {app/src/main/java/org/thoughtcrime/securesms/main => core-ui/src/main/java/org/signal/core/ui/compose}/CircularRevealModifiers.kt (97%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersFragment.kt index 540661a871..d71d04dfbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersFragment.kt @@ -2,32 +2,57 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profile import android.os.Bundle import android.view.View +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.rx3.asFlow +import org.signal.core.ui.compose.Buttons +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.SignalPreview +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.Texts +import org.signal.core.ui.compose.horizontalGutters import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers -import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference +import org.thoughtcrime.securesms.components.emoji.EmojiStrings +import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersViewModel.NotificationProfileAndRecipients +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileId +import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton +import java.util.UUID /** * Show and allow addition of recipients to a profile during the create flow. */ -class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragment_add_allowed_members) { +class AddAllowedMembersFragment : ComposeFragment() { private val viewModel: AddAllowedMembersViewModel by viewModels(factoryProducer = { AddAllowedMembersViewModel.Factory(profileId) }) private val lifecycleDisposable = LifecycleDisposable() @@ -37,90 +62,18 @@ class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragme super.onViewCreated(view, savedInstanceState) lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle) - - view.findViewById(R.id.add_allowed_members_profile_next).apply { - setOnClickListener { - findNavController().safeNavigate(AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToEditNotificationProfileScheduleFragment(profileId, true)) - } - } } - override fun bindAdapter(adapter: MappingAdapter) { - NotificationProfileAddMembers.register(adapter) - NotificationProfileRecipient.register(adapter) + @Composable + override fun FragmentContent() { + val state by remember { viewModel.getProfile().map { GetProfileResult.Ready(it) }.asFlow() } + .collectAsStateWithLifecycle(GetProfileResult.Loading) + val callbacks = remember { Callbacks() } - lifecycleDisposable += viewModel.getProfile() - .subscribeBy( - onNext = { (profile, recipients) -> - adapter.submitList(getConfiguration(profile, recipients).toMappingModelList()) - } - ) - } - - private fun getConfiguration(profile: NotificationProfile, recipients: List): DSLConfiguration { - return configure { - sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications) - - customPref( - NotificationProfileAddMembers.Model( - onClick = { id, currentSelection -> - findNavController().safeNavigate( - AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToSelectRecipientsFragment(id) - .setCurrentSelection(currentSelection.toTypedArray()) - ) - }, - profileId = profile.id, - currentSelection = profile.allowedMembers - ) - ) - - for (member in recipients) { - customPref( - NotificationProfileRecipient.Model( - recipientModel = RecipientPreference.Model( - recipient = member, - onClick = {} - ), - onRemoveClick = { id -> - lifecycleDisposable += viewModel.removeMember(id) - .subscribeBy( - onSuccess = { removed -> - view?.let { view -> - Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG) - .setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) } - .show() - } - } - ) - } - ) - ) - } - - sectionHeaderPref(R.string.AddAllowedMembers__exceptions) - - switchPref( - title = DSLSettingsText.from(R.string.AddAllowedMembers__allow_all_calls), - icon = DSLSettingsIcon.from(R.drawable.symbol_phone_24), - isChecked = profile.allowAllCalls, - onClick = { - lifecycleDisposable += viewModel.toggleAllowAllCalls() - .subscribeBy( - onError = { Log.w(TAG, "Error updating profile", it) } - ) - } - ) - - switchPref( - title = DSLSettingsText.from(R.string.AddAllowedMembers__notify_for_all_mentions), - icon = DSLSettingsIcon.from(R.drawable.symbol_at_24), - isChecked = profile.allowAllMentions, - onClick = { - lifecycleDisposable += viewModel.toggleAllowAllMentions() - .subscribeBy( - onError = { Log.w(TAG, "Error updating profile", it) } - ) - } + if (state is GetProfileResult.Ready) { + AddAllowedMembersContent( + state = (state as GetProfileResult.Ready).notificationProfileAndRecipients, + callbacks = callbacks ) } } @@ -133,4 +86,199 @@ class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragme companion object { private val TAG = Log.tag(AddAllowedMembersFragment::class.java) } + + private inner class Callbacks : AddAllowedMembersCallbacks { + override fun onNavigationClick() { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + override fun onAllowAllCallsChanged(enabled: Boolean) { + lifecycleDisposable += viewModel.toggleAllowAllCalls() + .subscribeBy( + onError = { Log.w(TAG, "Error updating profile", it) } + ) + } + + override fun onNotifyForAllMentionsChanged(enabled: Boolean) { + lifecycleDisposable += viewModel.toggleAllowAllMentions() + .subscribeBy( + onError = { Log.w(TAG, "Error updating profile", it) } + ) + } + + override fun onAddMembersClick(id: Long, allowedMembers: Set) { + findNavController().safeNavigate( + AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToSelectRecipientsFragment(id) + .setCurrentSelection(allowedMembers.toTypedArray()) + ) + } + + override fun onRemoveMemberClick(id: RecipientId) { + lifecycleDisposable += viewModel.removeMember(id) + .subscribeBy( + onSuccess = { removed -> + view?.let { view -> + Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG) + .setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) } + .show() + } + } + ) + } + + override fun onNextClick() { + findNavController().safeNavigate(AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToEditNotificationProfileScheduleFragment(profileId, true)) + } + } +} + +private sealed interface GetProfileResult { + data object Loading : GetProfileResult + data class Ready(val notificationProfileAndRecipients: NotificationProfileAndRecipients) : GetProfileResult +} + +private interface AddAllowedMembersCallbacks { + fun onNavigationClick() = Unit + fun onAllowAllCallsChanged(enabled: Boolean) = Unit + fun onNotifyForAllMentionsChanged(enabled: Boolean) = Unit + fun onAddMembersClick(id: Long, allowedMembers: Set) = Unit + fun onRemoveMemberClick(id: RecipientId) = Unit + fun onNextClick() = Unit + + object Empty : AddAllowedMembersCallbacks +} + +@Composable +private fun AddAllowedMembersContent( + state: NotificationProfileAndRecipients, + callbacks: AddAllowedMembersCallbacks, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + Scaffolds.Settings( + title = "", + onNavigationClick = callbacks::onNavigationClick, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), + snackbarHost = { + Snackbars.Host(snackbarHostState) + } + ) { contentPadding -> + Column( + modifier = Modifier.padding(contentPadding) + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + Text( + text = stringResource(R.string.AddAllowedMembers__allowed_notifications), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier + .horizontalGutters() + .padding(top = 20.dp) + .fillMaxWidth() + ) + + Text( + text = stringResource(R.string.AddAllowedMembers__add_people_and_groups_you_want_notifications_and_calls_from_when_this_profile_is_on), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .horizontalGutters() + .padding(top = 12.dp, bottom = 24.dp) + .fillMaxWidth() + ) + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.AddAllowedMembers__allowed_notifications) + ) + } + + item { + val callback = remember(state.profile.id, state.profile.allowedMembers) { + { + callbacks.onAddMembersClick(state.profile.id, state.profile.allowedMembers) + } + } + + NotificationProfileAddMembers(onClick = callback) + } + + for (member in state.recipients) { + item(key = member.id) { + NotificationProfileRecipient( + recipient = member, + onRemoveClick = callbacks::onRemoveMemberClick + ) + } + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.AddAllowedMembers__exceptions) + ) + } + + item { + Rows.ToggleRow( + checked = state.profile.allowAllCalls, + text = stringResource(R.string.AddAllowedMembers__allow_all_calls), + icon = ImageVector.vectorResource(R.drawable.symbol_phone_24), + onCheckChanged = callbacks::onAllowAllCallsChanged + ) + } + + item { + Rows.ToggleRow( + checked = state.profile.allowAllMentions, + text = stringResource(R.string.AddAllowedMembers__notify_for_all_mentions), + icon = ImageVector.vectorResource(R.drawable.symbol_at_24), + onCheckChanged = callbacks::onNotifyForAllMentionsChanged + ) + } + } + + Buttons.LargeTonal( + onClick = callbacks::onNextClick, + modifier = Modifier + .align(Alignment.End) + .padding(16.dp) + ) { + Text(text = stringResource(R.string.EditNotificationProfileFragment__next)) + } + } + } +} + +@SignalPreview +@Composable +private fun AddAllowedMembersContentPreview() { + Previews.Preview { + AddAllowedMembersContent( + state = NotificationProfileAndRecipients( + profile = NotificationProfile( + id = 0L, + name = "Test Profile", + emoji = EmojiStrings.PHOTO, + createdAt = System.currentTimeMillis(), + schedule = NotificationProfileSchedule( + id = 0L + ), + notificationProfileId = NotificationProfileId(UUID.randomUUID()) + ), + recipients = (1..3).map { + Recipient( + id = RecipientId.from(it.toLong()), + isResolving = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + systemContactName = "Test User $it" + ) + } + ), + callbacks = AddAllowedMembersCallbacks.Empty + ) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersViewModel.kt index 2248f46a55..b86d2a9d18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/AddAllowedMembersViewModel.kt @@ -1,16 +1,31 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profiles +import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId class AddAllowedMembersViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() { + private val internalSnackbarRequests = MutableSharedFlow() + + val snackbarRequests: Flow = internalSnackbarRequests + + fun requestSnackbar() { + viewModelScope.launch { + internalSnackbarRequests.emit(Unit) + } + } + fun getProfile(): Observable { return repository.getProfile(profileId) .map { profile -> @@ -40,6 +55,7 @@ class AddAllowedMembersViewModel(private val profileId: Long, private val reposi .observeOn(AndroidSchedulers.mainThread()) } + @Immutable data class NotificationProfileAndRecipients(val profile: NotificationProfile, val recipients: List) class Factory(private val profileId: Long) : ViewModelProvider.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfileComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfileComponents.kt new file mode 100644 index 0000000000..98e3dabeec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/NotificationProfileComponents.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.notifications.profiles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +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 org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.horizontalGutters +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.components.emoji.Emojifier +import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageMedium +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.rememberRecipientField + +@Composable +fun NotificationProfileAddMembers( + onClick: () -> Unit +) { + Rows.TextRow( + icon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .background( + color = SignalTheme.colors.colorSurface1, + shape = CircleShape + ) + .padding(8.dp) + ) + }, + text = { + Text( + text = stringResource(R.string.AddAllowedMembers__add_people_or_groups), + style = MaterialTheme.typography.bodyLarge + ) + }, + onClick = onClick + ) +} + +@Composable +fun NotificationProfileRecipient( + recipient: Recipient, + onRemoveClick: (RecipientId) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .horizontalGutters() + ) { + val context = LocalContext.current + val featuredBadge by rememberRecipientField(recipient) { recipient.featuredBadge } + val displayName by rememberRecipientField(recipient) { recipient.getDisplayName(context) } + + Box( + modifier = Modifier.padding(top = 6.dp) + ) { + AvatarImage( + recipient = recipient, + modifier = Modifier.size(40.dp) + ) + + BadgeImageMedium( + badge = featuredBadge, + modifier = Modifier + .padding(top = 22.dp, start = 20.dp) + .size(24.dp) + ) + } + + Spacer(modifier = Modifier.size(20.dp)) + + Emojifier(displayName) { string, map -> + Text( + text = string, + inlineContent = map, + modifier = Modifier.weight(1f) + ) + } + + IconButton(onClick = { + onRemoveClick(recipient.id) + }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_minus_circle_20), + contentDescription = stringResource(R.string.delete), + tint = colorResource(R.color.core_grey_45) + ) + } + } +} + +@SignalPreview +@Composable +fun NotificationProfileAddMembersPreview() { + Previews.Preview { + NotificationProfileAddMembers( + onClick = {} + ) + } +} + +@SignalPreview +@Composable +fun NotificationProfileRecipientPreview() { + Previews.Preview { + NotificationProfileRecipient( + recipient = Recipient( + id = RecipientId.from(1L), + isResolving = false, + registeredValue = RecipientTable.RegisteredState.REGISTERED, + systemContactName = "Miles Morales" + ), + onRemoveClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index f2c6441c42..1367351634 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -73,6 +73,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.TextFields import org.signal.core.ui.compose.Tooltips +import org.signal.core.ui.compose.circularReveal import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.calls.log.CallLogFilter diff --git a/app/src/main/res/layout/fragment_add_allowed_members.xml b/app/src/main/res/layout/fragment_add_allowed_members.xml deleted file mode 100644 index de6be97a14..0000000000 --- a/app/src/main/res/layout/fragment_add_allowed_members.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 4f9834bfba..bc0127110e 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -973,8 +973,7 @@ + android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersFragment">