From 8da7ef9a3e36ab2fa511d0c66645410c5b360d21 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 7 Jan 2025 16:10:14 -0400 Subject: [PATCH] Add loading state to toggle switch and enforce when changing call link admin settings. --- ...CreateCallLinkBottomSheetDialogFragment.kt | 11 ++- .../links/create/CreateCallLinkViewModel.kt | 9 +++ .../links/details/CallLinkDetailsFragment.kt | 6 +- .../links/details/CallLinkDetailsState.kt | 1 + .../links/details/CallLinkDetailsViewModel.kt | 24 ++++--- .../java/org/signal/core/ui/DelayedState.kt | 36 ++++++++++ .../src/main/java/org/signal/core/ui/Rows.kt | 69 +++++++++++++++++-- 7 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 core-ui/src/main/java/org/signal/core/ui/DelayedState.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt index 83e880720a..79bcdbf37d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkBottomSheetDialogFragment.kt @@ -89,6 +89,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment override fun SheetContent() { val callLink: CallLinkTable.CallLink by viewModel.callLink val displayAlreadyInACallSnackbar: Boolean by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(false) + val isLoadingAdminApprovalChange: Boolean by viewModel.isLoadingAdminApprovalChange.collectAsStateWithLifecycle(false) CreateCallLinkBottomSheetContent( callLink = callLink, @@ -100,7 +101,8 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment onCopyLinkClicked = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked, onShareLinkClicked = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked, onDoneClicked = this@CreateCallLinkBottomSheetDialogFragment::onDoneClicked, - displayAlreadyInACallSnackbar = displayAlreadyInACallSnackbar + displayAlreadyInACallSnackbar = displayAlreadyInACallSnackbar, + isLoadingAdminApprovalChange = isLoadingAdminApprovalChange ) } @@ -236,6 +238,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment private fun CreateCallLinkBottomSheetContent( callLink: CallLinkTable.CallLink, displayAlreadyInACallSnackbar: Boolean, + isLoadingAdminApprovalChange: Boolean, onJoinClicked: () -> Unit = {}, onAddACallNameClicked: () -> Unit = {}, onApproveAllMembersChanged: (Boolean) -> Unit = {}, @@ -288,7 +291,8 @@ private fun CreateCallLinkBottomSheetContent( checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL, text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__require_admin_approval), onCheckChanged = onApproveAllMembersChanged, - modifier = Modifier.clickable(onClick = onToggleApproveAllMembersClicked) + modifier = Modifier.clickable(onClick = onToggleApproveAllMembersClicked), + isLoading = isLoadingAdminApprovalChange ) Dividers.Default() @@ -347,7 +351,8 @@ private fun CreateCallLinkBottomSheetContentPreview() { ), deletionTimestamp = 0L ), - displayAlreadyInACallSnackbar = true + displayAlreadyInACallSnackbar = true, + isLoadingAdminApprovalChange = false ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt index 8a89bcce51..b7e98f9698 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt @@ -53,6 +53,9 @@ class CreateCallLinkViewModel( private val internalShowAlreadyInACall = MutableStateFlow(false) val showAlreadyInACall: StateFlow = internalShowAlreadyInACall + private val internalIsLoadingAdminApprovalChange = MutableStateFlow(false) + val isLoadingAdminApprovalChange: StateFlow = internalIsLoadingAdminApprovalChange + private val disposables = CompositeDisposable() init { @@ -88,6 +91,12 @@ class CreateCallLinkViewModel( } } .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe { + internalIsLoadingAdminApprovalChange.update { true } + } + .doFinally { + internalIsLoadingAdminApprovalChange.update { false } + } } fun toggleApproveAllMembers(): Single { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt index 1d69f29137..f2b92a93b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt @@ -81,7 +81,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback { @Composable override fun FragmentContent() { - val state by viewModel.state + val state by viewModel.state.collectAsStateWithLifecycle() val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(false) CallLinkDetails( @@ -236,6 +236,7 @@ private fun CallLinkDetailsPreview() { SignalTheme(false) { CallLinkDetails( CallLinkDetailsState( + false, false, callLink ), @@ -297,7 +298,8 @@ private fun CallLinkDetails( Rows.ToggleRow( checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL, text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval), - onCheckChanged = callback::onApproveAllMembersChanged + onCheckChanged = callback::onApproveAllMembersChanged, + isLoading = state.isLoadingAdminApprovalChange ) Dividers.Default() diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt index 81af3bea95..08bd847ba4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.service.webrtc.CallLinkPeekInfo @Immutable data class CallLinkDetailsState( val displayRevocationDialog: Boolean = false, + val isLoadingAdminApprovalChange: Boolean = false, val callLink: CallLinkTable.CallLink? = null, val peekInfo: CallLinkPeekInfo? = null ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt index e841892a28..93863da59d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt @@ -5,9 +5,6 @@ package org.thoughtcrime.securesms.calls.links.details -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -35,8 +32,8 @@ class CallLinkDetailsViewModel( ) : ViewModel() { private val disposables = CompositeDisposable() - private val _state: MutableState = mutableStateOf(CallLinkDetailsState()) - val state: State = _state + private val _state: MutableStateFlow = MutableStateFlow(CallLinkDetailsState()) + val state: StateFlow = _state val nameSnapshot: String get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.") @@ -55,8 +52,8 @@ class CallLinkDetailsViewModel( disposables += CallLinks.watchCallLink(callLinkRoomId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - _state.value = _state.value.copy(callLink = it) + .subscribeBy { callLink -> + _state.update { it.copy(callLink = callLink) } } disposables += repository @@ -77,7 +74,7 @@ class CallLinkDetailsViewModel( .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { callLinkPeekInfo -> - _state.value = _state.value.copy(peekInfo = callLinkPeekInfo) + _state.update { it.copy(peekInfo = callLinkPeekInfo) } } } @@ -91,12 +88,19 @@ class CallLinkDetailsViewModel( } fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) { - _state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog) + _state.update { it.copy(displayRevocationDialog = displayRevocationDialog) } } fun setApproveAllMembers(approveAllMembers: Boolean): Single { val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.") - return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE) + return mutationRepository + .setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE) + .doOnSubscribe { + _state.update { it.copy(isLoadingAdminApprovalChange = true) } + } + .doFinally { + _state.update { it.copy(isLoadingAdminApprovalChange = false) } + } } fun setName(name: String): Single { diff --git a/core-ui/src/main/java/org/signal/core/ui/DelayedState.kt b/core-ui/src/main/java/org/signal/core/ui/DelayedState.kt new file mode 100644 index 0000000000..a9e1fd39c9 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/DelayedState.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Delays setting the state to [key] for the given [delayDuration]. + * + * Useful for reducing animation flickering when displaying loading indicators + * when the process may finish immediately or may take a bit of time. + */ +@Composable +fun rememberDelayedState( + key: T, + delayDuration: Duration = 200.milliseconds +): State { + val delayedState = remember { mutableStateOf(key) } + + LaunchedEffect(key, delayDuration) { + delay(delayDuration) + delayedState.value = key + } + + return delayedState +} diff --git a/core-ui/src/main/java/org/signal/core/ui/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/Rows.kt index c98f13ece4..6d1fca7899 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Rows.kt @@ -1,5 +1,10 @@ package org.signal.core.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -11,11 +16,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -88,6 +95,9 @@ object Rows { /** * Row that positions [text] and optional [label] in a [TextAndLabel] to the side of a [Switch]. + * + * Can display a circular loading indicator by setting isLoaded to true. Setting isLoading to true + * will disable the control by default. */ @Composable fun ToggleRow( @@ -97,7 +107,8 @@ object Rows { modifier: Modifier = Modifier, label: String? = null, textColor: Color = MaterialTheme.colorScheme.onSurface, - enabled: Boolean = true + isLoading: Boolean = false, + enabled: Boolean = !isLoading ) { Row( modifier = modifier @@ -114,11 +125,32 @@ object Rows { modifier = Modifier.padding(end = 16.dp) ) - Switch( - checked = checked, - enabled = enabled, - onCheckedChange = onCheckChanged - ) + val loadingContent by rememberDelayedState(isLoading) + val toggleState = remember(checked, loadingContent, enabled, onCheckChanged) { + ToggleState(checked, loadingContent, enabled, onCheckChanged) + } + + AnimatedContent( + toggleState, + label = "toggle-loading-state", + contentKey = { it.isLoading }, + transitionSpec = { + fadeIn(animationSpec = tween(220, delayMillis = 90)) + .togetherWith(fadeOut(animationSpec = tween(90))) + } + ) { state -> + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.minimumInteractiveComponentSize() + ) + } else { + Switch( + checked = state.checked, + enabled = state.enabled, + onCheckedChange = state.onCheckChanged + ) + } + } } } @@ -239,6 +271,13 @@ object Rows { } } +private data class ToggleState( + val checked: Boolean, + val isLoading: Boolean, + val enabled: Boolean, + val onCheckChanged: (Boolean) -> Unit +) + @SignalPreview @Composable private fun RadioRowPreview() { @@ -273,6 +312,24 @@ private fun ToggleRowPreview() { } } +@SignalPreview +@Composable +private fun ToggleLoadingRowPreview() { + Previews.Preview { + var checked by remember { mutableStateOf(false) } + + Rows.ToggleRow( + checked = checked, + text = "ToggleRow", + label = "ToggleRow label", + isLoading = true, + onCheckChanged = { + checked = it + } + ) + } +} + @SignalPreview @Composable private fun TextRowPreview() {