From 675724411ee3a3112e7ab252aa2b9a6099095ca1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 11:03:32 +0200 Subject: [PATCH 1/9] feat(components): revealOnFocus keeps a focused input group above the keyboard On by default in InlineTextField, GroupedSurface and OptionalFormSection. --- .../floret/components/FormSection.kt | 2 +- .../floret/components/GroupedList.kt | 2 +- .../floret/components/InlineTextField.kt | 2 +- .../floret/components/RevealOnFocus.kt | 62 +++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt index d190547..b657e0e 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/FormSection.kt @@ -21,6 +21,6 @@ fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> enter = expandEnter(), exit = collapseExit(), ) { - Column(modifier = Modifier.fillMaxWidth(), content = content) + Column(modifier = Modifier.fillMaxWidth().revealOnFocus(), content = content) } } 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 292fe67..ee96595 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -122,7 +122,7 @@ fun GroupedSurface( position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp) else -> Modifier } - val base = modifier.fillMaxWidth().then(gap) + val base = modifier.fillMaxWidth().then(gap).revealOnFocus() val haptics = LocalHapticFeedback.current when { onLongClick != null -> Surface( 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 4ec755a..a8c2b13 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InlineTextField.kt @@ -91,6 +91,6 @@ fun InlineTextField( innerTextField() } }, - modifier = modifier, + modifier = modifier.revealOnFocus(), ) } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt new file mode 100644 index 0000000..7f80f16 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/RevealOnFocus.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull + +/** + * Scrolls this whole element into view while anything inside it has focus — once + * the keyboard has finished opening, and again whenever the element resizes. + * + * A text field on its own only brings its text line into view, so a card's + * padding, its label and whatever appears below the field (suggestions, chips) + * stay under the keyboard. [InlineTextField], [GroupedSurface] and + * [OptionalFormSection] apply it already; add it to an app's own card or row + * that holds an input. Needs a scrolling parent whose viewport shrinks with the + * keyboard (`imePadding`). + * + * The keyboard is only read while focused, so an unfocused element never + * recomposes as the keyboard animates. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +fun Modifier.revealOnFocus(): Modifier = composed { + val requester = remember { BringIntoViewRequester() } + var focused by remember { mutableStateOf(false) } + var size by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val ime = WindowInsets.ime + val imeTarget = WindowInsets.imeAnimationTarget + LaunchedEffect(focused) { + if (!focused) return@LaunchedEffect + // Reveal against the final viewport, not one frame of the keyboard sliding in. + snapshotFlow { + val now = ime.getBottom(density) + if (now == imeTarget.getBottom(density)) now to size else null + } + .filterNotNull() + .distinctUntilChanged() + .collect { requester.bringIntoView() } + } + this + .bringIntoViewRequester(requester) + .onSizeChanged { size = it } + .onFocusChanged { focused = it.hasFocus } +} From f1ded81c032bba88bfe351b87dfd99916e8a6b63 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 14 Oct 2026 17:19:31 +0200 Subject: [PATCH 2/9] feat(identity): pure-black (AMOLED) dark theme ColorScheme.toPureBlack() sets background, surface, surfaceDim and surfaceContainerLowest to #000 and dims the other containers by scaling their sRGB channels, so their hue tint carries over and grid tiles drawn as containers over surface keep visible gaps. FloretExpressiveTheme takes pureBlack to apply it to the dark scheme, dynamic or app-supplied. core-prefs gains ThemeMode.BLACK (dark, pure black) and ThemeMode.isPureBlack, stored under the same theme_mode key. --- CHANGELOG.md | 11 ++++ .../jeanlucmakiola/floret/prefs/Appearance.kt | 12 +++- .../floret/prefs/ThemeModeTest.kt | 18 +++++ identity/build.gradle.kts | 12 ++++ .../floret/identity/PureBlack.kt | 43 ++++++++++++ .../jeanlucmakiola/floret/identity/Theme.kt | 6 +- .../floret/identity/PureBlackTest.kt | 66 +++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt create mode 100644 identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8894c4a..d220c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` + sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to + #000 and pulls the other containers down towards black, keeping their hue + tint, so grid tiles drawn as containers over `surface` still show their + gaps. `FloretExpressiveTheme` gains `pureBlack` (default `false`), which + applies it to the dark scheme, dynamic or app-supplied. +- **`core-prefs`** — `ThemeMode.BLACK`, dark with pure-black surfaces. + `isDark` treats it as dark, and the new `ThemeMode.isPureBlack` is what an + app passes to the theme's `pureBlack`. It's stored as `"BLACK"` under the + same `theme_mode` key; a build without it reads that as `SYSTEM`. + - **`glance`** (new module) — the family's shared home-screen-widget plumbing and M3 Expressive widget vocabulary, built on Jetpack Glance: `FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt index dcb0c22..19a3fc5 100644 --- a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt @@ -4,16 +4,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -/** Light/dark override. [SYSTEM] follows the device setting. */ -enum class ThemeMode { SYSTEM, LIGHT, DARK } +/** + * Light/dark override. [SYSTEM] follows the device setting; [BLACK] is dark + * with pure-black surfaces (the AMOLED option). + */ +enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK } /** Resolve to a concrete dark flag; [SYSTEM] defers to [systemDark]. */ fun ThemeMode.isDark(systemDark: Boolean): Boolean = when (this) { ThemeMode.SYSTEM -> systemDark ThemeMode.LIGHT -> false - ThemeMode.DARK -> true + ThemeMode.DARK, ThemeMode.BLACK -> true } +/** Whether the theme should draw pure-black surfaces; only [ThemeMode.BLACK] does. */ +val ThemeMode.isPureBlack: Boolean get() = this == ThemeMode.BLACK + /** The two appearance choices every app in the family has. */ data class Appearance( val themeMode: ThemeMode = ThemeMode.SYSTEM, diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt index d42d47e..32155d9 100644 --- a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt @@ -42,6 +42,24 @@ class ThemeModeTest { assertThat(dark).isTrue() } + @Test + fun `isDark lets BLACK override a light system`() { + val mode = ThemeMode.BLACK + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isTrue() + } + + @Test + fun `isPureBlack is set only for BLACK`() { + val modes = ThemeMode.entries + + val pureBlack = modes.filter { it.isPureBlack } + + assertThat(pureBlack).containsExactly(ThemeMode.BLACK) + } + @Test fun `Appearance defaults to following the system with dynamic colour on`() { val appearance = Appearance() diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index a9fa649..26740f5 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -23,6 +23,13 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } } kotlin { @@ -39,4 +46,9 @@ dependencies { implementation(libs.androidx.animation) // activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek). implementation(libs.androidx.activity.compose) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) } diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt new file mode 100644 index 0000000..a99aa1b --- /dev/null +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color + +/** + * The pure-black (AMOLED) take on a dark [ColorScheme]: the ground — `background`, + * `surface`, `surfaceDim`, `surfaceContainerLowest` — goes to #000, and the + * container ladder is pulled down towards black rather than flattened onto it. + * Apps draw grids as container tiles over a `surface` ground, so the tiles keep + * a small lift and the gaps between them still read as lines; sheets and menus + * on the upper containers still separate from the page. + * + * Each container keeps a fixed share of its own sRGB channels, so the scheme's + * hue tint — a dynamic wallpaper colour or an app's seed — carries + * over. Accent, outline and `on*` roles are untouched. Meant for a dark scheme + * only; on a light one it would just darken the surfaces. + */ +fun ColorScheme.toPureBlack(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = surfaceContainerLow.dimmed(PURE_BLACK_LOW), + surfaceContainer = surfaceContainer.dimmed(PURE_BLACK_CONTAINER), + surfaceContainerHigh = surfaceContainerHigh.dimmed(PURE_BLACK_HIGH), + surfaceContainerHighest = surfaceContainerHighest.dimmed(PURE_BLACK_HIGHEST), +) + +/** + * This colour with each sRGB channel scaled by [share], keeping its hue. Plain + * channel scaling rather than `lerp`, which blends in Oklab and lands darker. + */ +private fun Color.dimmed(share: Float): Color = + Color(red = red * share, green = green * share, blue = blue * share, alpha = alpha) + +// Shares of each container's own sRGB channels kept in pure black. Against the +// stock dark tones (#1D1B20 / #211F26 / #2B2930 / #36343B) they land near +// #131215 / #1A191E / #252329 / #302F35. +private const val PURE_BLACK_LOW = 0.65f +private const val PURE_BLACK_CONTAINER = 0.80f +private const val PURE_BLACK_HIGH = 0.85f +private const val PURE_BLACK_HIGHEST = 0.90f diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt index bce727d..6574513 100644 --- a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt @@ -34,6 +34,9 @@ val LocalFloretDarkTheme = staticCompositionLocalOf { false } * passing its own hand-tuned, seed-derived [lightScheme] / [darkScheme] and an * optional [typography]. A thin per-app wrapper (e.g. `AgendulaTheme`) supplies * those and forwards [darkTheme] / [dynamicColor] from settings. + * + * [pureBlack] swaps the dark scheme — dynamic or app-supplied — for its + * [toPureBlack] take, the AMOLED option. It has no effect on a light theme. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -42,6 +45,7 @@ fun FloretExpressiveTheme( darkScheme: ColorScheme, darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + pureBlack: Boolean = false, typography: Typography = Typography(), content: @Composable () -> Unit, ) { @@ -52,7 +56,7 @@ fun FloretExpressiveTheme( } darkTheme -> darkScheme else -> lightScheme - } + }.let { if (darkTheme && pureBlack) it.toPureBlack() else it } CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) { MaterialExpressiveTheme( diff --git a/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt new file mode 100644 index 0000000..de62607 --- /dev/null +++ b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The pure-black take on a dark scheme: a black ground, a dimmed container ladder. */ +class PureBlackTest { + + private val dark = darkColorScheme() + + @Test + fun `toPureBlack sets the ground roles to black`() { + val black = dark.toPureBlack() + + val ground = listOf(black.background, black.surface, black.surfaceDim, black.surfaceContainerLowest) + + assertThat(ground).containsExactly(Color.Black, Color.Black, Color.Black, Color.Black) + } + + @Test + fun `toPureBlack keeps the containers above black and in order`() { + val black = dark.toPureBlack() + + val ladder = listOf( + black.surfaceContainerLow, + black.surfaceContainer, + black.surfaceContainerHigh, + black.surfaceContainerHighest, + ).map { it.red + it.green + it.blue } + + assertThat(ladder.first()).isGreaterThan(0f) + assertThat(ladder).isInStrictOrder() + } + + @Test + fun `toPureBlack dims each container below its stock tone`() { + val black = dark.toPureBlack() + + val dimmed = listOf( + black.surfaceContainer.red < dark.surfaceContainer.red, + black.surfaceContainerHighest.red < dark.surfaceContainerHighest.red, + ) + + assertThat(dimmed).containsExactly(true, true) + } + + @Test + fun `toPureBlack scales a container's channels linearly`() { + val black = dark.toPureBlack() + + val ratio = black.surfaceContainer.red / dark.surfaceContainer.red + + assertThat(ratio).isWithin(0.02f).of(0.80f) + } + + @Test + fun `toPureBlack leaves accent and content roles alone`() { + val black = dark.toPureBlack() + + val kept = listOf(black.primary, black.onSurface, black.outline) + + assertThat(kept).containsExactly(dark.primary, dark.onSurface, dark.outline).inOrder() + } +} From 762ea5fe5772822c0585688aab40ffe806e6feca Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:50:13 +0200 Subject: [PATCH 3/9] feat(components): checked rows, switch rows, custom-expand row and option rows GroupedRow gains checked, OptionPicker gains dismissOnSelect and exposes its rows as OptionRows. New GroupedSwitchRow, CustomExpandRow, GroupedHint and ExpandIcon. Every existing call keeps its behaviour. --- CHANGELOG.md | 14 ++ .../floret/components/GroupedList.kt | 19 ++- .../floret/components/GroupedRowRecipes.kt | 130 ++++++++++++++++++ .../floret/components/InstructionSteps.kt | 24 +--- .../floret/components/OptionPicker.kt | 62 ++++++--- 5 files changed, 204 insertions(+), 45 deletions(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedRowRecipes.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d220c5d..bb95f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`components`** — picker-row helpers lifted from Calendula's pickers and + settings, all additive. `GroupedRow` takes `checked` (default `false`): it + implies `selected` and fills an empty trailing slot with `SelectedCheck`, + replacing the hand-written `if (selected) { { SelectedCheck() } } else null`. + `OptionPicker` takes `dismissOnSelect` (default `true`, today's behaviour) for + preview pickers that stay open, and its rows are public as `OptionRows` for a + picker that composes its own `FullScreenPicker`. `CustomExpandRow` is the + "Custom…" row of a presets-plus-custom picker, revealing its editor + (typically `CustomAmountEditor`) as one connected container. + `GroupedSwitchRow` is the settings toggle row where the whole row flips the + switch. `GroupedHint` is the muted text beside the cards (settings hints, + picker descriptions), and `ExpandIcon` the turning chevron, now also used by + `InstructionSteps`. + - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to #000 and pulls the other containers down towards black, keeping their hue 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 ee96595..06e1dec 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedList.kt @@ -159,6 +159,10 @@ fun GroupedSurface( * 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. + * + * [checked] marks a picker row as the chosen one: it implies [selected] and puts + * [SelectedCheck] in the trailing slot when [trailing] is null — the single-select + * picker row without the `if (selected) { { SelectedCheck() } } else null` dance. */ @Composable fun GroupedRow( @@ -176,6 +180,7 @@ fun GroupedRow( trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + checked: Boolean = false, ) = GroupedRow( title = AnnotatedString(title), position = position, @@ -191,6 +196,7 @@ fun GroupedRow( trailing = trailing, onClick = onClick, onLongClick = onLongClick, + checked = checked, ) /** @@ -215,8 +221,15 @@ fun GroupedRow( trailing: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + checked: Boolean = false, ) { - val itemColors = if (selected) { + val highlighted = selected || checked + val trailingContent = trailing ?: if (checked) { + { SelectedCheck() } + } else { + null + } + val itemColors = if (highlighted) { ListItemDefaults.colors( containerColor = Color.Transparent, headlineColor = MaterialTheme.colorScheme.onSecondaryContainer, @@ -246,7 +259,7 @@ fun GroupedRow( ListItemDefaults.colors(containerColor = Color.Transparent) } val containerColor = when { - selected -> MaterialTheme.colorScheme.secondaryContainer + highlighted -> MaterialTheme.colorScheme.secondaryContainer container != null -> container else -> MaterialTheme.colorScheme.surfaceContainerHigh } @@ -262,7 +275,7 @@ fun GroupedRow( headlineContent = { Text(title) }, supportingContent = summary?.let { text -> { Text(text) } }, leadingContent = leading, - trailingContent = trailing, + trailingContent = trailingContent, colors = itemColors, modifier = Modifier.heightIn(min = minHeight), ) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedRowRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedRowRecipes.kt new file mode 100644 index 0000000..5070150 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/GroupedRowRecipes.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter + +/** + * A [GroupedRow] with a trailing [Switch], where a tap anywhere on the row flips + * it — the family's settings toggle. [enabled] false dims the row and disables + * both the row and the switch. + */ +@Composable +fun GroupedSwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + position: Position, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true, + leading: @Composable (() -> Unit)? = null, +) { + GroupedRow( + title = title, + position = position, + modifier = modifier, + summary = summary, + dimmed = !enabled, + leading = leading, + trailing = { + Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + }, + onClick = if (enabled) ({ onCheckedChange(!checked) }) else null, + ) +} + +/** + * The "Custom…" row that closes a presets-plus-custom picker: a [GroupedRow] that + * toggles [expanded] on tap and reveals [editor] beneath it (typically a + * [CustomAmountEditor]). Expanded, the row takes [Position.Top] so it and the + * editor read as one container; collapsed it sits at [position]. + * + * [checked] marks the custom value as the current choice, as on the preset rows; + * the caller names it through [title]/[summary]. + */ +@Composable +fun CustomExpandRow( + title: String, + position: Position, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + checked: Boolean = false, + editor: @Composable () -> Unit, +) { + Column(modifier) { + GroupedRow( + title = title, + summary = summary, + position = if (expanded) Position.Top else position, + onClick = { onExpandedChange(!expanded) }, + checked = checked, + ) + AnimatedVisibility( + visible = expanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + editor() + } + } +} + +/** + * Muted supporting text outside the cards — under a [GroupedSectionHeader], or + * above a picker's rows — inset to [GroupedListInset] so it shares their edge. + * [style] and [verticalPadding] cover the denser variants (`bodySmall`, 8dp under + * a picker title). + */ +@Composable +fun GroupedHint( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyMedium, + verticalPadding: Dp = 4.dp, +) { + Text( + text = text, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = GroupedListInset, vertical = verticalPadding), + ) +} + +/** + * The open/shut chevron for a row that folds content beneath it; turns over as + * [expanded] changes. Decorative — the row carries the semantics. + */ +@Composable +fun ExpandIcon( + expanded: Boolean, + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + val turn by animateFloatAsState(if (expanded) 180f else 0f, label = "expand-icon") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + tint = tint, + modifier = modifier.rotate(turn), + ) +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt index 2979ee9..e8dfa21 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt @@ -1,15 +1,11 @@ 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 @@ -20,7 +16,6 @@ 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 @@ -74,7 +69,7 @@ fun InstructionSteps( minHeight = 56.dp, // No disc: the empty leading slot is what tells the header // apart from the numbered rows it opens. - trailing = { Chevron(open) }, + trailing = { ExpandIcon(open) }, onClick = { open = !open }, ) } @@ -141,22 +136,5 @@ private fun StepNumber(number: Int) { } } -/** - * 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 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 dd5e550..eca0fba 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt @@ -105,6 +105,9 @@ fun FullScreenPicker( * 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. + * + * [dismissOnSelect] closes the picker on a pick (the default). Pass false for a + * preview picker, which applies at once and stays open (see [FullScreenPicker]). */ @Composable fun OptionPicker( @@ -118,28 +121,49 @@ fun OptionPicker( leading: (@Composable (T) -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null, predictiveBack: Boolean = false, + dismissOnSelect: Boolean = true, ) { FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) { header?.invoke(this) - options.forEachIndexed { index, option -> - val isSelected = option == selected - GroupedRow( - title = label(option), - summary = summary?.invoke(option), - position = positionOf(index, options.size), - selected = isSelected, - leading = leading?.let { { it(option) } }, - trailing = if (isSelected) { - { SelectedCheck() } - } else { - null - }, - onClick = { - onSelect(option) - onDismiss() - }, - ) - } + OptionRows( + options = options, + selected = selected, + label = label, + onSelect = { + onSelect(it) + if (dismissOnSelect) onDismiss() + }, + summary = summary, + leading = leading, + ) + } +} + +/** + * [OptionPicker]'s option list on its own: one connected run of [GroupedRow]s, + * the current option highlighted and checked. For a picker that composes its own + * [FullScreenPicker] — a preview above the rows, a description between — but + * wants the standard rows underneath. [onSelect] only reports the pick; closing + * the picker is the caller's call. + */ +@Composable +fun OptionRows( + options: List, + selected: T, + label: @Composable (T) -> String, + onSelect: (T) -> Unit, + summary: (@Composable (T) -> String?)? = null, + leading: (@Composable (T) -> Unit)? = null, +) { + options.forEachIndexed { index, option -> + GroupedRow( + title = label(option), + summary = summary?.invoke(option), + position = positionOf(index, options.size), + leading = leading?.let { { it(option) } }, + onClick = { onSelect(option) }, + checked = option == selected, + ) } } From 71212c49d2b4ee82dd6fb57057d690d88bf8e980 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:50:19 +0200 Subject: [PATCH 4/9] feat(components): icon info card, onboarding headline and language row header IconInfoCard for the detail/edit cards, OnboardingScaffold headline/supporting plus OnboardingPrimaryButton, and header/predictiveBack on LanguagePickerRow. --- CHANGELOG.md | 11 +++ .../floret/components/IconInfoCard.kt | 83 +++++++++++++++++++ .../floret/components/Onboarding.kt | 58 ++++++++++++- .../floret/components/SettingsRecipes.kt | 8 +- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index bb95f76..d3fe2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,17 @@ are no binary releases; "version" tracks the shared `version` in the root build switch. `GroupedHint` is the muted text beside the cards (settings hints, picker descriptions), and `ExpandIcon` the turning chevron, now also used by `InstructionSteps`. +- **`components`** — `IconInfoCard`, the tonal icon-plus-content card behind + Calendula's event detail and edit screens (optionally clickable, a top-aligned + icon for multi-line fields, a `header` line the icon centres on). +- **`components`** — `OnboardingScaffold` takes `headline` and `supporting`, + the centred title and muted explanation most steps open `body` with; `body` + now defaults to empty. `OnboardingPrimaryButton` is the full-width 56dp + call-to-action, with an optional trailing icon. Existing calls are unchanged. +- **`components`** — `LanguagePickerRow` takes `header` and `predictiveBack`, + forwarded to its `OptionPicker`, so an app with a "help translate" row above + the languages can drop its own copy of the row. Both default to today's + behaviour. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt new file mode 100644 index 0000000..dcecba8 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/IconInfoCard.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A free-standing info card: a tonal container with [icon] in a 24dp gutter on + * the left and [content] beside it — a detail screen's "where / when / repeats" + * cards, or the same fields on an edit form. + * + * [onClick] makes the whole card tappable. [iconAlignment] Top with an + * [iconTopPadding] lines the icon up with a multi-line field's first line. A + * tall card passes its first line as [header]: the icon centres on that, and + * [content] continues below it on the same text column. + */ +@Composable +fun IconInfoCard( + icon: ImageVector, + iconContentDescription: String?, + modifier: Modifier = Modifier, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: (() -> Unit)? = null, + iconAlignment: Alignment.Vertical = Alignment.CenterVertically, + iconTopPadding: Dp = 0.dp, + header: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val inner: @Composable () -> Unit = { + if (header != null) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CardIcon(icon, iconContentDescription, iconTint, Modifier) + Spacer(Modifier.width(IconGap)) + Column(Modifier.weight(1f), content = header) + } + Column(Modifier.padding(start = IconSize + IconGap), content = content) + } + } else { + Row(Modifier.padding(16.dp), verticalAlignment = iconAlignment) { + CardIcon(icon, iconContentDescription, iconTint, Modifier.padding(top = iconTopPadding)) + Spacer(Modifier.width(IconGap)) + Column(Modifier.weight(1f), content = content) + } + } + } + if (onClick != null) { + Surface(onClick = onClick, color = color, shape = shape, modifier = modifier.fillMaxWidth()) { inner() } + } else { + Surface(color = color, shape = shape, modifier = modifier.fillMaxWidth()) { inner() } + } +} + +@Composable +private fun CardIcon(icon: ImageVector, contentDescription: String?, tint: Color, modifier: Modifier) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = modifier.size(IconSize), + ) +} + +private val IconSize = 24.dp +private val IconGap = 16.dp 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 b82e6fd..22e50a5 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -30,6 +32,7 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -66,6 +69,10 @@ object OnboardingSpace { * the scrolling content, for a step whose content is taller than the screen: a * pinned bar there eats the room the content needs and hides the fact that * there is more of it below. + * + * [headline] and [supporting] are the step's centred title and muted + * explanation, drawn at the top of [body] — the block nearly every step opens + * with. [body] then continues below them, and may be left out entirely. */ @Composable fun OnboardingScaffold( @@ -77,7 +84,9 @@ fun OnboardingScaffold( topSpacing: Dp = OnboardingSpace.xl, contentPadding: Dp = OnboardingSpace.md, scrollingActions: Boolean = false, - body: @Composable ColumnScope.() -> Unit, + headline: String? = null, + supporting: String? = null, + body: @Composable ColumnScope.() -> Unit = {}, ) { Scaffold( modifier = modifier, @@ -120,6 +129,22 @@ fun OnboardingScaffold( Spacer(Modifier.height(topSpacing)) hero() Spacer(Modifier.height(OnboardingSpace.lg)) + if (headline != null) { + Text( + text = headline, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + } + if (headline != null && supporting != null) Spacer(Modifier.height(12.dp)) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } body() // No inset of its own: scrolling actions sit inside the content // column, so they line up with whatever is above them. @@ -135,6 +160,37 @@ fun OnboardingScaffold( } } +/** + * The step's main call to action for [OnboardingScaffold]'s `actions`: a + * full-width 56dp filled button with a `titleMedium` label, and an optional + * [trailingIcon] (e.g. a forward arrow) after it. + */ +@Composable +fun OnboardingPrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingIcon: ImageVector? = null, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth().height(56.dp), + contentPadding = if (trailingIcon != null) { + ButtonDefaults.ButtonWithIconContentPadding + } else { + ButtonDefaults.ContentPadding + }, + ) { + Text(text = text, style = MaterialTheme.typography.titleMedium) + if (trailingIcon != null) { + Spacer(Modifier.width(OnboardingSpace.xs)) + Icon(imageVector = trailingIcon, contentDescription = null, modifier = Modifier.size(20.dp)) + } + } +} + /** The call-to-action stack, wherever the shell has put it. */ @Composable private fun ActionsColumn( 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 3f82d9a..e8ec3db 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.annotation.XmlRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.PaddingValues @@ -111,7 +112,8 @@ fun AboutCard( * [AppLanguage]. The choice is mirrored locally so the row updates instantly * before the activity recreation lands. [autoLabel] names the follow-the-system * option (the `null` tag); [localesConfig] is the app's `res/xml/locales_config`; - * [leading] is the row's icon. + * [leading] is the row's icon. [header] and [predictiveBack] are forwarded to the + * picker's [OptionPicker] (e.g. a "help translate" row above the languages). */ @Composable fun LanguagePickerRow( @@ -119,6 +121,8 @@ fun LanguagePickerRow( title: String, autoLabel: String, @XmlRes localesConfig: Int, + header: (@Composable ColumnScope.() -> Unit)? = null, + predictiveBack: Boolean = false, leading: @Composable () -> Unit, ) { val context = LocalContext.current @@ -148,6 +152,8 @@ fun LanguagePickerRow( AppLanguage.apply(context, it) }, onDismiss = { showDialog = false }, + header = header, + predictiveBack = predictiveBack, ) } } From d4970ba2bc9bd0e5240d1689e2d70e11d95e67f8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:50:34 +0200 Subject: [PATCH 5/9] feat(core-locale): relativeTimeAgo --- CHANGELOG.md | 3 +++ README.md | 2 +- .../floret/locale/RelativeTime.kt | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/RelativeTime.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fe2fb..fa82398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ are no binary releases; "version" tracks the shared `version` in the root build forwarded to its `OptionPicker`, so an app with a "help translate" row above the languages can drop its own copy of the row. Both default to today's behaviour. +- **`core-locale`** — `relativeTimeAgo(epochMillis, nowMillis, minResolution)`, + the platform-localised "5 minutes ago" Calendula's backup and special-dates + status lines build through `DateUtils.getRelativeTimeSpanString`. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/README.md b/README.md index 821e7ef..ba1f958 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode |--------|------|---------------| | `core-time` | JVM | Date/time helpers: `DayWindow` local-day windows, locale/zone-aware `Instant` formatting, `TimeBridge` (millis ↔ `Instant`). No Android, no deps. | | `core-reminders` | JVM | Reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). Each app layers its own DataStore + string labels on top. | -| `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order). The app passes its own `locales_config`. | +| `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order) and `relativeTimeAgo(…)` ("5 minutes ago"). The app passes its own `locales_config`. | | `core-prefs` | Android | The typed DataStore layer: `Pref` + `PrefStore` (typed read/write, distinct-until-changed flows), `toEnum()` for values that degrade instead of throwing, and the family's shared appearance prefs (`ThemeMode`, `dynamicColor`). The store's name and construction stay with the app. | | `core-di` | JVM | The shared DI vocabulary: `@IoDispatcher`, `@DefaultDispatcher`, `@ApplicationScope` and the Hilt module that provides them. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | diff --git a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/RelativeTime.kt b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/RelativeTime.kt new file mode 100644 index 0000000..376a724 --- /dev/null +++ b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/RelativeTime.kt @@ -0,0 +1,17 @@ +package de.jeanlucmakiola.floret.locale + +import android.text.format.DateUtils + +/** + * "5 minutes ago" / "in 2 hours" for [epochMillis] relative to [nowMillis], in the + * process locale, via the platform's `DateUtils`. Spans shorter than + * [minResolution] read as the smallest unit ("0 minutes ago"), so a status line + * doesn't tick by the second. + * + * Needs a real Android runtime, like [localizedDateFormatter]. + */ +fun relativeTimeAgo( + epochMillis: Long, + nowMillis: Long = System.currentTimeMillis(), + minResolution: Long = DateUtils.MINUTE_IN_MILLIS, +): String = DateUtils.getRelativeTimeSpanString(epochMillis, nowMillis, minResolution).toString() From 1cc7f8f71d74effa3f154da786e833b06ee55f1b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:50:51 +0200 Subject: [PATCH 6/9] feat(core-reminders): ReminderOverride.minutesOrEmpty --- CHANGELOG.md | 2 ++ .../de/jeanlucmakiola/floret/reminders/ReminderOverride.kt | 3 +++ .../jeanlucmakiola/floret/reminders/ReminderModelTest.kt | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa82398..6c2b933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ are no binary releases; "version" tracks the shared `version` in the root build - **`core-locale`** — `relativeTimeAgo(epochMillis, nowMillis, minResolution)`, the platform-localised "5 minutes ago" Calendula's backup and special-dates status lines build through `DateUtils.getRelativeTimeSpanString`. +- **`core-reminders`** — `ReminderOverride.minutesOrEmpty()`, the lead times of + a `Minutes` override and empty for `Inherit`/`None`. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt index a2f7b80..36d86de 100644 --- a/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt +++ b/core-reminders/src/main/kotlin/de/jeanlucmakiola/floret/reminders/ReminderOverride.kt @@ -19,6 +19,9 @@ sealed interface ReminderOverride { data class Minutes(val minutes: List) : ReminderOverride } +/** The lead times of a [ReminderOverride.Minutes]; empty for [ReminderOverride.Inherit] and [ReminderOverride.None]. */ +fun ReminderOverride.minutesOrEmpty(): List = (this as? ReminderOverride.Minutes)?.minutes.orEmpty() + /** Distinct, ascending lead times — the canonical form stored and resolved. */ fun List.normalizeReminders(): List = distinct().sorted() diff --git a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt index 29d4399..170a6e2 100644 --- a/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt +++ b/core-reminders/src/test/kotlin/de/jeanlucmakiola/floret/reminders/ReminderModelTest.kt @@ -64,4 +64,11 @@ class ReminderModelTest { assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false)) .isEqualTo(ReminderOverride.None) } + + @Test + fun `minutesOrEmpty reads the lead times and is empty otherwise`() { + assertThat(ReminderOverride.Minutes(listOf(10, 60)).minutesOrEmpty()).containsExactly(10, 60).inOrder() + assertThat(ReminderOverride.Inherit.minutesOrEmpty()).isEmpty() + assertThat(ReminderOverride.None.minutesOrEmpty()).isEmpty() + } } From 394ac122360ed54c658f199f6053cb72ee51b4d2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:52:14 +0200 Subject: [PATCH 7/9] feat(core-android): receiver, permission, alarm and Flow helpers New Android module (no Compose, no DI) with broadcastTicks, a coroutine BroadcastReceiver.goAsync, Context.hasPermission, AlarmManager.setWakeup and Flow.stateWhileSubscribed. --- CHANGELOG.md | 10 ++++ CONTRIBUTING.md | 1 + README.md | 2 + core-android/build.gradle.kts | 47 +++++++++++++++++++ .../jeanlucmakiola/floret/android/Alarms.kt | 32 +++++++++++++ .../floret/android/Broadcasts.kt | 28 +++++++++++ .../de/jeanlucmakiola/floret/android/Flows.kt | 18 +++++++ .../floret/android/Permissions.kt | 9 ++++ .../floret/android/Receivers.kt | 28 +++++++++++ .../android/StateWhileSubscribedTest.kt | 45 ++++++++++++++++++ docs/ARCHITECTURE.md | 3 +- docs/ROADMAP.md | 1 + settings.gradle.kts | 1 + 13 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 core-android/build.gradle.kts create mode 100644 core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt create mode 100644 core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt create mode 100644 core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt create mode 100644 core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt create mode 100644 core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt create mode 100644 core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2b933..0e1bfdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,16 @@ are no binary releases; "version" tracks the shared `version` in the root build status lines build through `DateUtils.getRelativeTimeSpanString`. - **`core-reminders`** — `ReminderOverride.minutesOrEmpty()`, the lead times of a `Minutes` override and empty for `Inherit`/`None`. +- **`core-android`** (new module) — the small Android plumbing Calendula's data + layer writes over and over, in an Android library with no Compose and no DI: + `Context.broadcastTicks(vararg actions)` (a Flow that ticks once on + collection and again on each broadcast, behind its `TodaySource` and + `ZoneSource`), `BroadcastReceiver.goAsync(context) { }` (the + `goAsync()` + one-off scope + `finish()` dance), `Context.hasPermission`, + `AlarmManager.canScheduleExactCompat()` and `AlarmManager.setWakeup(…)` + (exact and allow-while-idle when the capability is held, the inexact + allow-while-idle alarm otherwise), and + `Flow.stateWhileSubscribed(scope, initialValue, stopTimeoutMillis = 5_000)`. - **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 684959a..94fcd76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ practical how. ./gradlew :core-crash:testDebugUnitTest ./gradlew :core-prefs:testDebugUnitTest ./gradlew :core-di:test +./gradlew :core-android:testDebugUnitTest ./gradlew :identity:assembleDebug ./gradlew build # everything ``` diff --git a/README.md b/README.md index ba1f958..dbd0ce2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order) and `relativeTimeAgo(…)` ("5 minutes ago"). The app passes its own `locales_config`. | | `core-prefs` | Android | The typed DataStore layer: `Pref` + `PrefStore` (typed read/write, distinct-until-changed flows), `toEnum()` for values that degrade instead of throwing, and the family's shared appearance prefs (`ThemeMode`, `dynamicColor`). The store's name and construction stay with the app. | | `core-di` | JVM | The shared DI vocabulary: `@IoDispatcher`, `@DefaultDispatcher`, `@ApplicationScope` and the Hilt module that provides them. | +| `core-android` | Android | Small Android plumbing: `broadcastTicks` (a Flow that ticks on system broadcasts), a coroutine `BroadcastReceiver.goAsync { }`, `Context.hasPermission`, `AlarmManager.setWakeup` (exact, or inexact allow-while-idle without the capability) and `Flow.stateWhileSubscribed`. No Compose, no DI. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | @@ -63,5 +64,6 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md) ./gradlew :core-crash:testDebugUnitTest ./gradlew :core-prefs:testDebugUnitTest ./gradlew :core-di:test +./gradlew :core-android:testDebugUnitTest ./gradlew build # everything (Android modules need an SDK; see above) ``` diff --git a/core-android/build.gradle.kts b/core-android/build.gradle.kts new file mode 100644 index 0000000..b386c91 --- /dev/null +++ b/core-android/build.gradle.kts @@ -0,0 +1,47 @@ +// core-android — small Android plumbing every app in the family writes again: +// a broadcast-driven tick Flow, a coroutine `goAsync` for receivers, a +// permission check, the exact-or-inexact alarm wake-up, and the +// `stateIn(WhileSubscribed(5s))` idiom. No Compose and no DI, so only the +// Android library plugin is applied; coroutines are part of the public API. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.floret.android" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // `Flow`/`StateFlow`/`CoroutineScope` appear in the public API. + api(libs.kotlinx.coroutines.core) + implementation(libs.androidx.core.ktx) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt new file mode 100644 index 0000000..9cf2655 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Alarms.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.floret.android + +import android.app.AlarmManager +import android.app.PendingIntent +import android.os.Build + +/** + * True below API 31 (no restriction), and on 31+ when the exact-alarm capability + * is held — `USE_EXACT_ALARM`, or a `SCHEDULE_EXACT_ALARM` the user hasn't + * revoked. + */ +fun AlarmManager.canScheduleExactCompat(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms() + +/** + * A doze-proof wake-up at [triggerAtMillis]: exact when [canScheduleExactCompat], + * otherwise the inexact allow-while-idle alarm, which needs no permission. + * Returns whether the exact alarm was set. + */ +fun AlarmManager.setWakeup( + triggerAtMillis: Long, + operation: PendingIntent, + type: Int = AlarmManager.RTC_WAKEUP, +): Boolean { + val exact = canScheduleExactCompat() + if (exact) { + setExactAndAllowWhileIdle(type, triggerAtMillis, operation) + } else { + setAndAllowWhileIdle(type, triggerAtMillis, operation) + } + return exact +} diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt new file mode 100644 index 0000000..36d8c29 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Broadcasts.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.floret.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.onStart + +/** + * Emits once on collection and again on every broadcast of [actions], through a + * not-exported receiver registered for as long as the flow is collected. Map + * each tick to a fresh read of whatever the broadcasts announce — the date, the + * zone — rather than reading the intent. + */ +fun Context.broadcastTicks(vararg actions: String): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { actions.forEach(::addAction) } + ContextCompat.registerReceiver(this@broadcastTicks, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { unregisterReceiver(receiver) } +}.onStart { emit(Unit) } diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt new file mode 100644 index 0000000..bf3d235 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Flows.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.floret.android + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * `stateIn(scope, WhileSubscribed(stopTimeoutMillis), initialValue)` — the + * ViewModel idiom: upstream runs while the UI collects and survives a + * configuration change inside the timeout. + */ +fun Flow.stateWhileSubscribed( + scope: CoroutineScope, + initialValue: T, + stopTimeoutMillis: Long = 5_000L, +): StateFlow = stateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis), initialValue) diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt new file mode 100644 index 0000000..8a6b987 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Permissions.kt @@ -0,0 +1,9 @@ +package de.jeanlucmakiola.floret.android + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat + +/** Whether [permission] is currently granted to this app. */ +fun Context.hasPermission(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED diff --git a/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt new file mode 100644 index 0000000..958a4e4 --- /dev/null +++ b/core-android/src/main/kotlin/de/jeanlucmakiola/floret/android/Receivers.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.floret.android + +import android.content.BroadcastReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * Runs [block] off the main thread with the broadcast kept alive until it + * returns: `goAsync()` plus a one-off scope, finishing the pending result even if + * [block] throws. Call it from `onReceive`; the usual ~10s receiver budget still + * applies. + */ +fun BroadcastReceiver.goAsync( + context: CoroutineContext = Dispatchers.IO, + block: suspend CoroutineScope.() -> Unit, +) { + val pending = goAsync() + CoroutineScope(SupervisorJob() + context).launch { + try { + block() + } finally { + pending.finish() + } + } +} diff --git a/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt b/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt new file mode 100644 index 0000000..e0d27c0 --- /dev/null +++ b/core-android/src/test/kotlin/de/jeanlucmakiola/floret/android/StateWhileSubscribedTest.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.floret.android + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StateWhileSubscribedTest { + + @Test + fun `starts at the initial value and follows upstream while collected`() = runTest { + val upstream = MutableStateFlow(1) + val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0) + assertThat(state.value).isEqualTo(0) + + backgroundScope.launch { state.collect {} } + runCurrent() + assertThat(state.value).isEqualTo(1) + + upstream.value = 2 + runCurrent() + assertThat(state.value).isEqualTo(2) + } + + @Test + fun `keeps upstream alive for the stop timeout after the last collector leaves`() = runTest { + val upstream = MutableStateFlow(1) + val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0, stopTimeoutMillis = 1_000L) + + val collector = backgroundScope.launch { state.collect {} } + runCurrent() + collector.cancel() + runCurrent() + assertThat(upstream.subscriptionCount.value).isEqualTo(1) + + advanceTimeBy(1_001L) + runCurrent() + assertThat(upstream.subscriptionCount.value).isEqualTo(0) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dfb1413..41e189e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,6 +75,7 @@ should scan the submodule's Gradle scripts (see §5). | Plumbing | `core-time` (JVM) | Pure logic; no Android, no deps. | | Plumbing | `core-crash` (Android) | Self-contained subsystem; parameterized per app. | | Plumbing | `core-prefs` (Android) | Typed DataStore layer. The store's name and construction stay with the app. | +| Plumbing | `core-android` (Android) | Receiver, permission, alarm and Flow helpers. No Compose, no DI. | | Plumbing | `core-di` (JVM) | Dispatcher/scope qualifiers + their Hilt module. The only module that knows about DI. | | Design — identity | `identity` (Android) | Theme factory + motion. App supplies seed/palette. | | Design — components | `components` (Android) | Shared Compose primitives. App composes screens. | @@ -95,7 +96,7 @@ Two kinds of module: - **JVM library** (`core-time`) — `org.jetbrains.kotlin.jvm`, `jvmTarget = 17`, JUnit 5 + Truth tests. The right home for pure logic; cheapest to build and test. -- **Android library** (`core-crash`, `core-prefs`, `identity`, `components`) — +- **Android library** (`core-crash`, `core-prefs`, `core-android`, `identity`, `components`) — `com.android.library` + the Compose plugin, `compileSdk 37` / `minSdk 29`. > **AGP 9.x provides built-in Kotlin compilation.** Android modules therefore diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f57ac4..98b86ad 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,6 +17,7 @@ divergent or app-specific code where it lives. | `core-crash` | Android | Agendula | | `core-prefs` | Android | Clockula | | `core-di` | JVM | Clockula | +| `core-android` | Android | — (Calendula pending) | | `identity` | Android | Agendula | | `components` | Android | Agendula | | `glance` | Android | Agendula (Calendula pending — its own M3 Expressive widget pass) | diff --git a/settings.gradle.kts b/settings.gradle.kts index ba063be..fef090c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include(":core-locale") include(":core-crash") include(":core-prefs") include(":core-di") +include(":core-android") include(":identity") include(":components") include(":glance") From 0f7beb828df18568e589aeccfab45448cce7506b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 15 Oct 2026 16:53:31 +0200 Subject: [PATCH 8/9] docs: list the new components in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dbd0ce2..6054b1d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-android` | Android | Small Android plumbing: `broadcastTicks` (a Flow that ticks on system broadcasts), a coroutine `BroadcastReceiver.goAsync { }`, `Context.hasPermission`, `AlarmManager.setWakeup` (exact, or inexact allow-while-idle without the capability) and `Flow.stateWhileSubscribed`. No Compose, no DI. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | -| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | +| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`/`GroupedSwitchRow`/`GroupedHint`, `IconInfoCard`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`/`OptionRows`/`CustomExpandRow`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | | `glance` | Android | Home-screen-widget plumbing on Jetpack Glance: `FloretGlanceTheme`, the rounded `WidgetSurface` (solid/tinted/transparent backgrounds), `WidgetHeader`/`WidgetActionButton`, the position-aware `WidgetGroupedRow`, `WidgetMessage`, `WidgetRollover` (the midnight-rollover alarm) and `FloretWidgetUpdateReceiver`. App supplies its own colour schemes, data and receiver glue. | _The principle: the **mechanics** are shared, the **look** stays per-app. See From 3b376ac2dd9c42a70f255e1b1ccd6b22bd2168f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 12:55:30 +0200 Subject: [PATCH 9/9] core-prefs: typed snapshot access and nullableBooleanPref --- CHANGELOG.md | 4 ++ .../de/jeanlucmakiola/floret/prefs/Pref.kt | 10 +++++ .../floret/prefs/PrefStoreTest.kt | 41 +++++++++++++++++++ .../jeanlucmakiola/floret/prefs/PrefTest.kt | 4 +- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1bfdc..cc88271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`core-prefs`** — `Preferences[pref]` and `MutablePreferences[pref] = value`, + so an app can read several prefs out of one `PrefStore.data` snapshot and + write several in one `PrefStore.edit` without dropping to raw keys. Plus + `nullableBooleanPref`, for a tri-state flag where unset differs from `false`. - **`components`** — picker-row helpers lifted from Calendula's pickers and settings, all additive. `GroupedRow` takes `checked` (default `false`): it implies `selected` and fills an empty trailing slot with `SelectedCheck`, diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt index 785660b..cc7c7cc 100644 --- a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt @@ -54,10 +54,20 @@ fun stringSetPref(name: String, default: Set = emptySet()): Pref = nullablePref(stringPreferencesKey(name), name) +/** Absent reads back as `null`; writing `null` removes the key. */ +fun nullableBooleanPref(name: String): Pref = + nullablePref(booleanPreferencesKey(name), name) + /** Absent reads back as `null`; writing `null` removes the key. */ fun nullableLongPref(name: String): Pref = nullablePref(longPreferencesKey(name), name) +/** [pref]'s value in this snapshot, for reading several prefs in one `PrefStore.data` map. */ +operator fun Preferences.get(pref: Pref): T = pref.read(this) + +/** Writes [pref] inside a multi-key `PrefStore.edit`, with the same encoding as `PrefStore.set`. */ +operator fun MutablePreferences.set(pref: Pref, value: T) = pref.write(this, value) + /** An absent key reads back as [default]; the value is stored under [key] verbatim. */ private fun scalarPref(key: Preferences.Key, name: String, default: T): Pref = Pref( diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt index ea34046..7d0360e 100644 --- a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt @@ -281,4 +281,45 @@ class PrefStoreTest { assertThat(store.get(pref)).isFalse() } + + @Test + fun `a nullable boolean keeps false apart from unset`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableBooleanPref("b") + assertThat(store.get(pref)).isNull() + + store.set(pref, false) + assertThat(store.get(pref)).isFalse() + + store.set(pref, null) + assertThat(store.get(pref)).isNull() + } + + @Test + fun `several prefs read from one snapshot`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val count = intPref("count", 1) + val mode = enumPref("mode", ThemeMode.SYSTEM) + store.set(mode, ThemeMode.DARK) + + val snapshot = store.data.first() + + assertThat(snapshot[count] to snapshot[mode]).isEqualTo(1 to ThemeMode.DARK) + } + + @Test + fun `a multi-key edit writes typed prefs with their codecs`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val mode = enumPref("mode", ThemeMode.SYSTEM) + val folder = nullableStringPref("folder") + store.set(folder, "content://x") + + store.edit { + it[mode] = ThemeMode.BLACK + it[folder] = null + } + + assertThat(store.data.first()[stringPreferencesKey("mode")]).isEqualTo("BLACK") + assertThat(store.get(folder)).isNull() + } } diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt index 4ec96d3..1ba43fb 100644 --- a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt @@ -46,11 +46,11 @@ class PrefTest { @Test fun `the nullable factories default to null`() { - val prefs = listOf(nullableStringPref("s"), nullableLongPref("l")) + val prefs = listOf(nullableStringPref("s"), nullableLongPref("l"), nullableBooleanPref("b")) val defaults = prefs.map { it.default } - assertThat(defaults).containsExactly(null, null) + assertThat(defaults).containsExactly(null, null, null) } @Test