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.
This commit is contained in:
2026-08-08 21:01:52 +02:00
parent ea860ed781
commit b36c330ada
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.floret.components package de.jeanlucmakiola.floret.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -22,10 +24,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector 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 import androidx.compose.ui.unit.dp
/** MD3 8dp spacing scale shared by the onboarding screens. */ /** 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 * 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 * their own content left-aligns. Each app supplies its own hero so the apps read
* as one family while keeping their own mark. * 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 @Composable
fun OnboardingScaffold( fun OnboardingScaffold(
hero: @Composable () -> Unit, hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit, actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
topSpacing: Dp = OnboardingSpace.xl,
body: @Composable ColumnScope.() -> Unit, body: @Composable ColumnScope.() -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = {
if (progress != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
) {
progress()
}
}
},
bottomBar = { bottomBar = {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -74,7 +97,7 @@ fun OnboardingScaffold(
.padding(horizontal = OnboardingSpace.md), .padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(Modifier.height(OnboardingSpace.xl)) Spacer(Modifier.height(topSpacing))
hero() hero()
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.lg))
body() 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. */ /** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable @Composable
fun BenefitRow(icon: ImageVector, title: String, body: String) { fun BenefitRow(icon: ImageVector, title: String, body: String) {