Add loading state to toggle switch and enforce when changing call link admin settings.
This commit is contained in:
parent
90fdcbf7b6
commit
8da7ef9a3e
7 changed files with 135 additions and 21 deletions
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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> {
|
||||
|
|
36
core-ui/src/main/java/org/signal/core/ui/DelayedState.kt
Normal file
36
core-ui/src/main/java/org/signal/core/ui/DelayedState.kt
Normal 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
|
||||
}
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Reference in a new issue