Convert AddAllowedMembersFragment to compose.

This commit is contained in:
Alex Hart
2025-08-19 13:17:30 -03:00
committed by Jeffrey Starke
parent 958dde0f6e
commit ecddf34083
9 changed files with 542 additions and 178 deletions

View File

@@ -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<CircularProgressMaterialButton>(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<Recipient>): 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<RecipientId>) {
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<RecipientId>) = 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
)
}
}

View File

@@ -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<Unit>()
val snackbarRequests: Flow<Unit> = internalSnackbarRequests
fun requestSnackbar() {
viewModelScope.launch {
internalSnackbarRequests.emit(Unit)
}
}
fun getProfile(): Observable<NotificationProfileAndRecipients> {
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<Recipient>)
class Factory(private val profileId: Long) : ViewModelProvider.Factory {

View File

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

View File

@@ -1,57 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import kotlin.math.sqrt
/**
* Circle Reveal Modifiers found here:
* https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
*
* A modifier that clips the composable content using a circular shape. The radius of the circle
* will be determined by the [transitionProgress].
*
* The values of the progress should be between 0 and 1.
*
* By default, the circle is centered in the content, but custom positions may be specified using
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
*/
fun Modifier.circularReveal(
transitionProgress: State<Float>,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier {
return drawWithCache {
val path = Path()
val center = revealFrom.mapTo(size)
val radius = calculateRadius(revealFrom, size)
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
clipPath(path) { this@onDrawWithContent.drawContent() }
}
}
}
private fun Offset.mapTo(size: Size): Offset {
return Offset(x * size.width, y * size.height)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}

View File

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