diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f12e6..9caee90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,60 @@ are no binary releases; "version" tracks the shared `version` in the root build - **`components`** — `StepNumber` is public, with a `done` flag that swaps the numeral for a check, for numbered steps an app builds from its own `GroupedRow`s (e.g. rows that open a setting). +- **`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`, + 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 + 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: 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 821e7ef..6054b1d 100644 --- a/README.md +++ b/README.md @@ -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` + `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) ``` 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..06e1dec 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( @@ -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/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/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/InstructionSteps.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt index 4f2dca2..3571bdb 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt @@ -1,6 +1,5 @@ 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 @@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -21,7 +19,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 @@ -75,7 +72,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 }, ) } @@ -153,22 +150,5 @@ fun StepNumber(number: Int, done: Boolean = false) { } } -/** - * 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/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/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, + ) } } 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 } +} 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, ) } } 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/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() 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/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 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/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() + } } 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/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() + } +} 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")