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
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) {