From 3dc34482654a5268ce9ce9b30808515d37f18a43 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 7 Sep 2026 18:39:39 +0200 Subject: [PATCH 01/11] feat(components): let an inline text field mask its value KeyboardType.Password only tells the IME to drop suggestions; without a visual transformation a borderless password field renders the secret in the clear. --- .../floret/components/InlineTextField.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt index f36aa93..4ec755a 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp /** @@ -42,6 +43,14 @@ fun InlineTextField( imeAction: ImeAction = ImeAction.Default, /** Invoked when the IME action key (e.g. Done) is pressed. */ onImeAction: (() -> Unit)? = null, + /** + * How the value is rendered, as opposed to stored. + * + * Exists so a password can be masked. [KeyboardType.Password] alone does + * not mask anything — it only tells the IME to drop suggestions — so a + * borderless field without this renders the secret in the clear. + */ + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val resolvedStyle = textStyle.copy( color = when { @@ -66,6 +75,7 @@ fun InlineTextField( keyboardActions = onImeAction?.let { action -> KeyboardActions(onAny = { action() }) } ?: KeyboardActions.Default, + visualTransformation = visualTransformation, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> Box { From 692c0a84c4be8bbafa7b326098463197a858fe54 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 13:48:17 +0200 Subject: [PATCH 02/11] feat(components): let a tinted grouped row name its content colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupedRow's container had no `on*` pair, so a row given a tint kept its text on onSurface — readable by luck rather than by the theme. Wanted by a status row that colours itself by outcome. --- .../floret/components/GroupedList.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 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 62f206d..a60e628 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -127,9 +127,12 @@ fun GroupedSurface( * headline and summary to the M3 disabled emphasis while leaving the [trailing] * control at full opacity — for rows that are present but switched off. * [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. + * when [selected]. [contentColor] is its pair — the `on*` role for that + * container, so a tinted row keeps its contrast in both themes instead of + * leaving the text on `onSurface`. [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. */ @Composable fun GroupedRow( @@ -140,6 +143,7 @@ fun GroupedRow( selected: Boolean = false, dimmed: Boolean = false, container: Color? = null, + contentColor: Color? = null, minHeight: Dp = 72.dp, gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, @@ -154,6 +158,7 @@ fun GroupedRow( selected = selected, dimmed = dimmed, container = container, + contentColor = contentColor, minHeight = minHeight, gapBelow = gapBelow, leading = leading, @@ -177,6 +182,7 @@ fun GroupedRow( selected: Boolean = false, dimmed: Boolean = false, container: Color? = null, + contentColor: Color? = null, minHeight: Dp = 72.dp, gapBelow: Boolean = true, leading: @Composable (() -> Unit)? = null, @@ -202,6 +208,14 @@ fun GroupedRow( leadingIconColor = muted, supportingColor = muted, ) + } else if (contentColor != null) { + ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = contentColor, + leadingIconColor = contentColor, + supportingColor = contentColor, + trailingIconColor = contentColor, + ) } else { ListItemDefaults.colors(containerColor = Color.Transparent) } From d7b7a542cd50e278d585cccbf2936b1ab66f799a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 13:48:24 +0200 Subject: [PATCH 03/11] feat(components): the preview picker's plate and scaled screen preview ScaledViewPreview measures real screen composables at a phone viewport and scales the finished layout down; ViewPreviewFrame is the tonal plate it sits on, cross-fading between options at the grouped rows' width and radius so preview and options read as one stack. Converged from Calendula's view and month-style choosers, where the pattern was invented, and now Agendula's quick-add step. The viewport height is a parameter here rather than a calendar-shaped constant. --- CHANGELOG.md | 20 +++ .../floret/components/ViewPreview.kt | 149 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa4ae4..b2b1f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ are no binary releases; "version" tracks the shared `version` in the root build ## [Unreleased] +### Added + +- **`components`** — `GroupedRow` takes a `contentColor`, the pair to the + existing `container`: a row given a tint now names the `on*` role that goes + with it instead of leaving its text on `onSurface`, which only happens to + read on a container the theme did not pick. Wanted by a status row that + colours itself by outcome (Agendula's export receipt: `secondaryContainer` on + success, `errorContainer` on failure). + +- **`components`** — `ScaledViewPreview` and `ViewPreviewFrame`, the preview + picker's two halves: the first measures real screen composables at a phone + viewport and scales the finished layout down (swallowing touches and + collapsing semantics, so a preview reads as a picture rather than a control), + the second is the tonal plate it sits on, cross-fading between options and + taking `GroupedListInset` and the grouped rows' outer radius so preview and + options read as one stack. Converged from Calendula's view/month-style + choosers, where the pattern was invented, and now Agendula's onboarding step + for the quick-add bar; the viewport height the app scales from is a parameter + rather than a calendar-shaped constant. + ## [0.3.0] - 2026-08-10 ### Added diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt new file mode 100644 index 0000000..d2a9fcb --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ViewPreview.kt @@ -0,0 +1,149 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlin.math.roundToInt + +/** + * The viewport a preview pretends to be, when the caller does not say: a phone's + * width by the height a full-screen view gets under a top bar. Scaling every + * preview from one fixed height keeps a chooser's options comparable — each + * shows the same slice of screen. + */ +val DefaultPreviewViewport = 440.dp + +/** + * Renders [content] at a full phone viewport and shrinks the finished layout + * into a box [height] tall, for a chooser that shows what it is offering rather + * than describing it. Callers pass the *real* screen composables, so a preview + * cannot drift from what it depicts. + * + * The [layout] below ignores the incoming constraints so the content measures at + * a phone viewport, and a [graphicsLayer] scale shrinks the result. + * `Modifier.requiredSize` is the obvious alternative but *centres* content that + * overflows its constraints, leaving only the bottom-right corner in the clip. + * + * [viewportHeight] is what the content is measured at before scaling; raise it + * for a screen that needs more room to read than [DefaultPreviewViewport]. + */ +@Composable +fun ScaledViewPreview( + height: Dp, + modifier: Modifier = Modifier, + viewportHeight: Dp = DefaultPreviewViewport, + content: @Composable () -> Unit, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val scale = height.value / viewportHeight.value + + Box( + modifier = modifier + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) + // A preview is a picture, not a control: swallow touches before the + // content's own clickables see them, and give screen readers one + // label instead of every row inside it. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes + .forEach { it.consume() } + } + } + } + .clearAndSetSemantics { } + // Measure at a full phone viewport but report the scaled size, so the + // node occupies exactly what it draws. + .layout { measurable, _ -> + val fullWidth = screenWidth.roundToPx() + val fullHeight = viewportHeight.roundToPx() + val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight)) + layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) { + placeable.place(0, 0) + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0f) + }, + ) { + Column(Modifier.fillMaxSize()) { content() } + } +} + +/** + * The stage a preview picker sets its preview on, cross-fading as [selected] + * changes. A preview draws the app's own surface, so a tonal plate gives it the + * edge it would otherwise lack on a chooser screen; the plate takes the width + * and outer radius of the [GroupedRow]s underneath so the two read as one stack. + * + * Only vertical spacing is the caller's to pass — the horizontal inset is + * [GroupedListInset], the rows' own, and the alignment is the point. + */ +@Composable +fun ViewPreviewFrame( + selected: T, + label: String, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit, +) { + val reduceMotion = rememberReduceMotion() + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset) + .clip(FrameShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(FrameInset), + contentAlignment = Alignment.Center, + ) { + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = label, + ) { shown -> + // Each preview renders the real screen at phone size and scales it + // down, so its own corners are square — the plate rounds them. + Box( + modifier = Modifier + .clip(PreviewShape) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + content(shown) + } + } + } +} + +private val FrameInset = 8.dp + +/** The grouped rows' own outer radius, so the stack shares one silhouette. */ +private val FrameShape = RoundedCornerShape(22.dp) +private val PreviewShape = RoundedCornerShape(12.dp) From 1af24841ae8e670fb608253086f8c702d4f7ba09 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 15:17:56 +0200 Subject: [PATCH 04/11] feat(components): a named header above a run of grouped rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settings page with a dozen categories in one flat run has no way to say which of them are about how the app behaves and which are about the app itself. GroupedSectionHeader is that label: it starts on GroupedListInset, the edge the cards it names start on, and owns the gap to the group above, so a page writes header-then-rows and nothing else. primary rather than onSurfaceVariant — after the Android-15 settings pattern Position already follows, and because a header that matches the summaries underneath it stops separating anything. --- CHANGELOG.md | 7 +++++ .../floret/components/GroupedList.kt | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b1f51..01f0cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`components`** — `GroupedSectionHeader`, the label above a run of + `GroupedRow`s. Starts on `GroupedListInset` and owns the gap to the group + above, so a page writes header-then-rows and nothing else; `primary`, after + the Android-15 settings pattern the row system already follows. Wanted by + Agendula's settings hub, which had grown a flat run of a dozen rows with + nothing separating how the app behaves from what it is. + - **`components`** — `GroupedRow` takes a `contentColor`, the pair to the existing `container`: a row given a tint now names the `on*` role that goes with it instead of leaving its text on `onSurface`, which only happens to 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 a60e628..33c8799 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -53,6 +53,33 @@ fun positionOf(index: Int, count: Int): Position = when { */ val GroupedListInset = 16.dp +/** + * The label above a run of [GroupedRow]s, for a page whose groups are named + * rather than merely spaced apart — a settings hub that separates how the app + * behaves from what it is. + * + * Starts on [GroupedListInset], the same edge as the cards it names, and owns + * the space that separates its group from the one above, so a caller writes + * header-then-rows and nothing else. Coloured `primary` after the Android-15 + * settings pattern this row system already follows: the headers are what makes + * the grouping readable at a glance, and `onSurfaceVariant` sinks them into the + * summaries. + */ +@Composable +fun GroupedSectionHeader(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 24.dp, + bottom = 8.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) From 97b4d24724170b1f15a4a401d7a908cea5e3b05e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 15:33:53 +0200 Subject: [PATCH 05/11] fix(components): take the section header's metrics from Calendula's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header was invented here rather than converged from the app that already had one. Calendula's SectionHeader is labelLarge at top 16 / bottom 4, and separates one group from the next with the caller's own 8dp spacer — a hub's first header follows the About card, not another group, so the gap cannot live on the header. Matched exactly, so the two apps' settings hubs are the same page. --- CHANGELOG.md | 10 +++++----- .../floret/components/GroupedList.kt | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f0cb6..d912622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,11 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added - **`components`** — `GroupedSectionHeader`, the label above a run of - `GroupedRow`s. Starts on `GroupedListInset` and owns the gap to the group - above, so a page writes header-then-rows and nothing else; `primary`, after - the Android-15 settings pattern the row system already follows. Wanted by - Agendula's settings hub, which had grown a flat run of a dozen rows with - nothing separating how the app behaves from what it is. + `GroupedRow`s: small, `primary`, inset to `GroupedListInset` so it shares the + cards' left margin. Converged from Calendula's settings hub, where the recipe + was written as a private `SectionHeader`, and now Agendula's, which had grown + a flat run of a dozen rows with nothing separating how the app behaves from + what it is. - **`components`** — `GroupedRow` takes a `contentColor`, the pair to the existing `container`: a row given a tint now names the `on*` role that goes 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 33c8799..292fe67 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -58,24 +58,23 @@ val GroupedListInset = 16.dp * rather than merely spaced apart — a settings hub that separates how the app * behaves from what it is. * - * Starts on [GroupedListInset], the same edge as the cards it names, and owns - * the space that separates its group from the one above, so a caller writes - * header-then-rows and nothing else. Coloured `primary` after the Android-15 - * settings pattern this row system already follows: the headers are what makes - * the grouping readable at a glance, and `onSurfaceVariant` sinks them into the - * summaries. + * Small, `primary`, and inset to [GroupedListInset] so it shares the left + * margin of the cards it names. Converged from Calendula's settings hub, where + * the recipe was written; a caller separates one group from the next with its + * own 8dp spacer, since a hub's first header follows the About card rather than + * another group. */ @Composable fun GroupedSectionHeader(text: String, modifier: Modifier = Modifier) { Text( text = text, - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = modifier.padding( start = GroupedListInset, end = GroupedListInset, - top = 24.dp, - bottom = 8.dp, + top = 16.dp, + bottom = 4.dp, ), ) } From 103a37cb28ea5f3adee9cad33ddea48285ecccf9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 9 Sep 2026 20:57:53 +0200 Subject: [PATCH 06/11] components: numbered instruction steps A connected list for a procedure the user has to carry out somewhere else. Collapsible, so it can be shown again as a reference once it has already had a screen of its own. --- .../floret/components/InstructionSteps.kt | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt new file mode 100644 index 0000000..2979ee9 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt @@ -0,0 +1,162 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * A short procedure the user has to carry out somewhere else, as a connected + * list of numbered steps. + * + * A component rather than a paragraph, because the instructions that matter most + * in a sign-in flow are for a *different* app — go to this site, turn that + * setting on, bring the result back here — and four actions written as prose is + * the form in which people miss the third one. + * + * Built from [GroupedRow] rather than as a tinted card, so a procedure looks + * like every other run of rows in the family: one rounded container, full + * corners at the ends, small ones between. That buys two things a card did not. + * Each step is a row of its own, so a long one wraps inside its own container + * instead of pushing the rest of a paragraph around; and the numerals sit in the + * leading slot every other row puts an avatar or icon in, which is what makes + * the column of them scan as a sequence. + * + * [title] renders as the group's [GroupedSectionHeader] — omit it where the + * screen already carries the headline, or the same sentence is said twice. + * [footnote] is the caveat that is not a step: what this will cost, or what it + * will not do. + * + * [collapsible] folds the whole thing behind its [title] as a row that opens on + * a tap, closed to begin with. For a procedure the user has *already been shown* + * — carried along afterwards so it can be re-read rather than remembered — that + * is the difference between a reference and a wall repeating itself. It needs a + * [title], since collapsed there is nothing else to tap. + */ +@Composable +fun InstructionSteps( + steps: List, + modifier: Modifier = Modifier, + title: String? = null, + footnote: String? = null, + collapsible: Boolean = false, +) { + var open by rememberSaveable { mutableStateOf(false) } + // Collapsed only where there is a header to reopen from. + val folds = collapsible && title != null + Column(modifier) { + when { + folds -> { + GroupedRow( + title = title, + // Alone while shut, the head of the run once open — the + // corners are what say whether anything follows. + position = if (open) Position.Top else Position.Alone, + minHeight = 56.dp, + // No disc: the empty leading slot is what tells the header + // apart from the numbered rows it opens. + trailing = { Chevron(open) }, + onClick = { open = !open }, + ) + } + + title != null -> GroupedSectionHeader(title) + } + if (folds && !open) return@Column + steps.forEachIndexed { index, step -> + GroupedRow( + title = step, + // The header is the group's first row when there is one, so the + // steps run from the middle rather than starting the shape over. + position = if (folds) { + positionOf(index + 1, steps.size + 1) + } else { + positionOf(index, steps.size) + }, + // Roomy enough for one line, free to grow for three. A step that + // wraps is the normal case here, not the exception. + minHeight = 56.dp, + leading = { StepNumber(index + 1) }, + ) + } + footnote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Outside the cards but belonging to them, so it starts on the + // same line they do. + modifier = Modifier.padding( + start = GroupedListInset, + end = GroupedListInset, + top = 8.dp, + ), + ) + } + } +} + +/** + * The step's number, in the leading slot. + * + * A filled `primary` disc rather than a bare numeral: on a neutral row the + * accent is what marks the column as a sequence rather than a list of unrelated + * settings, and it holds that reading at a glance without a rule down the side. + * + */ +@Composable +private fun StepNumber(number: Int) { + Box( + modifier = Modifier + .size(StepNumberSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * The open/shut marker. + * + * Decorative: the row it sits on carries the name and the click, so a screen + * reader announcing a chevron as well would be describing the furniture. + */ +@Composable +private fun Chevron(open: Boolean) { + val turn by animateFloatAsState(if (open) 180f else 0f, label = "instruction-chevron") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(turn), + ) +} + +/** The leading slot's own size, matched to the icons that usually fill it. */ +private val StepNumberSize = 28.dp From d5f5ad7b9df73a9dff4b7b82d8f338eec71423b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 16:51:55 +0200 Subject: [PATCH 07/11] components: ReorderableColumn row height parameter --- .../floret/components/ReorderableColumn.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt index d63ff1f..e99eb0b 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlin.math.roundToInt -/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ +/** Default uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ val ReorderableRowHeight: Dp = 64.dp private val RowGap: Dp = 2.dp @@ -41,7 +41,7 @@ private val RowGap: Dp = 2.dp * Built for the short, fixed grouped-card lists in Settings — no external * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings * screens are a single [androidx.compose.foundation.verticalScroll] column, which - * can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a + * can't nest a scrolling list). Rows are a fixed [rowHeight] with a * uniform gap, so a row's target slot is simply how many whole pitches it has * been dragged. The held row follows the finger while the others slide out of * its way (animated); on release the order is committed immediately with a @@ -52,6 +52,8 @@ private val RowGap: Dp = 2.dp * [rowContent] receives the [Position] for the row's place in the order (to reuse * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) * and a `dragHandle` [Modifier] to attach to the element that starts a drag. + * Pass [rowHeight] matching the rows' non-reorder `minHeight`, so they keep their + * size when a screen switches into reordering. */ @Composable fun ReorderableColumn( @@ -59,9 +61,10 @@ fun ReorderableColumn( keyOf: (T) -> Any, onReorder: (List) -> Unit, modifier: Modifier = Modifier, + rowHeight: Dp = ReorderableRowHeight, rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, ) { - val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() } + val pitchPx = with(LocalDensity.current) { (rowHeight + RowGap).toPx() } val scope = rememberCoroutineScope() // Local working copy; re-seeded when the incoming list changes (including the @@ -163,7 +166,7 @@ fun ReorderableColumn( Box( Modifier - .height(ReorderableRowHeight) + .height(rowHeight) .zIndex(if (isDragged) 1f else 0f) .graphicsLayer { translationY = if (isDragged) dragOffset else animatedShift From 1e49fd5067e5fcc44f3bc6bb4accb3dc087a137a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 17:13:07 +0200 Subject: [PATCH 08/11] components: ReorderableColumn drag shadow follows the card --- .../floret/components/ReorderableColumn.kt | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt index e99eb0b..cb162b1 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -21,10 +20,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.translate +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.Job @@ -53,7 +60,8 @@ private val RowGap: Dp = 2.dp * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) * and a `dragHandle` [Modifier] to attach to the element that starts a drag. * Pass [rowHeight] matching the rows' non-reorder `minHeight`, so they keep their - * size when a screen switches into reordering. + * size when a screen switches into reordering. [rowInset] is the card's horizontal + * inset inside its slot, so the held row's shadow hugs the card, not the slot. */ @Composable fun ReorderableColumn( @@ -62,6 +70,7 @@ fun ReorderableColumn( onReorder: (List) -> Unit, modifier: Modifier = Modifier, rowHeight: Dp = ReorderableRowHeight, + rowInset: Dp = GroupedListInset, rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, ) { val pitchPx = with(LocalDensity.current) { (rowHeight + RowGap).toPx() } @@ -174,7 +183,7 @@ fun ReorderableColumn( scaleX = 1.02f scaleY = 1.02f shadowElevation = 8.dp.toPx() - shape = RoundedCornerShape(20.dp) + shape = InsetShape(groupedShape(positionOf(index, order.size), 22.dp, 6.dp), rowInset) clip = false } }, @@ -184,3 +193,16 @@ fun ReorderableColumn( } } } + +/** [base] laid out [inset] in from both sides of the slot. */ +private class InsetShape(private val base: Shape, private val inset: Dp) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val dx = with(density) { inset.toPx() } + val inner = Size((size.width - 2 * dx).coerceAtLeast(0f), size.height) + return when (val outline = base.createOutline(inner, layoutDirection, density)) { + is Outline.Rectangle -> Outline.Rectangle(outline.rect.translate(Offset(dx, 0f))) + is Outline.Rounded -> Outline.Rounded(outline.roundRect.translate(Offset(dx, 0f))) + is Outline.Generic -> Outline.Generic(Path().apply { addPath(outline.path, Offset(dx, 0f)) }) + } + } +} From 8ed9b1dbdc0cdd7bfae091cccddde8fa1b7cbb67 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 17:24:36 +0200 Subject: [PATCH 09/11] components: ReorderableColumn resists dragging past the ends --- .../floret/components/ReorderableColumn.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt index cb162b1..edb362b 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt @@ -84,6 +84,8 @@ fun ReorderableColumn( // release settle — re-based onto the row's new slot once the order commits // (see onDragEnd), then eased down to zero. var dragOffset by remember { mutableFloatStateOf(0f) } + // The finger's own travel; [dragOffset] is this held to the list's ends. + var rawOffset by remember { mutableFloatStateOf(0f) } // The running release/cancel settle, cancelled if a new drag pre-empts it. // Purely visual — the order commit (see onDragEnd) never depends on it. var settleJob by remember { mutableStateOf(null) } @@ -131,10 +133,13 @@ fun ReorderableColumn( settleJob?.cancel() draggedKey = key dragOffset = 0f + rawOffset = 0f }, onDrag = { change, amount -> change.consume() - dragOffset += amount.y + rawOffset += amount.y + val from = order.indexOfFirst { keyOf(it) == key } + dragOffset = rubberBand(rawOffset, -from * pitchPx, (order.lastIndex - from) * pitchPx, pitchPx * 0.4f) }, onDragEnd = { val from = order.indexOfFirst { keyOf(it) == key } @@ -206,3 +211,16 @@ private class InsetShape(private val base: Shape, private val inset: Dp) : Shape } } } + +/** + * [value] as is between [min] and [max]; past either end it keeps following with + * growing resistance and never gets further than [stretch] beyond it. + */ +private fun rubberBand(value: Float, min: Float, max: Float, stretch: Float): Float { + fun resist(over: Float) = stretch * (1f - 1f / (over / stretch + 1f)) + return when { + value > max -> max + resist(value - max) + value < min -> min - resist(min - value) + else -> value + } +} From 94cb850fa4ed67b76c2d90657864c2a7b3a5cb01 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 18:15:12 +0200 Subject: [PATCH 10/11] core-time: optional 12/24-hour override for formatTime and friends --- .../floret/time/DateTimeFormat.kt | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt index bb5367a..d7f2208 100644 --- a/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt +++ b/core-time/src/main/kotlin/de/jeanlucmakiola/floret/time/DateTimeFormat.kt @@ -39,12 +39,25 @@ private fun Instant.atDisplayZone(allDay: Boolean) = fun Instant.formatDate(allDay: Boolean = false): String = atDisplayZone(allDay).format(dateFormatter) -/** Short localized time, e.g. "14:30". */ -fun Instant.formatTime(): String = atSystemZone().format(timeFormatter) +private val timeFormatter24: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") +private val timeFormatter12: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a") + +/** + * Short time, e.g. "14:30". [is24Hour] forces a 12- or 24-hour clock; null keeps + * the locale's own short pattern. + */ +fun Instant.formatTime(is24Hour: Boolean? = null): String { + val formatter = when (is24Hour) { + null -> timeFormatter + true -> timeFormatter24 + false -> timeFormatter12.withLocale(Locale.getDefault()) + } + return atSystemZone().format(formatter) +} /** Date alone for all-day items, otherwise date + time. */ -fun Instant.formatDateTime(allDay: Boolean): String = - if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime()}" +fun Instant.formatDateTime(allDay: Boolean, is24Hour: Boolean? = null): String = + if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime(is24Hour)}" // The locale's medium date pattern with the year token stripped, e.g. "20 Jun" / // "Jun 20" — derived from the pattern so day/month order still follows the locale. @@ -62,11 +75,11 @@ private val dateNoYearFormatter: DateTimeFormatter = run { * current year, and the time is dropped when it carries no information (all-day * items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027". */ -fun Instant.formatDateTimeCompact(allDay: Boolean): String { +fun Instant.formatDateTimeCompact(allDay: Boolean, is24Hour: Boolean? = null): String { val zdt = atDisplayZone(allDay) val datePart = if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter) else formatDate(allDay) return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart - else "$datePart · ${formatTime()}" + else "$datePart · ${formatTime(is24Hour)}" } From ffca74791f80f588cd706ab1dfb43d1969536ac4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 24 Sep 2026 16:25:14 +0200 Subject: [PATCH 11/11] release: floret-kit 0.4.0 --- CHANGELOG.md | 21 ++++++++++++++++++++- build.gradle.kts | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca8a71..dd00cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ All notable changes to floret-kit are documented here. The format follows floret-kit is consumed **from source** — each app embeds it as a git submodule and builds it through a Gradle composite build, pinning a specific commit. There are no binary releases; "version" tracks the shared `version` in the root build -(currently `0.3.0`). Entries are grouped by module. +(currently `0.4.0`). Entries are grouped by module. ## [Unreleased] +## [0.4.0] - 2026-09-24 + ### Added - **`components`** — `GroupedSectionHeader`, the label above a run of @@ -37,6 +39,23 @@ are no binary releases; "version" tracks the shared `version` in the root build for the quick-add bar; the viewport height the app scales from is a parameter rather than a calendar-shaped constant. +- **`components`** — `InstructionSteps`, a numbered, connected list for a + procedure the user carries out somewhere else. Collapsible, so it can come + back as a reference once it has had a screen of its own. + +- **`components`** — `InlineTextField` takes a `visualTransformation`, so a + password can be masked. `KeyboardType.Password` only drops IME suggestions; + without this the field rendered the secret in the clear. + +- **`components`** — `ReorderableColumn` takes a `rowHeight` (default + `ReorderableRowHeight`), so rows keep their normal height while reordering. + The drag shadow now follows the card's shape, and a row resists being dragged + past either end. + +- **`core-time`** — `formatTime`, `formatDateTime` and `formatDateTimeCompact` + take an optional `is24Hour`, for an app-level 12/24-hour setting. `null` + keeps following the system. + ### Fixed - **`core-crash`** — The prefilled crash issue is filed in English again. Its diff --git a/build.gradle.kts b/build.gradle.kts index 48ae938..637e3db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,5 +10,5 @@ plugins { subprojects { group = "de.jeanlucmakiola.floret" - version = "0.3.0" + version = "0.4.0" }