From 562e4394473caca28604bc9ab208578679e42385 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Sep 2026 12:48:44 +0200 Subject: [PATCH] Show a loading indicator on the placeholders which can stay on screen forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emptyNode() renders nothing at all, which is fine for a placeholder that is left within a few frames, but makes the application look frozen when the wait can last, or never end. Add a delayBeforeShowingContent parameter to emptyNode(): when it is finite, a progress indicator and a "Loading…" label fade in after that delay, reusing the existing DelayedVisibility helper. It defaults to Duration.INFINITE, so the splash screen keeps rendering an empty canvas. Use a one second delay on the two placeholders which are not guaranteed to be left: the FTUE one, rendered while waiting for the session verified status, and the LoggedInFlowNode one, rendered while the FTUE state is unknown. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/appnav/LoggedInFlowNode.kt | 5 +- .../features/ftue/impl/FtueFlowNode.kt | 6 ++- libraries/ui-common/build.gradle.kts | 1 + .../libraries/ui/common/nodes/EmptyNode.kt | 53 +++++++++++++++++-- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index dab9b7828c..4254d66664 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -113,6 +113,7 @@ import java.time.Duration import java.time.Instant import java.util.Optional import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toKotlinDuration @@ -324,7 +325,9 @@ class LoggedInFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Placeholder -> emptyNode(buildContext) + // Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all, + // since there is no guarantee that this state will be left. + NavTarget.Placeholder -> emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) NavTarget.LoggedInPermanent -> { val callback = object : LoggedInNode.Callback { override fun navigateToNotificationTroubleshoot() { diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index 8d66258e12..b3d96a88e5 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.milliseconds @ContributesNode(SessionScope::class) @AssistedInject @@ -84,7 +85,10 @@ class FtueFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Placeholder -> { - emptyNode(buildContext) + // Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState). + // This can last for a while, or forever if the sync service never manages to run, so + // render a loading state rather than nothing at all. + emptyNode(buildContext, delayBeforeShowingContent = 500.milliseconds) } is NavTarget.SessionVerification -> { val callback = object : FtueSessionVerificationFlowNode.Callback { diff --git a/libraries/ui-common/build.gradle.kts b/libraries/ui-common/build.gradle.kts index 76c772f9fc..440892e90d 100644 --- a/libraries/ui-common/build.gradle.kts +++ b/libraries/ui-common/build.gradle.kts @@ -17,4 +17,5 @@ android { dependencies { implementation(libs.appyx.core) implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) } diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt index 53cc8c8443..7d9e851e89 100644 --- a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -9,37 +9,84 @@ package io.element.android.libraries.ui.common.nodes import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.DelayedVisibility +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 + * + * @param buildContext the [BuildContext] of the Node. + * @param delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for + * placeholders which can stay on screen for a long time, or forever, else the application looks frozen. + * Defaults to [Duration.INFINITE], i.e. the Node stays empty. */ fun emptyNode( buildContext: BuildContext, + delayBeforeShowingContent: Duration = Duration.INFINITE, ): Node = node(buildContext) { modifier -> - EmptyView(modifier) + EmptyView( + delayBeforeShowingContent = delayBeforeShowingContent, + modifier = modifier, + ) } @Composable private fun EmptyView( + delayBeforeShowingContent: Duration, modifier: Modifier = Modifier, ) = Box( modifier = modifier .fillMaxSize() .background(ElementTheme.colors.bgCanvasDefault), -) + contentAlignment = Alignment.Center, +) { + if (delayBeforeShowingContent.isFinite()) { + DelayedVisibility(duration = delayBeforeShowingContent) { + LoadingContent() + } + } +} + +@Composable +private fun LoadingContent() = Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), +) { + CircularProgressIndicator() + Text( + text = stringResource(CommonStrings.common_loading), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) +} @PreviewsDayNight @Composable internal fun EmptyViewPreview() = ElementPreview { - EmptyView(Modifier) + EmptyView(delayBeforeShowingContent = Duration.INFINITE) +} + +@PreviewsDayNight +@Composable +internal fun EmptyViewLoadingPreview() = ElementPreview { + EmptyView(delayBeforeShowingContent = 1.seconds) }