Show loading and failure feedback when updating user status

This commit is contained in:
ganfra
2026-08-06 16:15:44 +02:00
parent c35a0a3864
commit a7d2b86fe0
7 changed files with 369 additions and 63 deletions
@@ -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
}
}
}
@@ -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
}
}