Merge pull request 'feat(components): step progress for the onboarding scaffold' (#3) from feat/onboarding-progress into main

Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/3
This commit is contained in:
Jean-Luc Makiola
2026-08-10 17:13:00 +02:00
5 changed files with 197 additions and 12 deletions
+5
View File
@@ -12,6 +12,11 @@ are no binary releases; "version" tracks the shared `version` in the root build
### Added ### Added
- **`components`** — `GroupedRow` takes an `AnnotatedString` title and summary
as well as plain `String`s, so a row can carry its own emphasis: marking the
run a search matched, without every caller having to rebuild the row. The
`String` overload delegates to it, so existing callers are unchanged.
- **`components`** — `SnackChip`, the family's compact confirmation: a - **`components`** — `SnackChip`, the family's compact confirmation: a
content-sized pill sliding up from the bottom with an optional trailing action, content-sized pill sliding up from the bottom with an optional trailing action,
for the case where the surface behind the confirmation *is* the confirmation for the case where the surface behind the confirmation *is* the confirmation
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.floret.components package de.jeanlucmakiola.floret.components
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,12 +15,16 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,6 +43,16 @@ fun positionOf(index: Int, count: Int): Position = when {
else -> Position.Middle else -> Position.Middle
} }
/**
* The inset a grouped list keeps from the screen edge.
*
* Exported because it is a *shared* line, not a private detail of [GroupedRow]:
* everything that sits outside the cards but belongs to them — section headers,
* hints, picker descriptions — has to start on the same edge, or the page reads
* as two ragged columns. Take this value rather than writing 16.dp again.
*/
val GroupedListInset = 16.dp
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */ /** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
Position.Alone -> RoundedCornerShape(full) Position.Alone -> RoundedCornerShape(full)
@@ -55,13 +71,17 @@ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (positio
* Set [gapBelow] to false to suppress that 2dp separation — for a caller that * Set [gapBelow] to false to suppress that 2dp separation — for a caller that
* owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must * owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must
* share the same pitch for the drag maths to line up. * share the same pitch for the drag maths to line up.
*
* [onLongClick] adds a long-press to the same surface (with the platform's
* long-press haptic), for lists that enter a selection mode from a row.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun GroupedSurface( fun GroupedSurface(
position: Position, position: Position,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
gapBelow: Boolean = true, gapBelow: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
@@ -77,10 +97,24 @@ fun GroupedSurface(
else -> Modifier else -> Modifier
} }
val base = modifier.fillMaxWidth().then(gap) val base = modifier.fillMaxWidth().then(gap)
if (onClick != null) { val haptics = LocalHapticFeedback.current
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() } when {
} else { onLongClick != null -> Surface(
Surface(color = color, shape = shape, modifier = base) { content() } color = color,
shape = shape,
modifier = base.combinedClickable(
interactionSource = interaction,
indication = ripple(),
onClick = { onClick?.invoke() },
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
},
),
) { content() }
onClick != null ->
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
else -> Surface(color = color, shape = shape, modifier = base) { content() }
} }
} }
@@ -95,8 +129,8 @@ fun GroupedSurface(
* [container] overrides the row's tonal colour (e.g. a category tint); ignored * [container] overrides the row's tonal colour (e.g. a category tint); ignored
* when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row * when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row
* gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn]. * gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn].
* [onLongClick] (see [GroupedSurface]) adds a long-press to the row.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GroupedRow( fun GroupedRow(
title: String, title: String,
@@ -111,6 +145,44 @@ fun GroupedRow(
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) = GroupedRow(
title = AnnotatedString(title),
position = position,
modifier = modifier,
summary = summary?.let { AnnotatedString(it) },
selected = selected,
dimmed = dimmed,
container = container,
minHeight = minHeight,
gapBelow = gapBelow,
leading = leading,
trailing = trailing,
onClick = onClick,
onLongClick = onLongClick,
)
/**
* [GroupedRow] with styled text, for a row whose title or summary carries its own
* emphasis — a search hit marking the run that matched, say. Identical otherwise;
* the plain-[String] overload delegates here.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: AnnotatedString,
position: Position,
modifier: Modifier = Modifier,
summary: AnnotatedString? = null,
selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
gapBelow: Boolean = true,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) { ) {
val itemColors = if (selected) { val itemColors = if (selected) {
ListItemDefaults.colors( ListItemDefaults.colors(
@@ -140,8 +212,9 @@ fun GroupedRow(
} }
GroupedSurface( GroupedSurface(
position = position, position = position,
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = GroupedListInset),
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
color = containerColor, color = containerColor,
gapBelow = gapBelow, gapBelow = gapBelow,
) { ) {
@@ -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) {
@@ -26,6 +26,20 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
* [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content] * [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content]
* scrolls itself, which a picker only needs for an option list long enough to * scrolls itself, which a picker only needs for an option list long enough to
* warrant a lazy container. * warrant a lazy container.
*
* **Picker richness.** Three shapes, in ascending order — take the cheapest one
* that makes the choice obvious:
* 1. plain option rows, when the label *is* the meaning ("English", "Dark");
* 2. rows plus a per-option summary of the concrete effect, when the meaning is
* a date, a time or a number the label only names ("Next 7 days" →
* "30 Jul – 5 Aug"). [OptionPicker]'s `summary` covers this;
* 3. a live preview above the rows, when the effect is *visual* and no words
* carry it (month grid style, dimmed past events, week start).
*
* A picker of the third kind **must not close when an option is tapped** — it
* applies immediately and stays open, because closing would hide the very thing
* the screen exists to show. The user leaves via back. The first two kinds close
* on tap as usual.
*/ */
@Composable @Composable
fun FullScreenPicker( fun FullScreenPicker(
@@ -82,6 +96,15 @@ fun FullScreenPicker(
* General single-select picker, full-screen: each option is a connected grouped * General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. The drop-in for the family's option * row and the current one carries a check. The drop-in for the family's option
* dialogs (theme, default list, reminder offset, …). * dialogs (theme, default list, reminder offset, …).
*
* [summary] adds a second line per option — reserve it for the option's
* *concrete effect*, the thing its label names but doesn't spell out: the dates
* a range resolves to, the time a format renders as, what "follow the system"
* currently means. Returning null leaves that row single-line, so one option
* (typically the automatic one) can carry a note the others don't need. It is
* the cheap middle rung of the richness ladder described on [FullScreenPicker]:
* every option shows its effect at once and stays comparable, without the cost
* of a live preview.
*/ */
@Composable @Composable
fun <T> OptionPicker( fun <T> OptionPicker(
@@ -91,6 +114,7 @@ fun <T> OptionPicker(
label: @Composable (T) -> String, label: @Composable (T) -> String,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
summary: (@Composable (T) -> String?)? = null,
leading: (@Composable (T) -> Unit)? = null, leading: (@Composable (T) -> Unit)? = null,
header: (@Composable ColumnScope.() -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
@@ -101,6 +125,7 @@ fun <T> OptionPicker(
val isSelected = option == selected val isSelected = option == selected
GroupedRow( GroupedRow(
title = label(option), title = label(option),
summary = summary?.invoke(option),
position = positionOf(index, options.size), position = positionOf(index, options.size),
selected = isSelected, selected = isSelected,
leading = leading?.let { { it(option) } }, leading = leading?.let { { it(option) } },
@@ -13,12 +13,10 @@ import androidx.compose.foundation.layout.height
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.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
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.runtime.getValue
@@ -41,6 +39,12 @@ data class AboutLink(val icon: ImageVector, val label: String, val url: String)
* outlined buttons (e.g. source, licence) and an optional full-width * outlined buttons (e.g. source, licence) and an optional full-width
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the * [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
* browser. The app supplies its own logo, strings and links. * browser. The app supplies its own logo, strings and links.
*
* [position] lets the card join a grouped list instead of standing alone: with
* [Position.Top] it takes the group's top corners and the 2dp gap below, so a
* [GroupedRow] underneath continues the same container. Use that when the card's
* call to action reads better as a row of its own than as a button inside the
* card — the two then form one block rather than a card with a lid on it.
*/ */
@Composable @Composable
fun AboutCard( fun AboutCard(
@@ -49,15 +53,16 @@ fun AboutCard(
author: String, author: String,
primaryLinks: List<AboutLink>, primaryLinks: List<AboutLink>,
highlightLink: AboutLink? = null, highlightLink: AboutLink? = null,
position: Position = Position.Alone,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val open = { url: String -> val open = { url: String ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Unit Unit
} }
Surface( GroupedSurface(
position = position,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {