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

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

View File

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="56dp"
android:theme="?attr/settingsToolbarStyle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationContentDescription="@string/DSLSettingsToolbar__navigate_up"
app:navigationIcon="@drawable/symbol_arrow_start_24"
app:titleTextAppearance="@style/Signal.Text.Title" />
<TextView
android:id="@+id/edit_notification_profile_schedule_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="24dp"
android:gravity="center"
android:text="@string/AddAllowedMembers__allowed_notifications"
android:textAppearance="@style/TextAppearance.Signal.Title1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<TextView
android:id="@+id/edit_notification_profile_schedule_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:gravity="center"
android:text="@string/AddAllowedMembers__add_people_and_groups_you_want_notifications_and_calls_from_when_this_profile_is_on"
android:textAppearance="@style/TextAppearance.Signal.Body1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/edit_notification_profile_schedule_title" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingBottom="60dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/edit_notification_profile_schedule_description" />
<View
android:id="@+id/toolbar_shadow"
android:layout_width="match_parent"
android:layout_height="5dp"
android:alpha="0"
android:background="@drawable/toolbar_shadow"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/recycler" />
<org.thoughtcrime.securesms.util.views.CircularProgressMaterialButton
android:id="@+id/add_allowed_members_profile_next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:circularProgressMaterialButton__label="@string/EditNotificationProfileFragment__next"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -973,8 +973,7 @@
<fragment
android:id="@+id/addAllowedMembersFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersFragment"
tools:layout="@layout/fragment_add_allowed_members">
android:name="org.thoughtcrime.securesms.components.settings.app.notifications.profiles.AddAllowedMembersFragment">
<argument
android:name="profileId"

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
/**
* Utilizes a circular reveal animation to display and hide the content given.
* When the content is hidden via settings [isLoading] to true, we display a
* circular progress indicator.
*
* This component will automatically size itself according to the content passed
* in via [content]
*/
@Composable
fun CircularProgressWrapper(
isLoading: Boolean,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
var size by remember { mutableStateOf(IntSize.Zero) }
val dpSize = with(LocalDensity.current) {
DpSize(size.width.toDp(), size.height.toDp())
}
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(dpSize)
) {
CircularProgressIndicator()
}
}
AnimatedVisibility(
visible = !isLoading,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
val visibility = transition.animateFloat(
transitionSpec = { tween(durationMillis = 400, easing = LinearOutSlowInEasing) },
label = "CircularProgressWrapper-Visibility"
) { state ->
if (state == EnterExitState.Visible) 1f else 0f
}
Box(
modifier = Modifier
.onSizeChanged { s ->
size = s
}
.circularReveal(visibility)
) {
content()
}
}
}
}
@SignalPreview
@Composable
fun CircularProgressWrapperPreview() {
var isLoading by remember {
mutableStateOf(false)
}
LaunchedEffect(isLoading) {
if (isLoading) {
delay(3.seconds)
isLoading = false
}
}
Previews.Preview {
CircularProgressWrapper(
isLoading = isLoading,
content = {
Buttons.LargeTonal(onClick = {
isLoading = true
}) {
Text(text = "Next")
}
}
)
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
package org.signal.core.ui.compose
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier

View File

@@ -156,6 +156,7 @@ object Rows {
onCheckChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
icon: ImageVector? = null,
textColor: Color = MaterialTheme.colorScheme.onSurface,
isLoading: Boolean = false
) {
@@ -168,6 +169,15 @@ object Rows {
.padding(defaultPadding()),
verticalAlignment = CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null
)
Spacer(modifier = Modifier.width(24.dp))
}
TextAndLabel(
text = text,
label = label,