Merge pull request 'feat: shared rows, onboarding pieces and Android helpers from Calendula' (#14) from feat/calendula-shared-batch into main

Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/14
This commit is contained in:
Jean-Luc Makiola
2026-10-15 17:09:45 +02:00
23 changed files with 620 additions and 50 deletions
+40
View File
@@ -30,6 +30,46 @@ 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`.
- **`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.
- **`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`.
- **`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
#000 and pulls the other containers down towards black, keeping their hue
+1
View File
@@ -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
```
+4 -2
View File
@@ -37,12 +37,13 @@ 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<T>` + `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`. |
| `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
@@ -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)
```
@@ -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),
)
@@ -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),
)
}
@@ -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
@@ -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
@@ -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(
@@ -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 <T> OptionPicker(
@@ -118,28 +121,49 @@ fun <T> 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 <T> OptionRows(
options: List<T>,
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,
)
}
}
@@ -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,
)
}
}
+47
View File
@@ -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)
}
@@ -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
}
@@ -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<Unit> = 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) }
@@ -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 <T> Flow<T>.stateWhileSubscribed(
scope: CoroutineScope,
initialValue: T,
stopTimeoutMillis: Long = 5_000L,
): StateFlow<T> = stateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis), initialValue)
@@ -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
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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()
@@ -19,6 +19,9 @@ sealed interface ReminderOverride {
data class Minutes(val minutes: List<Int>) : ReminderOverride
}
/** The lead times of a [ReminderOverride.Minutes]; empty for [ReminderOverride.Inherit] and [ReminderOverride.None]. */
fun ReminderOverride.minutesOrEmpty(): List<Int> = (this as? ReminderOverride.Minutes)?.minutes.orEmpty()
/** Distinct, ascending lead times — the canonical form stored and resolved. */
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
@@ -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()
}
}
+2 -1
View File
@@ -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
+1
View File
@@ -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) |
+1
View File
@@ -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")