From b36c330adadcab6cbdf5d55f11d4652fd52d0b7d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 21:01:52 +0200 Subject: [PATCH] feat(components): step progress for the onboarding scaffold A multi-step flow passes a progress slot, pinned above the scrolling content, and OnboardingProgress draws it as one capsule per step with the caller's own localised label. topSpacing lets a step whose hero is a large picture rather than a mark keep the content on one screen. --- .../floret/components/Onboarding.kt | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt index e56348f..f8eb277 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.floret.components +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -22,10 +24,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** MD3 8dp spacing scale shared by the onboarding screens. */ @@ -43,17 +48,35 @@ object OnboardingSpace { * bar). The content slot is centred horizontally; benefit rows fill the width so * their own content left-aligns. Each app supplies its own hero so the apps read * as one family while keeping their own mark. + * + * A multi-step flow passes [progress] (see [OnboardingProgress]), which is + * pinned above the scrolling content. [topSpacing] gives a step whose hero is a + * large picture rather than a mark room to breathe without overflowing. */ @Composable fun OnboardingScaffold( hero: @Composable () -> Unit, actions: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, + progress: (@Composable () -> Unit)? = null, + topSpacing: Dp = OnboardingSpace.xl, body: @Composable ColumnScope.() -> Unit, ) { Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surface, + topBar = { + if (progress != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), + ) { + progress() + } + } + }, bottomBar = { Column( modifier = Modifier @@ -74,7 +97,7 @@ fun OnboardingScaffold( .padding(horizontal = OnboardingSpace.md), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(Modifier.height(OnboardingSpace.xl)) + Spacer(Modifier.height(topSpacing)) hero() Spacer(Modifier.height(OnboardingSpace.lg)) body() @@ -83,6 +106,60 @@ fun OnboardingScaffold( } } +/** + * Where a multi-step onboarding flow has got to: one capsule per step, filled up + * to and including [step] (1-based), with [label] — the caller's own localised + * "Step 2 of 4" — underneath. + * + * The capsules carry no semantics of their own; [label] is the accessible + * description, so a screen reader hears the position once rather than counting + * decorations. + */ +@Composable +fun OnboardingProgress( + step: Int, + total: Int, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clearAndSetSemantics { }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(total) { index -> + val done = index < step + val color by animateColorAsState( + targetValue = if (done) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + label = "onboarding-progress-segment", + ) + Box( + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(CircleShape) + .background(color), + ) + } + } + Spacer(Modifier.height(OnboardingSpace.xs)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** One trust point: a tonal icon chip on the left, title + supporting text right. */ @Composable fun BenefitRow(icon: ImageVector, title: String, body: String) {