Merge remote-tracking branch 'origin/main' into step-number-done
# Conflicts: # CHANGELOG.md # components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -91,6 +91,6 @@ fun InlineTextField(
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
modifier = modifier.revealOnFocus(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -54,10 +54,20 @@ fun stringSetPref(name: String, default: Set<String> = emptySet()): Pref<Set<Str
|
||||
fun nullableStringPref(name: String): Pref<String?> =
|
||||
nullablePref(stringPreferencesKey(name), name)
|
||||
|
||||
/** Absent reads back as `null`; writing `null` removes the key. */
|
||||
fun nullableBooleanPref(name: String): Pref<Boolean?> =
|
||||
nullablePref(booleanPreferencesKey(name), name)
|
||||
|
||||
/** Absent reads back as `null`; writing `null` removes the key. */
|
||||
fun nullableLongPref(name: String): Pref<Long?> =
|
||||
nullablePref(longPreferencesKey(name), name)
|
||||
|
||||
/** [pref]'s value in this snapshot, for reading several prefs in one `PrefStore.data` map. */
|
||||
operator fun <T> Preferences.get(pref: Pref<T>): T = pref.read(this)
|
||||
|
||||
/** Writes [pref] inside a multi-key `PrefStore.edit`, with the same encoding as `PrefStore.set`. */
|
||||
operator fun <T> MutablePreferences.set(pref: Pref<T>, value: T) = pref.write(this, value)
|
||||
|
||||
/** An absent key reads back as [default]; the value is stored under [key] verbatim. */
|
||||
private fun <T : Any> scalarPref(key: Preferences.Key<T>, name: String, default: T): Pref<T> =
|
||||
Pref(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user