mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Show loading and failure feedback when updating user status
This commit is contained in:
+83
-61
@@ -9,9 +9,12 @@
|
|||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -26,7 +29,10 @@ import io.element.android.features.preferences.impl.R
|
|||||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||||
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
@@ -73,72 +79,88 @@ fun PreferencesRootView(
|
|||||||
) {
|
) {
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
||||||
// Include pref from other modules
|
Box(modifier = modifier) {
|
||||||
PreferencePage(
|
// Include pref from other modules
|
||||||
modifier = modifier,
|
PreferencePage(
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
title = stringResource(id = CommonStrings.common_settings),
|
title = stringResource(id = CommonStrings.common_settings),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
) {
|
) {
|
||||||
UserPreferences(
|
UserPreferences(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
onOpenUserProfile(state.myUser)
|
onOpenUserProfile(state.myUser)
|
||||||
},
|
},
|
||||||
matrixUser = state.myUser,
|
matrixUser = state.myUser,
|
||||||
)
|
|
||||||
if (state.isMultiAccountEnabled) {
|
|
||||||
MultiAccountSection(
|
|
||||||
state = state,
|
|
||||||
onAddAccountClick = onAddAccountClick,
|
|
||||||
)
|
)
|
||||||
}
|
if (state.isMultiAccountEnabled) {
|
||||||
if (state.userStatusState != null) {
|
MultiAccountSection(
|
||||||
UserStatusSection(
|
state = state,
|
||||||
userStatusState = state.userStatusState,
|
onAddAccountClick = onAddAccountClick,
|
||||||
emojiPickerRenderer = emojiPickerRenderer,
|
)
|
||||||
showTopDivider = !state.isMultiAccountEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 'Account' section
|
|
||||||
ManageAccountSection(
|
|
||||||
state = state,
|
|
||||||
onManageAccountClick = onManageAccountClick,
|
|
||||||
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
|
||||||
onOpenBlockedUsers = onOpenBlockedUsers
|
|
||||||
)
|
|
||||||
// 'Manage my app' section
|
|
||||||
ManageAppSection(
|
|
||||||
state = state,
|
|
||||||
onOpenNotificationSettings = onOpenNotificationSettings,
|
|
||||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
|
||||||
onSecureBackupClick = onSecureBackupClick,
|
|
||||||
)
|
|
||||||
|
|
||||||
// General section
|
|
||||||
GeneralSection(
|
|
||||||
state = state,
|
|
||||||
onOpenAbout = onOpenAbout,
|
|
||||||
onOpenAnalytics = onOpenAnalytics,
|
|
||||||
onOpenRageShake = onOpenRageShake,
|
|
||||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
|
||||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
|
||||||
onOpenLabs = onOpenLabs,
|
|
||||||
onSignOutClick = onSignOutClick,
|
|
||||||
onDeactivateClick = onDeactivateClick,
|
|
||||||
)
|
|
||||||
// Version
|
|
||||||
Footer(
|
|
||||||
version = state.version,
|
|
||||||
deviceId = state.deviceId,
|
|
||||||
onClick = if (!state.showDeveloperSettings) {
|
|
||||||
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
)
|
if (state.userStatusState != null) {
|
||||||
|
UserStatusSection(
|
||||||
|
userStatusState = state.userStatusState,
|
||||||
|
emojiPickerRenderer = emojiPickerRenderer,
|
||||||
|
showTopDivider = !state.isMultiAccountEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 'Account' section
|
||||||
|
ManageAccountSection(
|
||||||
|
state = state,
|
||||||
|
onManageAccountClick = onManageAccountClick,
|
||||||
|
onLinkNewDeviceClick = onLinkNewDeviceClick,
|
||||||
|
onOpenBlockedUsers = onOpenBlockedUsers
|
||||||
|
)
|
||||||
|
// 'Manage my app' section
|
||||||
|
ManageAppSection(
|
||||||
|
state = state,
|
||||||
|
onOpenNotificationSettings = onOpenNotificationSettings,
|
||||||
|
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||||
|
onSecureBackupClick = onSecureBackupClick,
|
||||||
|
)
|
||||||
|
|
||||||
|
// General section
|
||||||
|
GeneralSection(
|
||||||
|
state = state,
|
||||||
|
onOpenAbout = onOpenAbout,
|
||||||
|
onOpenAnalytics = onOpenAnalytics,
|
||||||
|
onOpenRageShake = onOpenRageShake,
|
||||||
|
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||||
|
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||||
|
onOpenLabs = onOpenLabs,
|
||||||
|
onSignOutClick = onSignOutClick,
|
||||||
|
onDeactivateClick = onDeactivateClick,
|
||||||
|
)
|
||||||
|
// Version
|
||||||
|
Footer(
|
||||||
|
version = state.version,
|
||||||
|
deviceId = state.deviceId,
|
||||||
|
onClick = if (!state.showDeveloperSettings) {
|
||||||
|
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.userStatusState?.let {
|
||||||
|
UserStatusUpdateIndicator(it.updateStatusAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
|
||||||
|
AsyncActionIndicator(
|
||||||
|
asyncAction = updateStatusAction,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.statusBarsPadding(),
|
||||||
|
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
|
||||||
|
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.UserStatusSection(
|
private fun ColumnScope.UserStatusSection(
|
||||||
userStatusState: UserStatusState,
|
userStatusState: UserStatusState,
|
||||||
|
|||||||
+10
-2
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||||
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
@@ -38,6 +40,7 @@ class UserStatusPresenter(
|
|||||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||||
val customTextFieldState = rememberTextFieldState()
|
val customTextFieldState = rememberTextFieldState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
fun handleEvent(event: UserStatusEvent) {
|
fun handleEvent(event: UserStatusEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -47,11 +50,15 @@ class UserStatusPresenter(
|
|||||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||||
is UserStatusEvent.SetStatus -> {
|
is UserStatusEvent.SetStatus -> {
|
||||||
pickerState = UserStatusPickerState.Hidden
|
pickerState = UserStatusPickerState.Hidden
|
||||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
coroutineScope.launch {
|
||||||
|
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UserStatusEvent.ClearStatus -> {
|
UserStatusEvent.ClearStatus -> {
|
||||||
pickerState = UserStatusPickerState.Hidden
|
pickerState = UserStatusPickerState.Hidden
|
||||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
coroutineScope.launch {
|
||||||
|
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UserStatusEvent.OpenCustomInput -> {
|
UserStatusEvent.OpenCustomInput -> {
|
||||||
val raw = userProfile.rawStatus
|
val raw = userProfile.rawStatus
|
||||||
@@ -93,6 +100,7 @@ class UserStatusPresenter(
|
|||||||
displayedStatus = userProfile.displayedStatus,
|
displayedStatus = userProfile.displayedStatus,
|
||||||
rawStatus = userProfile.rawStatus,
|
rawStatus = userProfile.rawStatus,
|
||||||
pickerState = effectivePickerState,
|
pickerState = effectivePickerState,
|
||||||
|
updateStatusAction = updateStatusAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
@@ -19,6 +20,7 @@ data class UserStatusState(
|
|||||||
val displayedStatus: DisplayedStatus?,
|
val displayedStatus: DisplayedStatus?,
|
||||||
val rawStatus: UserStatus?,
|
val rawStatus: UserStatus?,
|
||||||
val pickerState: UserStatusPickerState,
|
val pickerState: UserStatusPickerState,
|
||||||
|
val updateStatusAction: AsyncAction<Unit>,
|
||||||
val eventSink: (UserStatusEvent) -> Unit,
|
val eventSink: (UserStatusEvent) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
|
|||||||
|
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
|
|
||||||
@@ -44,10 +45,12 @@ fun aUserStatusState(
|
|||||||
displayedStatus: DisplayedStatus? = null,
|
displayedStatus: DisplayedStatus? = null,
|
||||||
rawStatus: UserStatus? = null,
|
rawStatus: UserStatus? = null,
|
||||||
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
||||||
|
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (UserStatusEvent) -> Unit = {},
|
eventSink: (UserStatusEvent) -> Unit = {},
|
||||||
) = UserStatusState(
|
) = UserStatusState(
|
||||||
displayedStatus = displayedStatus,
|
displayedStatus = displayedStatus,
|
||||||
rawStatus = rawStatus,
|
rawStatus = rawStatus,
|
||||||
pickerState = pickerState,
|
pickerState = pickerState,
|
||||||
|
updateStatusAction = updateStatusAction,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|||||||
+39
@@ -8,9 +8,11 @@
|
|||||||
package io.element.android.features.preferences.impl.userstatus
|
package io.element.android.features.preferences.impl.userstatus
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
|
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
|
||||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
|
|||||||
val newState = awaitItem()
|
val newState = awaitItem()
|
||||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||||
|
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||||
|
assertThat(client.setUserStatusCalled).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Set event failure surfaces a Failure action`() = runTest {
|
||||||
|
val client = FakeMatrixClient().apply {
|
||||||
|
setUserStatusResult = Result.failure(AN_EXCEPTION)
|
||||||
|
}
|
||||||
|
createPresenter(client).test {
|
||||||
|
val status = UserStatus(emoji = "💬", text = "In a meeting")
|
||||||
|
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||||
|
val newState = awaitItem()
|
||||||
|
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
|
// On failure the SDK does not emit a new profile, so nothing is displayed.
|
||||||
|
assertThat(newState.displayedStatus).isNull()
|
||||||
|
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
assertThat(client.setUserStatusCalled).isTrue()
|
assertThat(client.setUserStatusCalled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
|
|||||||
val clearedState = awaitItem()
|
val clearedState = awaitItem()
|
||||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
assertThat(clearedState.displayedStatus).isNull()
|
assertThat(clearedState.displayedStatus).isNull()
|
||||||
|
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||||
|
assertThat(client.clearUserStatusCalled).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Clear event failure surfaces a Failure action`() = runTest {
|
||||||
|
val client = FakeMatrixClient().apply {
|
||||||
|
clearUserStatusResult = Result.failure(AN_EXCEPTION)
|
||||||
|
}
|
||||||
|
createPresenter(client).test {
|
||||||
|
val status = UserStatus(emoji = "☕", text = "Be right back")
|
||||||
|
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||||
|
awaitItem().eventSink(UserStatusEvent.ClearStatus)
|
||||||
|
val clearedState = awaitItem()
|
||||||
|
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||||
|
// Clear failed, so the previously set status is still displayed.
|
||||||
|
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||||
|
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
assertThat(client.clearUserStatusCalled).isTrue()
|
assertThat(client.clearUserStatusCalled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.components.async
|
||||||
|
|
||||||
|
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.Modifier
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.TimeMark
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val DefaultShowDelay = 300.milliseconds
|
||||||
|
private val DefaultMinDisplayDuration = 500.milliseconds
|
||||||
|
private val DefaultFailureDuration = 500.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
|
||||||
|
*
|
||||||
|
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
|
||||||
|
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
|
||||||
|
* prevents the indicator from flashing for operations that complete almost instantly.
|
||||||
|
*
|
||||||
|
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
|
||||||
|
* never debounced) and auto-dismisses after [failureDuration].
|
||||||
|
*
|
||||||
|
* @param asyncAction the action whose progress drives the indicator.
|
||||||
|
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
|
||||||
|
* @param state the [AsyncIndicatorState] backing the host.
|
||||||
|
* @param showDelay how long the loading state must persist before the indicator is shown.
|
||||||
|
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
|
||||||
|
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
|
||||||
|
* @param loading the composable to display while loading.
|
||||||
|
* @param failure the composable to display on failure.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AsyncActionIndicator(
|
||||||
|
asyncAction: AsyncAction<*>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
|
||||||
|
showDelay: Duration = DefaultShowDelay,
|
||||||
|
minDisplayDuration: Duration = DefaultMinDisplayDuration,
|
||||||
|
failureDuration: Duration = DefaultFailureDuration,
|
||||||
|
loading: @Composable () -> Unit,
|
||||||
|
failure: @Composable (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
AsyncIndicatorHost(modifier = modifier, state = state)
|
||||||
|
AsyncActionIndicatorEffect(
|
||||||
|
asyncAction = asyncAction,
|
||||||
|
state = state,
|
||||||
|
showDelay = showDelay,
|
||||||
|
minDisplayDuration = minDisplayDuration,
|
||||||
|
failureDuration = failureDuration,
|
||||||
|
loading = loading,
|
||||||
|
failure = failure,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AsyncActionIndicatorEffect(
|
||||||
|
asyncAction: AsyncAction<*>,
|
||||||
|
state: AsyncIndicatorState,
|
||||||
|
showDelay: Duration,
|
||||||
|
minDisplayDuration: Duration,
|
||||||
|
failureDuration: Duration,
|
||||||
|
loading: @Composable () -> Unit,
|
||||||
|
failure: @Composable (Throwable) -> Unit,
|
||||||
|
timeSource: TimeSource = TimeSource.Monotonic,
|
||||||
|
) {
|
||||||
|
// Mark set when the loader actually becomes visible, null while it is hidden.
|
||||||
|
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
|
||||||
|
LaunchedEffect(asyncAction) {
|
||||||
|
when (asyncAction) {
|
||||||
|
is AsyncAction.Loading -> {
|
||||||
|
// If the operation completes before this delay elapses, the effect is cancelled
|
||||||
|
// and the loader is never shown.
|
||||||
|
delay(showDelay)
|
||||||
|
state.enqueue(composable = loading)
|
||||||
|
loaderShownAt = timeSource.markNow()
|
||||||
|
}
|
||||||
|
is AsyncAction.Failure -> {
|
||||||
|
// Always surface failures immediately, even if the loader was never shown.
|
||||||
|
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { failure(asyncAction.error) })
|
||||||
|
loaderShownAt = null
|
||||||
|
}
|
||||||
|
is AsyncAction.Success -> {
|
||||||
|
// Hold the loader for its minimum duration before clearing it.
|
||||||
|
loaderShownAt?.let { shownAt ->
|
||||||
|
val remaining = minDisplayDuration - shownAt.elapsedNow()
|
||||||
|
if (remaining > Duration.ZERO) delay(remaining)
|
||||||
|
}
|
||||||
|
state.clear()
|
||||||
|
loaderShownAt = null
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.component.async
|
||||||
|
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import app.cash.molecule.RecompositionMode
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.TestTimeSource
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AsyncActionIndicatorTest {
|
||||||
|
private val showDelay = 300.milliseconds
|
||||||
|
private val minDisplay = 500.milliseconds
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loader is not shown before the show delay elapses`() = runTest {
|
||||||
|
val state = AsyncIndicatorState()
|
||||||
|
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||||
|
indicatorFlow(action, state).test {
|
||||||
|
action.value = AsyncAction.Loading
|
||||||
|
advanceTimeBy(100.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||||
|
// Once the show delay has fully elapsed, the loader is shown.
|
||||||
|
advanceTimeBy(300.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loader is never shown when the operation completes before the show delay`() = runTest {
|
||||||
|
val state = AsyncIndicatorState()
|
||||||
|
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||||
|
indicatorFlow(action, state).test {
|
||||||
|
action.value = AsyncAction.Loading
|
||||||
|
advanceTimeBy(100.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
action.value = AsyncAction.Success(Unit)
|
||||||
|
advanceTimeBy(1000.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
|
||||||
|
val state = AsyncIndicatorState()
|
||||||
|
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||||
|
indicatorFlow(action, state).test {
|
||||||
|
action.value = AsyncAction.Failure(Exception("failed"))
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loader stays visible for the minimum duration once shown`() = runTest {
|
||||||
|
val state = AsyncIndicatorState()
|
||||||
|
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||||
|
val timeSource = TestTimeSource()
|
||||||
|
indicatorFlow(action, state, timeSource).test {
|
||||||
|
// Show the loader.
|
||||||
|
action.value = AsyncAction.Loading
|
||||||
|
advanceTimeBy(400.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||||
|
// 200ms of display has already elapsed when the operation succeeds.
|
||||||
|
timeSource += 200.milliseconds
|
||||||
|
action.value = AsyncAction.Success(Unit)
|
||||||
|
runCurrent()
|
||||||
|
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||||
|
// Past the minimum display duration -> cleared.
|
||||||
|
advanceTimeBy(200.milliseconds)
|
||||||
|
runCurrent()
|
||||||
|
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun indicatorFlow(
|
||||||
|
action: State<AsyncAction<*>>,
|
||||||
|
state: AsyncIndicatorState,
|
||||||
|
timeSource: TimeSource = TimeSource.Monotonic,
|
||||||
|
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
AsyncActionIndicatorEffect(
|
||||||
|
asyncAction = action.value,
|
||||||
|
state = state,
|
||||||
|
showDelay = showDelay,
|
||||||
|
minDisplayDuration = minDisplay,
|
||||||
|
failureDuration = minDisplay,
|
||||||
|
loading = {},
|
||||||
|
failure = {},
|
||||||
|
timeSource = timeSource,
|
||||||
|
)
|
||||||
|
state.currentAnimationState.targetState
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user