Add loading state to toggle switch and enforce when changing call link admin settings.

This commit is contained in:
Alex Hart 2025-01-07 16:10:14 -04:00 committed by Greyson Parrelli
parent 90fdcbf7b6
commit 8da7ef9a3e
7 changed files with 135 additions and 21 deletions

View file

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

View file

@ -53,6 +53,9 @@ class CreateCallLinkViewModel(
private val internalShowAlreadyInACall = MutableStateFlow(false)
val showAlreadyInACall: StateFlow<Boolean> = internalShowAlreadyInACall
private val internalIsLoadingAdminApprovalChange = MutableStateFlow(false)
val isLoadingAdminApprovalChange: StateFlow<Boolean> = 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<UpdateCallLinkResult> {

View file

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

View file

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

View file

@ -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<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
val state: State<CallLinkDetailsState> = _state
private val _state: MutableStateFlow<CallLinkDetailsState> = MutableStateFlow(CallLinkDetailsState())
val state: StateFlow<CallLinkDetailsState> = _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<UpdateCallLinkResult> {
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<UpdateCallLinkResult> {

View file

@ -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 <T> rememberDelayedState(
key: T,
delayDuration: Duration = 200.milliseconds
): State<T> {
val delayedState = remember { mutableStateOf(key) }
LaunchedEffect(key, delayDuration) {
delay(delayDuration)
delayedState.value = key
}
return delayedState
}

View file

@ -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() {