From bb3ff1f31601763cf4a477af79a375648ce17109 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 21:12:37 +0200 Subject: [PATCH 1/6] feat(components): give OptionPicker a per-option summary slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picker row often names something it doesn't spell out — a range's dates, a reminder's firing time, what "follow the system" resolves to today. The summary slot puts that under the label, so every option shows its effect at once and stays comparable, without the cost of a live preview. The KDoc now also states the three rungs of picker richness (plain rows / per-option summary / live preview) and the rule that a preview picker must not close on selection, since closing hides the thing it exists to show. Co-Authored-By: Claude Opus 5 --- .../floret/components/OptionPicker.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt index 6c0d984..dd5e550 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt @@ -26,6 +26,20 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme * [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content] * scrolls itself, which a picker only needs for an option list long enough to * 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 fun FullScreenPicker( @@ -82,6 +96,15 @@ fun FullScreenPicker( * 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 * 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 fun OptionPicker( @@ -91,6 +114,7 @@ fun OptionPicker( label: @Composable (T) -> String, onSelect: (T) -> Unit, onDismiss: () -> Unit, + summary: (@Composable (T) -> String?)? = null, leading: (@Composable (T) -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null, predictiveBack: Boolean = false, @@ -101,6 +125,7 @@ fun OptionPicker( val isSelected = option == selected GroupedRow( title = label(option), + summary = summary?.invoke(option), position = positionOf(index, options.size), selected = isSelected, leading = leading?.let { { it(option) } }, From 38e508b2591a1123a416cdf17100b5ab3a46c29d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 21:12:37 +0200 Subject: [PATCH 2/6] feat(components): export the grouped-list inset as GroupedListInset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 16dp a grouped list keeps from the screen edge was a private literal in GroupedRow, so everything that sits outside the cards but belongs to them — section headers, hints, picker descriptions — had to repeat the number and had quietly drifted to 24dp in places, leaving pages with two ragged left margins. Exporting it gives those callers the one value to take. Co-Authored-By: Claude Opus 5 --- .../jeanlucmakiola/floret/components/GroupedList.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index f11b6d6..0cb7535 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -37,6 +37,16 @@ fun positionOf(index: Int, count: Int): Position = when { 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. */ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { Position.Alone -> RoundedCornerShape(full) @@ -140,7 +150,7 @@ fun GroupedRow( } GroupedSurface( position = position, - modifier = modifier.padding(horizontal = 16.dp), + modifier = modifier.padding(horizontal = GroupedListInset), onClick = onClick, color = containerColor, gapBelow = gapBelow, From ed1d3ca5e8ff0d63e2ed9d7ece25bcfbcd3be02d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 21:12:37 +0200 Subject: [PATCH 3/6] feat(components): let AboutCard join a grouped list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AboutCard drew its own Surface, so it could only stand alone. Taking a Position and rendering through GroupedSurface lets it be the top of a group with rows continuing underneath — for apps whose call to action reads better as a row of its own than as a tonal button inside the card. Defaults to Position.Alone, so existing callers are unchanged. Co-Authored-By: Claude Opus 5 --- .../floret/components/SettingsRecipes.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt index 9f647da..aaebaa4 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -13,12 +13,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 * [highlightLink] tonal button (e.g. donate). Each link opens its URL in the * 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 fun AboutCard( @@ -49,15 +53,16 @@ fun AboutCard( author: String, primaryLinks: List, highlightLink: AboutLink? = null, + position: Position = Position.Alone, ) { val context = LocalContext.current val open = { url: String -> runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } Unit } - Surface( + GroupedSurface( + position = position, color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth(), ) { Column(Modifier.fillMaxWidth().padding(16.dp)) { From 3134098965c5fa74c3201c5f920c04b06289a557 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 2 Aug 2026 20:55:57 +0200 Subject: [PATCH 4/6] feat(components): let a grouped row take a long-press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupedSurface/GroupedRow gain onLongClick (with the platform long-press haptic), for lists that enter a selection mode from a row — Calendula's search results (#80). --- .../floret/components/GroupedList.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index 0cb7535..1897556 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.floret.components 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.collectIsPressedAsState import androidx.compose.foundation.layout.fillMaxWidth @@ -13,12 +15,15 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -65,13 +70,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 * owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must * 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 fun GroupedSurface( position: Position, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, gapBelow: Boolean = true, content: @Composable () -> Unit, @@ -87,10 +96,24 @@ fun GroupedSurface( else -> Modifier } val base = modifier.fillMaxWidth().then(gap) - if (onClick != null) { - Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() } - } else { - Surface(color = color, shape = shape, modifier = base) { content() } + val haptics = LocalHapticFeedback.current + when { + onLongClick != null -> Surface( + 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() } } } @@ -105,6 +128,7 @@ fun GroupedSurface( * [container] overrides the row's tonal colour (e.g. a category tint); ignored * when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row * 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 @@ -121,6 +145,7 @@ fun GroupedRow( leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { val itemColors = if (selected) { ListItemDefaults.colors( @@ -152,6 +177,7 @@ fun GroupedRow( position = position, modifier = modifier.padding(horizontal = GroupedListInset), onClick = onClick, + onLongClick = onLongClick, color = containerColor, gapBelow = gapBelow, ) { From fd1f283b775825b7e9060263f8e3513d30e63876 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 16:24:39 +0200 Subject: [PATCH 5/6] feat(components): let a grouped row carry styled text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupedRow took plain Strings, so a caller that needs emphasis inside the title or summary — a search hit marking what it matched — had no way in short of rebuilding the row. Adds an AnnotatedString overload and delegates the String one to it. --- CHANGELOG.md | 5 +++ .../floret/components/GroupedList.kt | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d39ab26..21f6261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ are no binary releases; "version" tracks the shared `version` in the root build ### 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 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 diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt index 1897556..62f206d 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color 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 @@ -130,7 +131,6 @@ fun GroupedSurface( * 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 fun GroupedRow( title: String, @@ -146,6 +146,43 @@ fun GroupedRow( trailing: @Composable (() -> 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) { ListItemDefaults.colors( From b36c330adadcab6cbdf5d55f11d4652fd52d0b7d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 21:01:52 +0200 Subject: [PATCH 6/6] 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) {