Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ffaed925 | ||
|
|
76e07fe8cb | ||
|
|
513c135ec2 | ||
|
|
d6b55476d1 | ||
|
|
6dc7bc50c5 | ||
|
|
96509d0f9d | ||
|
|
68d6432434 | ||
|
|
a34a74fb8b | ||
|
|
4858e00dfa | ||
|
|
44ab9cd966 | ||
|
|
a1d36ba1f0 | ||
|
|
29b455341a | ||
|
|
3449583225 | ||
|
|
9f90027dcd | ||
|
|
3b376ac2dd | ||
|
|
5093e1cc5f | ||
|
|
0f7beb828d | ||
|
|
394ac12236 | ||
|
|
1cc7f8f71d | ||
|
|
d4970ba2bc | ||
|
|
71212c49d2 | ||
|
|
762ea5fe57 | ||
|
|
b1905dac00 | ||
|
|
f1ded81c03 | ||
|
|
0b44d353d2 | ||
|
|
675724411e | ||
|
|
8b33635a85 | ||
|
|
318aeab6bd | ||
|
|
b7e73ee880 | ||
|
|
1d6b1ed2a9 | ||
|
|
1ab46897c3 | ||
|
|
db4be68dad | ||
|
|
ffca74791f | ||
|
|
a344b2aa04 | ||
|
|
94cb850fa4 | ||
|
|
8ed9b1dbdc | ||
|
|
1e49fd5067 | ||
|
|
d5f5ad7b9d | ||
|
|
14c13bae6d | ||
|
|
ae5b7548d0 | ||
|
|
103a37cb28 | ||
|
|
97b4d24724 | ||
|
|
1af24841ae | ||
|
|
d7b7a542cd | ||
|
|
692c0a84c4 | ||
|
|
3dc3448265 | ||
|
|
ad440e8cac | ||
|
|
c6071239ce | ||
|
|
05c79f2afc | ||
|
|
4e0b97538a | ||
|
|
1705e67eeb | ||
|
|
98dcaf09ad | ||
|
|
a366ca9188 | ||
|
|
63661b9a7e | ||
|
|
b36c330ada | ||
|
|
ea860ed781 | ||
|
|
fd1f283b77 | ||
|
|
b9475688a8 | ||
|
|
3134098965 | ||
|
|
e047a2bd48 | ||
|
|
71a4f371b6 | ||
|
|
b08fb69843 | ||
|
|
b614e427df | ||
|
|
fc4ff6b7dc | ||
|
|
ed1d3ca5e8 | ||
|
|
38e508b259 | ||
|
|
bb3ff1f316 | ||
|
|
b4d3ead8f0 | ||
|
|
9bbef911c0 | ||
|
|
396e538903 | ||
|
|
75b90d1b3b | ||
|
|
b4954713ff | ||
|
|
df8bdbaf73 | ||
|
|
9fe655a33b |
+347
-1
@@ -6,10 +6,356 @@ All notable changes to floret-kit are documented here. The format follows
|
||||
floret-kit is consumed **from source** — each app embeds it as a git submodule
|
||||
and builds it through a Gradle composite build, pinning a specific commit. There
|
||||
are no binary releases; "version" tracks the shared `version` in the root build
|
||||
(currently `0.2.0`). Entries are grouped by module.
|
||||
(currently `0.7.0`). Entries are grouped by module.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`core-reminders`** — reminders can count back from a target's start as
|
||||
well as its due/end date. `ReminderLead(minutes, fromStart)`, an optional
|
||||
`fromStart` list on `ReminderOverride.Minutes` (with `leads` and
|
||||
`Minutes.of(leads)`), lead-aware `ReminderOverrideCodec.parseLeads` /
|
||||
`serializeLeads` (start leads stored as `s30`, plain values still read as
|
||||
before due) and lead versions of the override-map helpers. Additive:
|
||||
minute-only callers are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`core-locale`** — picking a language did nothing on Android 13+. AppCompat's
|
||||
`setApplicationLocales` only reaches the platform through a live
|
||||
`AppCompatActivity`, and every app uses `ComponentActivity`. `AppLanguage`
|
||||
now calls `LocaleManager` directly there: `apply(context, tag)` and
|
||||
`currentTag(context[, supported])`. The context-free versions are deprecated.
|
||||
`LanguagePickerRow` uses the new ones; apps that call `AppLanguage` themselves
|
||||
(Agendula's settings row) need to pass a context.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Build** — dependencies to the newest stable releases: AGP 9.4.1, Kotlin
|
||||
2.4.20, Gradle 9.8.0, Compose BOM 2026.09.00, core-ktx 1.19.1, appcompat
|
||||
1.8.0, JUnit 6.1.3; material3 to `1.5.0-alpha29` (still no stable 1.5 with the
|
||||
Expressive APIs). AGP must match across a composite build, so a consuming app
|
||||
re-pinning to this needs AGP 9.4.1 itself.
|
||||
|
||||
### Added
|
||||
|
||||
- **`glance`** — widget-refresh plumbing lifted from Calendula and Agendula.
|
||||
`GlanceAppWidget.updatePlaced(context, receiverClass)` redraws every placed
|
||||
instance using ids from `AppWidgetManager`, because `updateAll`'s own lookup
|
||||
comes back empty in a process that hasn't hosted a widget session yet. With
|
||||
`bumpRefreshStamp = true` it also moves each instance's `currentRefreshStamp()`,
|
||||
which a widget can key its data load on, since an update to a running
|
||||
session only recomposes and never re-runs `provideGlance`.
|
||||
`updateWithRefresh(context, glanceId)` does the same for one instance, and
|
||||
`reloadOnUpdate` replaces a receiver's `super.onUpdate` so the periodic
|
||||
update reloads too. `WidgetData` is
|
||||
the composition side: `provideGlance` creates it with what it loaded and the
|
||||
`refreshStamp` it loaded under, and `current(keys…) { load() }` reloads when
|
||||
the keys move. One instance is shared by every size of a `SizeMode.Exact`
|
||||
widget, and a failed load keeps the last data on screen.
|
||||
`WidgetRefresher` turns a burst of "data changed" requests into one debounced
|
||||
redraw, for an app's own writes. `FloretWidgetUpdateReceiver` takes
|
||||
`extraRearmInterval`, so an extra action such as `PROVIDER_CHANGED` can also
|
||||
re-arm the rollover alarm, at most once per interval, as a backstop for an
|
||||
alarm the system dropped. `skipRedraw(action)` lets an app drop a redraw it has
|
||||
already done, such as the `PROVIDER_CHANGED` that echoes its own write.
|
||||
- **`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:
|
||||
`FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback
|
||||
schemes otherwise, a per-widget `WidgetThemeMode` override), `WidgetSurface`
|
||||
(solid/tinted/transparent root backgrounds with the system corner radius),
|
||||
`WidgetHeader` + `WidgetActionButton` (a 48dp tonal/filled pill, extendable
|
||||
with a label), `WidgetGroupedRow` (the Glance counterpart of `components`'
|
||||
`GroupedSurface`, built from four position-shaped drawables since Glance's
|
||||
`cornerRadius` has no per-corner overload), `WidgetMessage`, and the
|
||||
`WidgetRollover` + `FloretWidgetUpdateReceiver` midnight-rollover plumbing.
|
||||
Converged from Agendula's widget package, written for the M3 Expressive
|
||||
widget pass (calendula#71); Calendula adopts it when its own widget overhaul
|
||||
lands.
|
||||
|
||||
## [0.7.0] - 2026-09-12
|
||||
|
||||
### Added
|
||||
|
||||
- **`components`** — `CollapsingScaffold` takes `floatingActionButton` and
|
||||
`floatingActionButtonPosition`, forwarded to the underlying `Scaffold`. Both
|
||||
default to the bare scaffold, so every existing call site in every app is
|
||||
unchanged, and they sit after `snackbarHost` so no positional call shifts.
|
||||
The driver is Clockula's alarms tab: three of its four tabs need an "add"
|
||||
affordance on a scaffold the kit owns, and the alternative — a `Box` wrapping
|
||||
the scaffold with a bottom-end FAB — re-derives the window-inset and
|
||||
position maths `Scaffold` already does, once per tab and again in every
|
||||
sibling app.
|
||||
|
||||
## [0.6.0] - 2026-09-11
|
||||
|
||||
### Changed
|
||||
|
||||
- **`components`** — `CollapsingScaffold`'s `onBack` is now nullable and
|
||||
defaults to null. A null `onBack` draws no navigation icon and wires no
|
||||
predictive-back handler; every existing call site, which passes a lambda,
|
||||
is unchanged. The driver is Clockula's four tabs: a top-level destination
|
||||
wants exactly this scaffold — the large title that shrinks into the bar over
|
||||
a scrolling column — and nothing behind it to go back to, and a back arrow
|
||||
that pops nothing is worse than no arrow at all. Cheaper and truer than each
|
||||
app hand-rolling a second, arrow-less scaffold beside this one.
|
||||
|
||||
## [0.5.0] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- **`core-prefs`** — the family's typed DataStore layer, extracted now that
|
||||
Clockula is the third app that would have written it again. `Pref<T>` is one
|
||||
preference as data — its on-disk key name, its default and its codec — built
|
||||
by `booleanPref`/`intPref`/`longPref`/`floatPref`/`stringPref`/`stringSetPref`,
|
||||
the nullable pair (`nullableStringPref`/`nullableLongPref`, where writing
|
||||
`null` *removes* the key rather than storing a sentinel), `enumPref`, and
|
||||
`map()` for a view of the same slot as another type. `PrefStore` wraps a
|
||||
`DataStore<Preferences>` with `get`/`set`/`update`/`clear`/`clearAll`, a raw
|
||||
`edit` escape hatch, and `flow(pref)` — which is `distinctUntilChanged`, so an
|
||||
unrelated key's write no longer re-emits every screen's setting, the bug both
|
||||
siblings' hand-rolled `data.map { … }` carried. `String?.toEnum(default)` is
|
||||
the read path for every enum-valued preference: unknown, empty and wrong-case
|
||||
values degrade to the default instead of throwing. Also the shared appearance
|
||||
slice — `ThemeMode` (with `isDark(systemDark)`, the `when` block that was
|
||||
duplicated in both siblings' `MainActivity`), `Appearance` and
|
||||
`AppearancePrefs`. The stored key names are byte-identical to what Calendula
|
||||
and Agendula already write (`theme_mode`, `dynamic_color`), so adopting the
|
||||
module is a code change with **no data migration**. The store's *name* and its
|
||||
construction stay with the app. Consumed by Clockula.
|
||||
|
||||
- **`core-di`** — the family's shared DI vocabulary: the `@IoDispatcher`,
|
||||
`@DefaultDispatcher` and `@ApplicationScope` qualifiers and the
|
||||
`CoroutinesModule` that provides them (the application scope on a
|
||||
`SupervisorJob`, so one failing collector can't take its siblings down). Every
|
||||
app in the family had re-declared the same qualifier and the same provider.
|
||||
The kit's only module that knows about Dagger, and its only one carrying an
|
||||
annotation processor — a `@Module @InstallIn(...)` reaches a consuming app's
|
||||
component only if the library itself is run through `hilt-compiler`. Consumed
|
||||
by Clockula.
|
||||
|
||||
## [0.4.0] - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- **`components`** — `GroupedSectionHeader`, the label above a run of
|
||||
`GroupedRow`s: small, `primary`, inset to `GroupedListInset` so it shares the
|
||||
cards' left margin. Converged from Calendula's settings hub, where the recipe
|
||||
was written as a private `SectionHeader`, and now Agendula's, which had grown
|
||||
a flat run of a dozen rows with nothing separating how the app behaves from
|
||||
what it is.
|
||||
|
||||
- **`components`** — `GroupedRow` takes a `contentColor`, the pair to the
|
||||
existing `container`: a row given a tint now names the `on*` role that goes
|
||||
with it instead of leaving its text on `onSurface`, which only happens to
|
||||
read on a container the theme did not pick. Wanted by a status row that
|
||||
colours itself by outcome (Agendula's export receipt: `secondaryContainer` on
|
||||
success, `errorContainer` on failure).
|
||||
|
||||
- **`components`** — `ScaledViewPreview` and `ViewPreviewFrame`, the preview
|
||||
picker's two halves: the first measures real screen composables at a phone
|
||||
viewport and scales the finished layout down (swallowing touches and
|
||||
collapsing semantics, so a preview reads as a picture rather than a control),
|
||||
the second is the tonal plate it sits on, cross-fading between options and
|
||||
taking `GroupedListInset` and the grouped rows' outer radius so preview and
|
||||
options read as one stack. Converged from Calendula's view/month-style
|
||||
choosers, where the pattern was invented, and now Agendula's onboarding step
|
||||
for the quick-add bar; the viewport height the app scales from is a parameter
|
||||
rather than a calendar-shaped constant.
|
||||
|
||||
- **`components`** — `InstructionSteps`, a numbered, connected list for a
|
||||
procedure the user carries out somewhere else. Collapsible, so it can come
|
||||
back as a reference once it has had a screen of its own.
|
||||
|
||||
- **`components`** — `InlineTextField` takes a `visualTransformation`, so a
|
||||
password can be masked. `KeyboardType.Password` only drops IME suggestions;
|
||||
without this the field rendered the secret in the clear.
|
||||
|
||||
- **`components`** — `ReorderableColumn` takes a `rowHeight` (default
|
||||
`ReorderableRowHeight`), so rows keep their normal height while reordering.
|
||||
The drag shadow now follows the card's shape, and a row resists being dragged
|
||||
past either end.
|
||||
|
||||
- **`core-time`** — `formatTime`, `formatDateTime` and `formatDateTimeCompact`
|
||||
take an optional `is24Hour`, for an app-level 12/24-hour setting. `null`
|
||||
keeps following the system.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`identity`** — A committed predictive back no longer snaps the surface back
|
||||
to full size before it leaves. `Modifier.predictiveBack()` now holds the
|
||||
preview transform after `onBack`, and the new `predictiveBackExit()` exit
|
||||
fades and keeps shrinking from there; pair them on the surface's
|
||||
`AnimatedVisibility` instead of a full-size slide-out. Pass that
|
||||
`AnimatedVisibility`'s `visible` to `predictiveBack(visible = …)` (or
|
||||
`CollapsingScaffold(visible = …)`) so the preview is dropped as soon as the
|
||||
surface is shown again — reopened mid-exit it no longer appears shrunk — and
|
||||
settles back when a back leaves it in place. Without it the preview is held
|
||||
for a fixed second.
|
||||
- **`core-crash`** — The prefilled crash issue is filed in English again. Its
|
||||
title and body came from string resources, so a crash on a French phone
|
||||
opened the tracker with a French issue — unreadable to whoever has to triage
|
||||
it. That text is now fixed English in `CrashReportSubmit`, out of resources
|
||||
altogether, and `CrashConfig` no longer takes an `issueTitle` (apps drop the
|
||||
argument, and their own `crash_report_issue_title` / `crash_report_body_*`
|
||||
strings with it). Everything the reporter *sees* — the dialog, the toasts,
|
||||
the clipboard label — stays translated.
|
||||
|
||||
## [0.3.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`components`** — `OnboardingScaffold`, the shared shell for a first-run
|
||||
flow: hero, body and a call-to-action stack, so the family's onboarding
|
||||
screens read as one family while keeping their own mark. A multi-step flow
|
||||
passes `progress`, which `OnboardingProgress` draws as one capsule per step
|
||||
with the caller's own localised label, and `navigationIcon` for a step that
|
||||
can be stepped back from — the navigation slot's width is reserved whether or
|
||||
not it is filled and mirrored on the other side, so the progress stays centred
|
||||
as steps come and go. `topSpacing` gives a step whose hero is a large picture
|
||||
rather than a mark room to breathe; `contentPadding` is the scrolling column's
|
||||
horizontal inset, which a step built from grouped rows passes 0 for since
|
||||
those carry their own; and `scrollingActions` moves the actions off the bottom
|
||||
bar onto the end of the content, for a step taller than the screen where a
|
||||
pinned bar eats the room the content needs and hides that there is more below.
|
||||
`BenefitRow` is the icon/title/body line those screens list their points with.
|
||||
|
||||
- **`components`** — `SnackChip`, the family's compact confirmation: a
|
||||
content-sized pill sliding up from the bottom with an optional trailing action,
|
||||
for the case where the surface behind the confirmation *is* the confirmation
|
||||
(a list that just lost a row, a calendar that just moved an event) and a
|
||||
full-width snackbar would cover it. Placement and timing stay with the caller —
|
||||
the bottom inset to clear differs per screen, and what an expiring undo window
|
||||
commits is the app's business. Converged from Agendula's swipe-delete undo chip
|
||||
and Calendula's drag-to-reschedule confirmation; the shared version keeps the
|
||||
polite live region so the chip is announced, takes a `maxWidth` so a message
|
||||
can't run into a bottom-end FAB, and takes `color`/`contentColor` so an app can
|
||||
put it on the FAB's own fill. A message replaced in place crossfades and the
|
||||
pill resizes with it, so an answered confirmation ("Moved" → "Move undone")
|
||||
reads as one chip changing rather than two chips swapping.
|
||||
|
||||
- **`components`** — `GroupedRow` takes an `AnnotatedString` title and summary
|
||||
as well as plain `String`s, so a row can carry its own emphasis: marking the
|
||||
run a search matched, without every caller having to rebuild the row. The
|
||||
`String` overload delegates to it, so existing callers are unchanged.
|
||||
|
||||
- **`components`** — `GroupedSurface`/`GroupedRow` take an `onLongClick` (with
|
||||
the platform long-press haptic), for a list that enters a selection mode from
|
||||
a row.
|
||||
|
||||
- **`components`** — `GroupedListInset`, the 16dp a grouped list keeps from the
|
||||
screen edge, exported. It was a private literal, so everything sitting outside
|
||||
the cards but belonging to them — section headers, hints, picker descriptions —
|
||||
repeated the number and had drifted to 24dp in places, leaving pages with two
|
||||
ragged left margins.
|
||||
|
||||
- **`components`** — `OptionPicker` takes a per-option summary, put under the
|
||||
label, for a row that names something it doesn't spell out (a range's dates, a
|
||||
reminder's firing time, what "follow the system" resolves to today). Every
|
||||
option then shows its effect at once and stays comparable, without the cost of
|
||||
a live preview. The KDoc states the three rungs of picker richness — plain
|
||||
rows / per-option summary / live preview — and the rule that a preview picker
|
||||
must not close on selection, since closing hides the thing it exists to show.
|
||||
|
||||
- **`components`** — `SelectedCheck`, the trailing check that marks a chosen row
|
||||
in a full-screen picker, made public. It was private to `OptionPicker`, so
|
||||
every consumer hand-rolled a copy — Calendula alone carried five, all drawing
|
||||
a different check than the kit's.
|
||||
|
||||
- **`components`** — `AboutCard` takes a `Position` and renders through
|
||||
`GroupedSurface`, so it can be the top of a group with rows continuing
|
||||
underneath rather than only standing alone. Defaults to `Position.Alone`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`core-time`** — all-day values rendered a day early west of UTC. All-day
|
||||
items are date-only in iCalendar and stored as UTC midnight of their calendar
|
||||
date, but `formatDate`/`formatDateTime`/`formatDateTimeCompact` resolved every
|
||||
instant against `ZoneId.systemDefault()`. `formatDate` now takes an `allDay`
|
||||
flag (default `false`, so existing call sites are unaffected) and resolves
|
||||
those against UTC; callers must pass it at the call site.
|
||||
|
||||
- **`components`** — full-screen picker status/navigation-bar icons matched the
|
||||
XML theme rather than the running one. A Compose `Dialog` owns its own window,
|
||||
seeded from the XML theme, so a picker drawing under the bars showed dark icons
|
||||
on a dark surface. `FloretExpressiveTheme` now exposes its resolved light/dark
|
||||
decision as `LocalFloretDarkTheme` and the dialog window's bar appearance
|
||||
follows it, tracking the in-app choice even when it diverges from the system
|
||||
night qualifier.
|
||||
|
||||
## [0.2.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- **`components`** — `CollapsingScaffold` takes a `scrollable` flag (default
|
||||
`true`, so every existing caller is unchanged). Set it to `false` when the
|
||||
content brings its own scrolling container: the scaffold's own
|
||||
`verticalScroll` composes each row up front and can't host a `LazyColumn`
|
||||
(nesting a same-axis scrollable throws), which ruled out any surface with more
|
||||
options than fit an eager pass. Opting out hands the content a plain column and
|
||||
leaves the insets, IME padding and top-bar collapse as they were.
|
||||
`FullScreenPicker` forwards the flag. Consumed by Calendula's ~600-entry
|
||||
time-zone picker from 2.16.0.
|
||||
|
||||
## [0.2.0] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
+8
-1
@@ -36,6 +36,9 @@ practical how.
|
||||
```sh
|
||||
./gradlew :core-time:test # JVM module (JUnit 5 + Truth)
|
||||
./gradlew :core-crash:testDebugUnitTest
|
||||
./gradlew :core-prefs:testDebugUnitTest
|
||||
./gradlew :core-di:test
|
||||
./gradlew :core-android:testDebugUnitTest
|
||||
./gradlew :identity:assembleDebug
|
||||
./gradlew build # everything
|
||||
```
|
||||
@@ -50,7 +53,11 @@ source.
|
||||
[`settings.gradle.kts`](settings.gradle.kts).
|
||||
2. Add a `<module>/build.gradle.kts`:
|
||||
- **JVM:** `plugins { alias(libs.plugins.kotlin.jvm) }`, a `kotlin { compilerOptions { jvmTarget = JVM_17 } }` block, JUnit 5 test deps.
|
||||
- **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret.<area>"`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`.
|
||||
- **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret.<area>"`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`. An Android module with no
|
||||
composables in it (e.g. `core-prefs`) applies only `android.library`.
|
||||
- **Needs an annotation processor?** Add `alias(libs.plugins.ksp)` and declare
|
||||
the processor with `ksp(...)` — see `core-di`, whose Hilt `@Module` has to be
|
||||
processed in the library for a consuming app's component to see it.
|
||||
3. Put sources under `src/main/kotlin/de/jeanlucmakiola/floret/<area>/`. The root
|
||||
build's `subprojects {}` sets the shared `group`/`version`, so no per-module
|
||||
coordinates are needed.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
The shared **Material 3 Expressive design system + plumbing** for the Floret
|
||||
family of Android apps ([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
|
||||
[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future siblings).
|
||||
[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula),
|
||||
[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future siblings).
|
||||
|
||||
"Floret" is the family's design language: a Calendula flower head is a cluster of
|
||||
many small *florets*, and each app is one of them. This repo is the bloom they
|
||||
@@ -36,10 +37,14 @@ 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`. |
|
||||
| `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), `FloretWidgetUpdateReceiver`, and the refresh helpers `updatePlaced` / `WidgetData` / `WidgetRefresher`. App supplies its own colour schemes, data and receiver glue. |
|
||||
|
||||
_The principle: the **mechanics** are shared, the **look** stays per-app. See
|
||||
[`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and
|
||||
@@ -57,5 +62,8 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md)
|
||||
```sh
|
||||
./gradlew :core-time:test # JVM module tests
|
||||
./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)
|
||||
```
|
||||
|
||||
+2
-1
@@ -6,9 +6,10 @@ plugins {
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
group = "de.jeanlucmakiola.floret"
|
||||
version = "0.2.0"
|
||||
version = "0.7.0"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ dependencies {
|
||||
// core-locale backs the language-picker recipe (AppLanguage).
|
||||
implementation(project(":core-locale"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.foundation)
|
||||
|
||||
+34
-9
@@ -13,6 +13,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
@@ -44,8 +45,17 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
*
|
||||
* Optional extras, all defaulting to the bare scaffold so existing callers are
|
||||
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
|
||||
* transient messages; [predictiveBack] (off by default) wires the gesture
|
||||
* preview to [onBack] for full-screen surfaces that want it.
|
||||
* transient messages; [floatingActionButton] (with
|
||||
* [floatingActionButtonPosition]) places a FAB the underlying [Scaffold]
|
||||
* positions and insets — for a list destination whose primary action is "add";
|
||||
* [predictiveBack] (off by default) wires the gesture preview to [onBack] for
|
||||
* full-screen surfaces that want it, with [visible] passed through to
|
||||
* [Modifier.predictiveBack] for a surface that is shown and hidden in place.
|
||||
*
|
||||
* [onBack] is optional. Pass null for a **top-level destination** — a tab in an
|
||||
* app's navigation bar, say — which has nothing to go back to: no navigation
|
||||
* icon is drawn, and [predictiveBack] wires nothing, so the gesture falls
|
||||
* through to whoever owns the back stack above.
|
||||
*
|
||||
* Set [scrollable] to false when [content] brings its own scrolling container.
|
||||
* The default column scrolls as a whole, which composes every row up front —
|
||||
@@ -61,13 +71,16 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@Composable
|
||||
fun CollapsingScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
largeTopBar: Boolean = true,
|
||||
predictiveBack: Boolean = false,
|
||||
visible: Boolean? = null,
|
||||
scrollable: Boolean = true,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
floatingActionButton: @Composable () -> Unit = {},
|
||||
floatingActionButtonPosition: FabPosition = FabPosition.End,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
@@ -77,17 +90,27 @@ fun CollapsingScaffold(
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack) else Modifier)
|
||||
.then(
|
||||
if (predictiveBack && onBack != null) {
|
||||
Modifier.predictiveBack(onBack = onBack, visible = visible)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
val navigationIcon = @Composable {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
// A top-level destination has nothing to go back to, so a null
|
||||
// [onBack] draws no arrow at all rather than an arrow that lies.
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (largeTopBar) {
|
||||
@@ -113,6 +136,8 @@ fun CollapsingScaffold(
|
||||
}
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
floatingActionButton = floatingActionButton,
|
||||
floatingActionButtonPosition = floatingActionButtonPosition,
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ButtonGroupDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A grouped row holding [title] over a connected M3 Expressive button group,
|
||||
* for a choice of two or three short options that reads better inline than
|
||||
* behind a full-screen picker. Selecting applies at once.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun <T> GroupedChoiceRow(
|
||||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
GroupedSurface(position = position, modifier = modifier.padding(horizontal = GroupedListInset)) {
|
||||
Column(modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 12.dp)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
ToggleButton(
|
||||
checked = option == selected,
|
||||
onCheckedChange = { onSelect(option) },
|
||||
shapes = when (index) {
|
||||
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.semantics { role = Role.RadioButton },
|
||||
) {
|
||||
Text(label(option), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,12 +15,16 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -37,6 +43,42 @@ fun positionOf(index: Int, count: Int): Position = when {
|
||||
else -> Position.Middle
|
||||
}
|
||||
|
||||
/**
|
||||
* The inset a grouped list keeps from the screen edge.
|
||||
*
|
||||
* Exported because it is a *shared* line, not a private detail of [GroupedRow]:
|
||||
* everything that sits outside the cards but belongs to them — section headers,
|
||||
* hints, picker descriptions — has to start on the same edge, or the page reads
|
||||
* as two ragged columns. Take this value rather than writing 16.dp again.
|
||||
*/
|
||||
val GroupedListInset = 16.dp
|
||||
|
||||
/**
|
||||
* The label above a run of [GroupedRow]s, for a page whose groups are named
|
||||
* rather than merely spaced apart — a settings hub that separates how the app
|
||||
* behaves from what it is.
|
||||
*
|
||||
* Small, `primary`, and inset to [GroupedListInset] so it shares the left
|
||||
* margin of the cards it names. Converged from Calendula's settings hub, where
|
||||
* the recipe was written; a caller separates one group from the next with its
|
||||
* own 8dp spacer, since a hub's first header follows the About card rather than
|
||||
* another group.
|
||||
*/
|
||||
@Composable
|
||||
fun GroupedSectionHeader(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
|
||||
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
@@ -55,13 +97,17 @@ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (positio
|
||||
* Set [gapBelow] to false to suppress that 2dp separation — for a caller that
|
||||
* owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must
|
||||
* share the same pitch for the drag maths to line up.
|
||||
*
|
||||
* [onLongClick] adds a long-press to the same surface (with the platform's
|
||||
* long-press haptic), for lists that enter a selection mode from a row.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GroupedSurface(
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
gapBelow: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
@@ -76,11 +122,25 @@ fun GroupedSurface(
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
}
|
||||
val base = modifier.fillMaxWidth().then(gap)
|
||||
if (onClick != null) {
|
||||
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
|
||||
} else {
|
||||
Surface(color = color, shape = shape, modifier = base) { content() }
|
||||
val base = modifier.fillMaxWidth().then(gap).revealOnFocus()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
when {
|
||||
onLongClick != null -> Surface(
|
||||
color = color,
|
||||
shape = shape,
|
||||
modifier = base.combinedClickable(
|
||||
interactionSource = interaction,
|
||||
indication = ripple(),
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
},
|
||||
),
|
||||
) { content() }
|
||||
onClick != null ->
|
||||
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
|
||||
else -> Surface(color = color, shape = shape, modifier = base) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +153,17 @@ fun GroupedSurface(
|
||||
* headline and summary to the M3 disabled emphasis while leaving the [trailing]
|
||||
* control at full opacity — for rows that are present but switched off.
|
||||
* [container] overrides the row's tonal colour (e.g. a category tint); ignored
|
||||
* when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row
|
||||
* gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn].
|
||||
* when [selected]. [contentColor] is its pair — the `on*` role for that
|
||||
* container, so a tinted row keeps its contrast in both themes instead of
|
||||
* leaving the text on `onSurface`. [gapBelow] (see [GroupedSurface]) suppresses
|
||||
* the inter-row gap for a caller that owns its own uniform spacing, e.g.
|
||||
* [ReorderableColumn]. [onLongClick] (see [GroupedSurface]) adds a long-press to
|
||||
* the row.
|
||||
*
|
||||
* [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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
title: String,
|
||||
@@ -106,13 +173,63 @@ fun GroupedRow(
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
checked: Boolean = false,
|
||||
) = GroupedRow(
|
||||
title = AnnotatedString(title),
|
||||
position = position,
|
||||
modifier = modifier,
|
||||
summary = summary?.let { AnnotatedString(it) },
|
||||
selected = selected,
|
||||
dimmed = dimmed,
|
||||
container = container,
|
||||
contentColor = contentColor,
|
||||
minHeight = minHeight,
|
||||
gapBelow = gapBelow,
|
||||
leading = leading,
|
||||
trailing = trailing,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
checked = checked,
|
||||
)
|
||||
|
||||
/**
|
||||
* [GroupedRow] with styled text, for a row whose title or summary carries its own
|
||||
* emphasis — a search hit marking the run that matched, say. Identical otherwise;
|
||||
* the plain-[String] overload delegates here.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
title: AnnotatedString,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: AnnotatedString? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
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,
|
||||
@@ -130,18 +247,27 @@ fun GroupedRow(
|
||||
leadingIconColor = muted,
|
||||
supportingColor = muted,
|
||||
)
|
||||
} else if (contentColor != null) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = contentColor,
|
||||
leadingIconColor = contentColor,
|
||||
supportingColor = contentColor,
|
||||
trailingIconColor = contentColor,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
highlighted -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
GroupedSurface(
|
||||
position = position,
|
||||
modifier = modifier.padding(horizontal = 16.dp),
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
color = containerColor,
|
||||
gapBelow = gapBelow,
|
||||
) {
|
||||
@@ -149,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
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,14 @@ fun InlineTextField(
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
/**
|
||||
* How the value is rendered, as opposed to stored.
|
||||
*
|
||||
* Exists so a password can be masked. [KeyboardType.Password] alone does
|
||||
* not mask anything — it only tells the IME to drop suggestions — so a
|
||||
* borderless field without this renders the secret in the clear.
|
||||
*/
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = when {
|
||||
@@ -66,6 +75,7 @@ fun InlineTextField(
|
||||
keyboardActions = onImeAction?.let { action ->
|
||||
KeyboardActions(onAny = { action() })
|
||||
} ?: KeyboardActions.Default,
|
||||
visualTransformation = visualTransformation,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
@@ -81,6 +91,6 @@ fun InlineTextField(
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
modifier = modifier.revealOnFocus(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A short procedure the user has to carry out somewhere else, as a connected
|
||||
* list of numbered steps.
|
||||
*
|
||||
* A component rather than a paragraph, because the instructions that matter most
|
||||
* in a sign-in flow are for a *different* app — go to this site, turn that
|
||||
* setting on, bring the result back here — and four actions written as prose is
|
||||
* the form in which people miss the third one.
|
||||
*
|
||||
* Built from [GroupedRow] rather than as a tinted card, so a procedure looks
|
||||
* like every other run of rows in the family: one rounded container, full
|
||||
* corners at the ends, small ones between. That buys two things a card did not.
|
||||
* Each step is a row of its own, so a long one wraps inside its own container
|
||||
* instead of pushing the rest of a paragraph around; and the numerals sit in the
|
||||
* leading slot every other row puts an avatar or icon in, which is what makes
|
||||
* the column of them scan as a sequence.
|
||||
*
|
||||
* [title] renders as the group's [GroupedSectionHeader] — omit it where the
|
||||
* screen already carries the headline, or the same sentence is said twice.
|
||||
* [footnote] is the caveat that is not a step: what this will cost, or what it
|
||||
* will not do.
|
||||
*
|
||||
* [collapsible] folds the whole thing behind its [title] as a row that opens on
|
||||
* a tap, closed to begin with. For a procedure the user has *already been shown*
|
||||
* — carried along afterwards so it can be re-read rather than remembered — that
|
||||
* is the difference between a reference and a wall repeating itself. It needs a
|
||||
* [title], since collapsed there is nothing else to tap.
|
||||
*/
|
||||
@Composable
|
||||
fun InstructionSteps(
|
||||
steps: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
footnote: String? = null,
|
||||
collapsible: Boolean = false,
|
||||
) {
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
// Collapsed only where there is a header to reopen from.
|
||||
val folds = collapsible && title != null
|
||||
Column(modifier) {
|
||||
when {
|
||||
folds -> {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
// Alone while shut, the head of the run once open — the
|
||||
// corners are what say whether anything follows.
|
||||
position = if (open) Position.Top else Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
// No disc: the empty leading slot is what tells the header
|
||||
// apart from the numbered rows it opens.
|
||||
trailing = { ExpandIcon(open) },
|
||||
onClick = { open = !open },
|
||||
)
|
||||
}
|
||||
|
||||
title != null -> GroupedSectionHeader(title)
|
||||
}
|
||||
if (folds && !open) return@Column
|
||||
steps.forEachIndexed { index, step ->
|
||||
GroupedRow(
|
||||
title = step,
|
||||
// The header is the group's first row when there is one, so the
|
||||
// steps run from the middle rather than starting the shape over.
|
||||
position = if (folds) {
|
||||
positionOf(index + 1, steps.size + 1)
|
||||
} else {
|
||||
positionOf(index, steps.size)
|
||||
},
|
||||
// Roomy enough for one line, free to grow for three. A step that
|
||||
// wraps is the normal case here, not the exception.
|
||||
minHeight = 56.dp,
|
||||
leading = { StepNumber(index + 1) },
|
||||
)
|
||||
}
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Outside the cards but belonging to them, so it starts on the
|
||||
// same line they do.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's number, in the leading slot.
|
||||
*
|
||||
* A filled `primary` disc rather than a bare numeral: on a neutral row the
|
||||
* accent is what marks the column as a sequence rather than a list of unrelated
|
||||
* settings, and it holds that reading at a glance without a rule down the side.
|
||||
*
|
||||
* Public for steps an app builds from its own rows, e.g. ones that open a setting.
|
||||
* [done] swaps the numeral for a check once the step has been carried out.
|
||||
*/
|
||||
@Composable
|
||||
fun StepNumber(number: Int, done: Boolean = false) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(StepNumberSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (done) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = number.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The leading slot's own size, matched to the icons that usually fill it. */
|
||||
private val StepNumberSize = 28.dp
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -13,21 +14,31 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
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
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
|
||||
/** Standard touch target, reserved for the shell's navigation slot. */
|
||||
private val NavigationSlotSize = 48.dp
|
||||
|
||||
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||
object OnboardingSpace {
|
||||
val xs = 8.dp
|
||||
@@ -43,27 +54,68 @@ object OnboardingSpace {
|
||||
* bar). The content slot is centred horizontally; benefit rows fill the width so
|
||||
* their own content left-aligns. Each app supplies its own hero so the apps read
|
||||
* as one family while keeping their own mark.
|
||||
*
|
||||
* A multi-step flow passes [progress] (see [OnboardingProgress]), which is
|
||||
* pinned above the scrolling content, and [navigationIcon] for a step that can
|
||||
* be stepped back from. The navigation slot's width is reserved whether or not
|
||||
* it is filled, and mirrored on the other side, so the progress stays centred
|
||||
* and does not shift as steps come and go.
|
||||
*
|
||||
* [topSpacing] gives a step whose hero is a large picture rather than a mark
|
||||
* room to breathe. [contentPadding] is the scrolling column's own horizontal
|
||||
* inset — a step built from grouped rows passes 0, since those carry their own.
|
||||
*
|
||||
* [scrollingActions] moves the actions off the bottom bar and onto the end of
|
||||
* 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(
|
||||
hero: @Composable () -> Unit,
|
||||
actions: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
topSpacing: Dp = OnboardingSpace.xl,
|
||||
contentPadding: Dp = OnboardingSpace.md,
|
||||
scrollingActions: Boolean = false,
|
||||
headline: String? = null,
|
||||
supporting: String? = null,
|
||||
body: @Composable ColumnScope.() -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
if (progress != null || navigationIcon != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = OnboardingSpace.xs, vertical = OnboardingSpace.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(NavigationSlotSize)) { navigationIcon?.invoke() }
|
||||
Box(Modifier.weight(1f)) { progress?.invoke() }
|
||||
Spacer(Modifier.width(NavigationSlotSize))
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = actions,
|
||||
)
|
||||
// Pinned actions carry the navigation-bar inset themselves; scrolling
|
||||
// ones get it from the scaffold's own content padding.
|
||||
if (!scrollingActions) {
|
||||
ActionsColumn(
|
||||
horizontalPadding = OnboardingSpace.md,
|
||||
insetBottom = true,
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
@@ -71,18 +123,146 @@ fun OnboardingScaffold(
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = OnboardingSpace.md),
|
||||
.padding(horizontal = contentPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
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.
|
||||
if (scrollingActions) {
|
||||
ActionsColumn(
|
||||
horizontalPadding = 0.dp,
|
||||
insetBottom = false,
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
horizontalPadding: Dp,
|
||||
insetBottom: Boolean,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (insetBottom) Modifier.navigationBarsPadding() else Modifier)
|
||||
.padding(horizontal = horizontalPadding, vertical = OnboardingSpace.sm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a multi-step onboarding flow has got to: one capsule per step, filled up
|
||||
* to and including [step] (1-based), with [label] — the caller's own localised
|
||||
* "Step 2 of 4" — underneath.
|
||||
*
|
||||
* The capsules carry no semantics of their own; [label] is the accessible
|
||||
* description, so a screen reader hears the position once rather than counting
|
||||
* decorations.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingProgress(
|
||||
step: Int,
|
||||
total: Int,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clearAndSetSemantics { },
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
repeat(total) { index ->
|
||||
val done = index < step
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (done) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
},
|
||||
label = "onboarding-progress-segment",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||
@Composable
|
||||
fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.WindowCompat
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
|
||||
/**
|
||||
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
|
||||
@@ -24,6 +26,20 @@ import androidx.compose.ui.window.DialogWindowProvider
|
||||
* [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content]
|
||||
* scrolls itself, which a picker only needs for an option list long enough to
|
||||
* warrant a lazy container.
|
||||
*
|
||||
* **Picker richness.** Three shapes, in ascending order — take the cheapest one
|
||||
* that makes the choice obvious:
|
||||
* 1. plain option rows, when the label *is* the meaning ("English", "Dark");
|
||||
* 2. rows plus a per-option summary of the concrete effect, when the meaning is
|
||||
* a date, a time or a number the label only names ("Next 7 days" →
|
||||
* "30 Jul – 5 Aug"). [OptionPicker]'s `summary` covers this;
|
||||
* 3. a live preview above the rows, when the effect is *visual* and no words
|
||||
* carry it (month grid style, dimmed past events, week start).
|
||||
*
|
||||
* A picker of the third kind **must not close when an option is tapped** — it
|
||||
* applies immediately and stays open, because closing would hide the very thing
|
||||
* the screen exists to show. The user leaves via back. The first two kinds close
|
||||
* on tap as usual.
|
||||
*/
|
||||
@Composable
|
||||
fun FullScreenPicker(
|
||||
@@ -41,14 +57,28 @@ fun FullScreenPicker(
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// The dialog window pans by default when the keyboard opens, which —
|
||||
// A Compose Dialog owns a separate android.view.Window whose bar-icon
|
||||
// appearance is seeded from the XML theme, not from the activity window's
|
||||
// runtime edge-to-edge state — so with decorFitsSystemWindows = false it
|
||||
// draws edge-to-edge under bars whose icons don't match the active theme
|
||||
// (dark icons on a dark picker). Drive the appearance from the runtime
|
||||
// [LocalFloretDarkTheme] so it tracks the in-app light/dark choice even
|
||||
// when that diverges from the system night qualifier.
|
||||
//
|
||||
// Same window also pans by default when the keyboard opens, which —
|
||||
// combined with the content's own imePadding — leaves a fixed black gap
|
||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||
// full-screen and imePadding alone lifts the focused field.
|
||||
val view = LocalView.current
|
||||
val darkTheme = LocalFloretDarkTheme.current
|
||||
SideEffect {
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
(view.parent as? DialogWindowProvider)?.window?.let { window ->
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
@@ -66,6 +96,18 @@ fun FullScreenPicker(
|
||||
* General single-select picker, full-screen: each option is a connected grouped
|
||||
* row and the current one carries a check. The drop-in for the family's option
|
||||
* dialogs (theme, default list, reminder offset, …).
|
||||
*
|
||||
* [summary] adds a second line per option — reserve it for the option's
|
||||
* *concrete effect*, the thing its label names but doesn't spell out: the dates
|
||||
* a range resolves to, the time a format renders as, what "follow the system"
|
||||
* currently means. Returning null leaves that row single-line, so one option
|
||||
* (typically the automatic one) can carry a note the others don't need. It is
|
||||
* 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(
|
||||
@@ -75,35 +117,68 @@ fun <T> OptionPicker(
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
summary: (@Composable (T) -> String?)? = null,
|
||||
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),
|
||||
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
|
||||
private fun SelectedCheck() {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing check that marks the chosen row in a full-screen picker — the
|
||||
* family's single selection affordance, so every picker (option, reminder,
|
||||
* agenda range, timezone, calendar, recurrence) marks its selection the same
|
||||
* way. Pair it with `GroupedRow(selected = true)`: the tonal highlight carries
|
||||
* the state at a glance, the check confirms it unambiguously.
|
||||
*
|
||||
* Purely decorative — the row itself owns the semantics, so it takes no
|
||||
* content description.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectedCheck() {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
|
||||
+50
-7
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -21,17 +20,25 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.translate
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
/** Default uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
val ReorderableRowHeight: Dp = 64.dp
|
||||
private val RowGap: Dp = 2.dp
|
||||
|
||||
@@ -41,7 +48,7 @@ private val RowGap: Dp = 2.dp
|
||||
* Built for the short, fixed grouped-card lists in Settings — no external
|
||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* can't nest a scrolling list). Rows are a fixed [rowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the order is committed immediately with a
|
||||
@@ -52,6 +59,9 @@ private val RowGap: Dp = 2.dp
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||
* Pass [rowHeight] matching the rows' non-reorder `minHeight`, so they keep their
|
||||
* size when a screen switches into reordering. [rowInset] is the card's horizontal
|
||||
* inset inside its slot, so the held row's shadow hugs the card, not the slot.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ReorderableColumn(
|
||||
@@ -59,9 +69,11 @@ fun <T> ReorderableColumn(
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowHeight: Dp = ReorderableRowHeight,
|
||||
rowInset: Dp = GroupedListInset,
|
||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||
val pitchPx = with(LocalDensity.current) { (rowHeight + RowGap).toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Local working copy; re-seeded when the incoming list changes (including the
|
||||
@@ -72,6 +84,8 @@ fun <T> ReorderableColumn(
|
||||
// release settle — re-based onto the row's new slot once the order commits
|
||||
// (see onDragEnd), then eased down to zero.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The finger's own travel; [dragOffset] is this held to the list's ends.
|
||||
var rawOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
@@ -119,10 +133,13 @@ fun <T> ReorderableColumn(
|
||||
settleJob?.cancel()
|
||||
draggedKey = key
|
||||
dragOffset = 0f
|
||||
rawOffset = 0f
|
||||
},
|
||||
onDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragOffset += amount.y
|
||||
rawOffset += amount.y
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
dragOffset = rubberBand(rawOffset, -from * pitchPx, (order.lastIndex - from) * pitchPx, pitchPx * 0.4f)
|
||||
},
|
||||
onDragEnd = {
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
@@ -163,7 +180,7 @@ fun <T> ReorderableColumn(
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.height(ReorderableRowHeight)
|
||||
.height(rowHeight)
|
||||
.zIndex(if (isDragged) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
translationY = if (isDragged) dragOffset else animatedShift
|
||||
@@ -171,7 +188,7 @@ fun <T> ReorderableColumn(
|
||||
scaleX = 1.02f
|
||||
scaleY = 1.02f
|
||||
shadowElevation = 8.dp.toPx()
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
shape = InsetShape(groupedShape(positionOf(index, order.size), 22.dp, 6.dp), rowInset)
|
||||
clip = false
|
||||
}
|
||||
},
|
||||
@@ -181,3 +198,29 @@ fun <T> ReorderableColumn(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [base] laid out [inset] in from both sides of the slot. */
|
||||
private class InsetShape(private val base: Shape, private val inset: Dp) : Shape {
|
||||
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
|
||||
val dx = with(density) { inset.toPx() }
|
||||
val inner = Size((size.width - 2 * dx).coerceAtLeast(0f), size.height)
|
||||
return when (val outline = base.createOutline(inner, layoutDirection, density)) {
|
||||
is Outline.Rectangle -> Outline.Rectangle(outline.rect.translate(Offset(dx, 0f)))
|
||||
is Outline.Rounded -> Outline.Rounded(outline.roundRect.translate(Offset(dx, 0f)))
|
||||
is Outline.Generic -> Outline.Generic(Path().apply { addPath(outline.path, Offset(dx, 0f)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [value] as is between [min] and [max]; past either end it keeps following with
|
||||
* growing resistance and never gets further than [stretch] beyond it.
|
||||
*/
|
||||
private fun rubberBand(value: Float, min: Float, max: Float, stretch: Float): Float {
|
||||
fun resist(over: Float) = stretch * (1f - 1f / (over / stretch + 1f))
|
||||
return when {
|
||||
value > max -> max + resist(value - max)
|
||||
value < min -> min - resist(min - value)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -13,12 +14,10 @@ import androidx.compose.foundation.layout.height
|
||||
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.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,6 +40,12 @@ data class AboutLink(val icon: ImageVector, val label: String, val url: String)
|
||||
* outlined buttons (e.g. source, licence) and an optional full-width
|
||||
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
|
||||
* browser. The app supplies its own logo, strings and links.
|
||||
*
|
||||
* [position] lets the card join a grouped list instead of standing alone: with
|
||||
* [Position.Top] it takes the group's top corners and the 2dp gap below, so a
|
||||
* [GroupedRow] underneath continues the same container. Use that when the card's
|
||||
* call to action reads better as a row of its own than as a button inside the
|
||||
* card — the two then form one block rather than a card with a lid on it.
|
||||
*/
|
||||
@Composable
|
||||
fun AboutCard(
|
||||
@@ -49,15 +54,16 @@ fun AboutCard(
|
||||
author: String,
|
||||
primaryLinks: List<AboutLink>,
|
||||
highlightLink: AboutLink? = null,
|
||||
position: Position = Position.Alone,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val open = { url: String ->
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
||||
Unit
|
||||
}
|
||||
Surface(
|
||||
GroupedSurface(
|
||||
position = position,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
@@ -106,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(
|
||||
@@ -114,13 +121,16 @@ fun LanguagePickerRow(
|
||||
title: String,
|
||||
autoLabel: String,
|
||||
@XmlRes localesConfig: Int,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
predictiveBack: Boolean = false,
|
||||
leading: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||
val supported = remember { AppLanguage.supportedTags(context, localesConfig) }
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag(context, supported)) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, localesConfig) }
|
||||
val options = remember(supported) { listOf<String?>(null) + supported }
|
||||
val label: (String?) -> String = { tag -> tag?.let { AppLanguage.displayName(it) } ?: autoLabel }
|
||||
|
||||
GroupedRow(
|
||||
@@ -139,9 +149,11 @@ fun LanguagePickerRow(
|
||||
label = { label(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
AppLanguage.apply(context, it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
header = header,
|
||||
predictiveBack = predictiveBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.TransformOrigin
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** The band a snack chip is meant to sit in: the FAB's height, and its margin. */
|
||||
val SnackChipHeight: Dp = 56.dp
|
||||
val SnackChipMargin: Dp = 16.dp
|
||||
|
||||
/**
|
||||
* The family's compact confirmation: a rounded pill sized to its content,
|
||||
* springing open at the bottom, with an optional trailing action ("Undo").
|
||||
*
|
||||
* Preferred over a full-width snackbar wherever the surface behind it *is* the
|
||||
* confirmation — a task list that just lost a row, a calendar that just moved an
|
||||
* event: a bar across the bottom hides the very change it reports, while a pill
|
||||
* on the FAB's own band leaves it visible.
|
||||
*
|
||||
* Placement stays with the caller, because the bottom inset to clear differs per
|
||||
* screen (a scaffold's own padding, the navigation bar, a bottom bar). Anchor it
|
||||
* bottom-start in a [SnackChipHeight] band with [SnackChipMargin] around it, and
|
||||
* pass [maxWidth] where a bottom-end FAB shares that band — the message then
|
||||
* ellipsises instead of running into it.
|
||||
*
|
||||
* The chip carries no timer: how long a confirmation stands, and what expiry
|
||||
* commits, belongs to the caller's own undo window.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun SnackChip(
|
||||
visible: Boolean,
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
maxWidth: Dp = Dp.Unspecified,
|
||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor: Color = contentColorFor(color),
|
||||
) {
|
||||
// The expressive *fast* springs: a chip is a receipt, not a transition. It
|
||||
// springs open from its anchored corner rather than sliding in — a slide is
|
||||
// clipped to the animating bounds, so the pill appears to be painted on from
|
||||
// one edge, which at this size reads as a glitch rather than as motion.
|
||||
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
val corner = TransformOrigin(0f, 1f)
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = scaleIn(spatial, initialScale = 0.8f, transformOrigin = corner) +
|
||||
fadeIn(effects),
|
||||
exit = scaleOut(effects, targetScale = 0.9f, transformOrigin = corner) +
|
||||
fadeOut(effects),
|
||||
modifier = modifier,
|
||||
) {
|
||||
val action = if (actionLabel != null && onAction != null) actionLabel else null
|
||||
Surface(
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(50),
|
||||
shadowElevation = 6.dp,
|
||||
// A chip whose message is replaced in place — "Moved" answered by
|
||||
// "Move undone" — grows or shrinks into the new width instead of
|
||||
// snapping, so the swap reads as one chip changing its mind.
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.animateContentSize(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = 20.dp,
|
||||
end = if (action != null) 8.dp else 20.dp,
|
||||
top = 6.dp,
|
||||
bottom = 6.dp,
|
||||
),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = message,
|
||||
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
|
||||
label = "snack-chip-message",
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// A chip that appears without being focused is announced
|
||||
// only if it says it is worth announcing.
|
||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
)
|
||||
}
|
||||
if (action != null) {
|
||||
TextButton(
|
||||
onClick = { onAction?.invoke() },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(text = action, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a preview pretends to be, when the caller does not say: a phone's
|
||||
* width by the height a full-screen view gets under a top bar. Scaling every
|
||||
* preview from one fixed height keeps a chooser's options comparable — each
|
||||
* shows the same slice of screen.
|
||||
*/
|
||||
val DefaultPreviewViewport = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for a chooser that shows what it is offering rather
|
||||
* than describing it. Callers pass the *real* screen composables, so a preview
|
||||
* cannot drift from what it depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the content measures at
|
||||
* a phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*
|
||||
* [viewportHeight] is what the content is measured at before scaling; raise it
|
||||
* for a screen that needs more room to read than [DefaultPreviewViewport].
|
||||
*/
|
||||
@Composable
|
||||
fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp = DefaultPreviewViewport,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / viewportHeight.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// content's own clickables see them, and give screen readers one
|
||||
// label instead of every row inside it.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = viewportHeight.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage a preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the [GroupedRow]s underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is
|
||||
* [GroupedListInset], the rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FrameShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FrameInset),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real screen at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PreviewShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FrameInset = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FrameShape = RoundedCornerShape(22.dp)
|
||||
private val PreviewShape = RoundedCornerShape(12.dp)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ package de.jeanlucmakiola.floret.crash
|
||||
*
|
||||
* URLs are passed as plain strings (resolved from each app's own resources)
|
||||
* rather than kit resources, since they differ per app and are not translated.
|
||||
*
|
||||
* The prefilled issue's title and body are *not* here: they are posted to the
|
||||
* tracker rather than shown in the app, so they are fixed English text owned by
|
||||
* the kit (see [submitCrashReport]) and cannot pick up the device language.
|
||||
*/
|
||||
data class CrashConfig(
|
||||
/** Display name, e.g. "Agendula". Used in the report header and dialog copy. */
|
||||
@@ -16,6 +20,4 @@ data class CrashConfig(
|
||||
val newIssueUrl: String,
|
||||
/** Issue tracker entry point for a manual (non-crash) report — e.g. a chooser. */
|
||||
val chooseIssueUrl: String,
|
||||
/** Title used for the prefilled crash issue, e.g. "Crash report". */
|
||||
val issueTitle: String,
|
||||
)
|
||||
|
||||
@@ -14,12 +14,15 @@ import androidx.core.net.toUri
|
||||
* automatically — the apps have no network access; the user reviews and submits
|
||||
* the issue themselves. Issue-tracker URLs and the app label come from the
|
||||
* installed [CrashReporter.config].
|
||||
*
|
||||
* What the user *sees* here — the dialog, the toasts, the clipboard label — is
|
||||
* translated. What is *posted* is not: see [buildIssueBody].
|
||||
*/
|
||||
fun submitCrashReport(context: Context, report: String) {
|
||||
val config = CrashReporter.config ?: return
|
||||
copyReportToClipboard(context, config.appLabel, report)
|
||||
val opened = runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, config, report)))
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(config, report)))
|
||||
}.isSuccess
|
||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
@@ -43,20 +46,33 @@ private fun copyReportToClipboard(context: Context, appLabel: String, report: St
|
||||
* "paste from clipboard" placeholder) — the clipboard copy is the source of
|
||||
* truth in that case.
|
||||
*/
|
||||
private fun buildIssueUri(context: Context, config: CrashConfig, report: String) =
|
||||
private fun buildIssueUri(config: CrashConfig, report: String) =
|
||||
config.newIssueUrl.toUri().buildUpon()
|
||||
.appendQueryParameter("title", config.issueTitle)
|
||||
.appendQueryParameter("body", buildIssueBody(context, config.appLabel, report))
|
||||
.appendQueryParameter("title", ISSUE_TITLE)
|
||||
.appendQueryParameter("body", buildIssueBody(config.appLabel, report))
|
||||
.build()
|
||||
|
||||
private fun buildIssueBody(context: Context, appLabel: String, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||
context.getString(R.string.crash_report_body_paste)
|
||||
} else {
|
||||
"```\n$report\n```"
|
||||
}
|
||||
return context.getString(R.string.crash_report_body_template, appLabel, block)
|
||||
/**
|
||||
* The prefilled issue body. Deliberately hard-coded English rather than a string
|
||||
* resource: this text is filed in the project's issue tracker, where maintainers
|
||||
* read it, so it must not follow the device language — a report from a French
|
||||
* phone used to arrive as a French issue. Everything the reporter sees inside
|
||||
* the app stays translated.
|
||||
*/
|
||||
internal fun buildIssueBody(appLabel: String, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) REPORT_TOO_LONG else "```\n$report\n```"
|
||||
return "Thanks for reporting a crash in $appLabel. Please add anything you remember " +
|
||||
"about what you were doing, then submit.\n\n" +
|
||||
"### What happened\n\n\n" +
|
||||
"### Crash report\n$block\n"
|
||||
}
|
||||
|
||||
/** Title of the prefilled issue. English, for the same reason as the body. */
|
||||
internal const val ISSUE_TITLE = "Crash report"
|
||||
|
||||
/** Stands in for a report too long to carry in the link; it is on the clipboard. */
|
||||
private const val REPORT_TOO_LONG =
|
||||
"_(The report was too long for this link — paste it from your clipboard here.)_"
|
||||
|
||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
Generic crash-report UI strings shared by the family. App-agnostic: the
|
||||
app's display name is injected as %1$s from CrashConfig.appLabel, so these
|
||||
read correctly for any app. Issue-tracker URLs are NOT here — they are
|
||||
per-app and come from CrashConfig. Apps may override any of these strings.
|
||||
per-app and come from CrashConfig. Neither is the prefilled issue's title
|
||||
or body: that text is posted to the tracker rather than shown in the app,
|
||||
so it is fixed English in CrashReportSubmit.kt and never translated.
|
||||
Apps may override any of the strings below.
|
||||
-->
|
||||
<string name="crash_dialog_title">%1$s crashed</string>
|
||||
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or app content — only the technical details below.</string>
|
||||
@@ -12,8 +15,6 @@
|
||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<!-- Fallback app label if the dialog is shown before install() (should not happen). -->
|
||||
<string name="crash_app_fallback">The app</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The issue title and body are posted to the tracker, not shown in the app, so
|
||||
* they must stay English whatever language the device is in — a crash from a
|
||||
* French phone once filed a French issue. Pinning them as plain text (rather
|
||||
* than string resources) is what guarantees it; these tests hold that line.
|
||||
*/
|
||||
class IssueBodyTest {
|
||||
|
||||
@Test
|
||||
fun `body is English regardless of the device locale`() {
|
||||
val body = withLocale(Locale.FRANCE) { buildIssueBody("Calendula", "boom") }
|
||||
assertThat(body).contains("Thanks for reporting a crash in Calendula.")
|
||||
assertThat(body).contains("### What happened")
|
||||
assertThat(body).contains("### Crash report")
|
||||
assertThat(ISSUE_TITLE).isEqualTo("Crash report")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short report rides along in a fenced block`() {
|
||||
val body = buildIssueBody("Agendula", "stack trace here")
|
||||
assertThat(body).contains("```\nstack trace here\n```")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an over-long report is replaced by the clipboard placeholder`() {
|
||||
val body = buildIssueBody("Clockula", "x".repeat(7_000))
|
||||
assertThat(body).doesNotContain("xxxx")
|
||||
assertThat(body).contains("paste it from your clipboard here")
|
||||
}
|
||||
|
||||
private fun <T> withLocale(locale: Locale, block: () -> T): T {
|
||||
val previous = Locale.getDefault()
|
||||
Locale.setDefault(locale)
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
Locale.setDefault(previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// core-di — the family's shared DI vocabulary: the `@IoDispatcher`,
|
||||
// `@DefaultDispatcher` and `@ApplicationScope` qualifiers plus the Hilt module
|
||||
// that provides them. A plain JVM library (nothing here touches Android), but
|
||||
// the one kit module that carries an annotation processor: a `@Module
|
||||
// @InstallIn(...)` only reaches a consuming app's component if the *library*
|
||||
// itself is run through `hilt-compiler`, which generates the `@AggregatedDeps`
|
||||
// metadata the app's processor reads off the classpath.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// `CoroutineDispatcher` / `CoroutineScope` are returned from the module.
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.hilt.core)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.truth)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.floret.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Provides the family's dispatchers and the application-lifetime scope. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CoroutinesModule {
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
/** SupervisorJob, so one failing collector can't take the others down with it. */
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun applicationScope(@DefaultDispatcher dispatcher: CoroutineDispatcher): CoroutineScope =
|
||||
CoroutineScope(SupervisorJob() + dispatcher)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.jeanlucmakiola.floret.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/** The IO [kotlinx.coroutines.CoroutineDispatcher] — disk, network, content providers. */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
/** The CPU-bound default [kotlinx.coroutines.CoroutineDispatcher]. */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
/**
|
||||
* The process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that outlives
|
||||
* any screen. Never cancelled, so nothing unbounded may be launched in it.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.floret.di
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.coroutines.ContinuationInterceptor
|
||||
|
||||
/** What the Hilt module hands out, checked by calling the provider functions directly. */
|
||||
class CoroutinesModuleTest {
|
||||
|
||||
@Test
|
||||
fun `ioDispatcher provides the IO dispatcher`() {
|
||||
val dispatcher = CoroutinesModule.ioDispatcher()
|
||||
|
||||
assertThat(dispatcher).isSameInstanceAs(Dispatchers.IO)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defaultDispatcher provides the Default dispatcher`() {
|
||||
val dispatcher = CoroutinesModule.defaultDispatcher()
|
||||
|
||||
assertThat(dispatcher).isSameInstanceAs(Dispatchers.Default)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the application scope runs on the dispatcher it was given`() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
|
||||
val scope = CoroutinesModule.applicationScope(dispatcher)
|
||||
|
||||
assertThat(scope.coroutineContext[ContinuationInterceptor]).isSameInstanceAs(dispatcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing child does not take down its sibling`() = runTest {
|
||||
val scope = CoroutinesModule.applicationScope(UnconfinedTestDispatcher(testScheduler))
|
||||
val swallow = CoroutineExceptionHandler { _, _ -> }
|
||||
var siblingCompleted = false
|
||||
|
||||
scope.launch(swallow) { error("boom") }
|
||||
scope.launch { siblingCompleted = true }
|
||||
|
||||
assertThat(siblingCompleted && scope.isActive).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the application scope is handed out live`() {
|
||||
val scope = CoroutinesModule.applicationScope(Dispatchers.Unconfined)
|
||||
|
||||
val job = scope.coroutineContext[Job]
|
||||
|
||||
assertThat(job?.isActive).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.floret.di
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.Module
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.lang.annotation.Retention
|
||||
import java.lang.annotation.RetentionPolicy
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* The DI vocabulary itself: an annotation only works as a Dagger qualifier if it
|
||||
* carries `@Qualifier` and survives into the class file.
|
||||
*/
|
||||
class QualifiersTest {
|
||||
|
||||
private val qualifiers = listOf(
|
||||
IoDispatcher::class.java,
|
||||
DefaultDispatcher::class.java,
|
||||
ApplicationScope::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `IoDispatcher is a qualifier`() {
|
||||
val annotation = IoDispatcher::class.java
|
||||
|
||||
val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java)
|
||||
|
||||
assertThat(isQualifier).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DefaultDispatcher is a qualifier`() {
|
||||
val annotation = DefaultDispatcher::class.java
|
||||
|
||||
val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java)
|
||||
|
||||
assertThat(isQualifier).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApplicationScope is a qualifier`() {
|
||||
val annotation = ApplicationScope::class.java
|
||||
|
||||
val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java)
|
||||
|
||||
assertThat(isQualifier).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every qualifier is retained in the class file`() {
|
||||
val retentions = qualifiers.map { it.getAnnotation(Retention::class.java)?.value }
|
||||
|
||||
assertThat(retentions).containsExactly(
|
||||
RetentionPolicy.CLASS,
|
||||
RetentionPolicy.CLASS,
|
||||
RetentionPolicy.CLASS,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CoroutinesModule is a Dagger module`() {
|
||||
val module = CoroutinesModule::class.java
|
||||
|
||||
val isModule = module.isAnnotationPresent(Module::class.java)
|
||||
|
||||
assertThat(isModule).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.jeanlucmakiola.floret.locale
|
||||
|
||||
import android.app.LocaleManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.LocaleList
|
||||
import androidx.annotation.XmlRes
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
@@ -18,10 +21,12 @@ private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
* makes the language show up here and in the system per-app-language settings,
|
||||
* with no other code change. The system-default choice is represented as `null`.
|
||||
*
|
||||
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||
* activity, which re-reads the current value for the picker.
|
||||
* On API 33+ this talks to the platform [LocaleManager] directly. AppCompat's
|
||||
* `setApplicationLocales` only reaches it through a live `AppCompatActivity`, so
|
||||
* from a plain `ComponentActivity` it silently did nothing. Below 33 it falls back
|
||||
* to the appcompat backport (manifest `autoStoreLocales` service), which only
|
||||
* applies to AppCompat activities. Setting a locale recreates the activity, which
|
||||
* re-reads the current value for the picker.
|
||||
*/
|
||||
object AppLanguage {
|
||||
|
||||
@@ -52,12 +57,66 @@ object AppLanguage {
|
||||
}
|
||||
|
||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||
fun currentTag(context: Context): String? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val locales = context.getSystemService(LocaleManager::class.java).applicationLocales
|
||||
return if (locales.isEmpty) null else locales[0].toLanguageTag()
|
||||
}
|
||||
return currentTag()
|
||||
}
|
||||
|
||||
@Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context)"))
|
||||
fun currentTag(): String? {
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied app language as one of [supported], or `null` when following
|
||||
* the system.
|
||||
*
|
||||
* Prefer this over [currentTag] whenever the result is compared against
|
||||
* [supportedTags]: the platform store re-parses the applied locale through
|
||||
* ICU and fills in the implied script, so a "zh-CN" choice reads back as
|
||||
* "zh-Hans-CN" and no longer string-matches the tag the picker offers.
|
||||
*/
|
||||
fun currentTag(context: Context, supported: List<String>): String? =
|
||||
resolveTag(currentTag(context), supported)
|
||||
|
||||
@Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context, supported)"))
|
||||
@Suppress("DEPRECATION")
|
||||
fun currentTag(supported: List<String>): String? = resolveTag(currentTag(), supported)
|
||||
|
||||
/**
|
||||
* Maps [tag] onto the entry of [supported] naming the same language, or
|
||||
* returns it unchanged when nothing matches. Matching widens in steps —
|
||||
* exact tag, then language plus region, then language alone — so a more
|
||||
* specific entry always wins over a looser one.
|
||||
*/
|
||||
fun resolveTag(tag: String?, supported: List<String>): String? {
|
||||
if (tag == null) return null
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
return supported.firstOrNull { it == tag }
|
||||
?: supported.firstOrNull {
|
||||
val other = Locale.forLanguageTag(it)
|
||||
other.language == locale.language && other.country == locale.country
|
||||
}
|
||||
?: supported.firstOrNull { Locale.forLanguageTag(it).language == locale.language }
|
||||
?: tag
|
||||
}
|
||||
|
||||
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||
fun apply(context: Context, tag: String?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.getSystemService(LocaleManager::class.java).applicationLocales =
|
||||
LocaleList.forLanguageTags(tag.orEmpty())
|
||||
return
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
apply(tag)
|
||||
}
|
||||
|
||||
@Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("apply(context, tag)"))
|
||||
fun apply(tag: String?) {
|
||||
val locales = if (tag == null) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
|
||||
@@ -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()
|
||||
@@ -17,4 +17,44 @@ class AppLanguageTest {
|
||||
// them title-cased so each language reads as a proper name.
|
||||
assertThat(AppLanguage.displayName("fr")).isEqualTo("Français")
|
||||
}
|
||||
|
||||
private val supported = listOf("en", "ar", "de", "es", "fr", "it", "pl", "pt-BR", "zh-CN")
|
||||
|
||||
@Test
|
||||
fun `a script the platform filled in still resolves to the shipped tag`() {
|
||||
// The per-app-language store re-parses "zh-CN" through ICU likely
|
||||
// subtags and hands it back as "zh-Hans-CN"; the picker only has
|
||||
// "zh-CN" to offer, so without this the selected row loses its mark.
|
||||
assertThat(AppLanguage.resolveTag("zh-Hans-CN", supported)).isEqualTo("zh-CN")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags that round-trip unchanged are returned as they are`() {
|
||||
assertThat(AppLanguage.resolveTag("pt-BR", supported)).isEqualTo("pt-BR")
|
||||
assertThat(AppLanguage.resolveTag("de", supported)).isEqualTo("de")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a region the app does not distinguish falls back to the language`() {
|
||||
assertThat(AppLanguage.resolveTag("en-US", supported)).isEqualTo("en")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exact match wins over a looser one`() {
|
||||
// "pt" and "pt-BR" both name Portuguese; the regional entry must not
|
||||
// swallow the plain one just by being listed first.
|
||||
val both = listOf("pt-BR", "pt")
|
||||
assertThat(AppLanguage.resolveTag("pt", both)).isEqualTo("pt")
|
||||
assertThat(AppLanguage.resolveTag("pt-BR", both)).isEqualTo("pt-BR")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unshipped language is left alone`() {
|
||||
assertThat(AppLanguage.resolveTag("ja", supported)).isEqualTo("ja")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `following the system stays null`() {
|
||||
assertThat(AppLanguage.resolveTag(null, supported)).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// core-prefs — the family's typed DataStore layer: `Pref<T>` (a key + a default +
|
||||
// its codec), `PrefStore` (typed, distinct-until-changed reads and writes over a
|
||||
// `DataStore<Preferences>`), `toEnum()` for stored values that degrade instead of
|
||||
// throwing, and the shared appearance preferences (`ThemeMode`, `dynamicColor`).
|
||||
// The store's *name* and its construction stay with the consuming app.
|
||||
//
|
||||
// An Android library rather than a JVM one: the whole surface is typed over
|
||||
// `androidx.datastore.preferences.core.DataStore<Preferences>` and the app needs
|
||||
// `Context.preferencesDataStoreFile(...)`, which ships only in the Android
|
||||
// artifact — so this module `api`-exposes it and an app gets the whole stack from
|
||||
// one dependency. No Compose here, so only the Android library plugin is applied.
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.jeanlucmakiola.floret.prefs"
|
||||
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 {
|
||||
// Both are in the module's public API: `DataStore<Preferences>` is a
|
||||
// constructor parameter and `Flow` is a return type.
|
||||
api(libs.androidx.datastore.preferences)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.truth)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.turbine)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* 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, 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,
|
||||
val dynamicColor: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* The family's shared appearance preferences. The key names are fixed at the
|
||||
* values Calendula and Agendula already write — changing one is a data migration.
|
||||
*/
|
||||
object AppearancePrefs {
|
||||
val themeMode: Pref<ThemeMode> = enumPref("theme_mode", ThemeMode.SYSTEM)
|
||||
val dynamicColor: Pref<Boolean> = booleanPref("dynamic_color", true)
|
||||
}
|
||||
|
||||
/** Both appearance choices as one distinct-until-changed stream. */
|
||||
fun PrefStore.appearance(): Flow<Appearance> = data
|
||||
.map {
|
||||
Appearance(
|
||||
themeMode = AppearancePrefs.themeMode.read(it),
|
||||
dynamicColor = AppearancePrefs.dynamicColor.read(it),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
|
||||
/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */
|
||||
fun <E : Enum<E>> String?.toEnum(entries: Collection<E>, default: E): E =
|
||||
entries.firstOrNull { it.name == this } ?: default
|
||||
|
||||
/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */
|
||||
inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
toEnum(enumValues<E>().toList(), default)
|
||||
|
||||
/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */
|
||||
fun <E : Enum<E>> enumPref(name: String, default: E, entries: Collection<E>): Pref<E> {
|
||||
val key = stringPreferencesKey(name)
|
||||
return Pref(
|
||||
name = name,
|
||||
default = default,
|
||||
key = key,
|
||||
read = { it[key].toEnum(entries, default) },
|
||||
write = { preferences, value -> preferences[key] = value.name },
|
||||
)
|
||||
}
|
||||
|
||||
/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */
|
||||
inline fun <reified E : Enum<E>> enumPref(name: String, default: E): Pref<E> =
|
||||
enumPref(name, default, enumValues<E>().toList())
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
|
||||
/**
|
||||
* One typed preference: its on-disk key name, its default, and the pair of
|
||||
* functions that read it out of / write it into a [Preferences] snapshot.
|
||||
* Instances come from the factory functions below or from [map]; the
|
||||
* constructor is internal so the stored representation stays this module's.
|
||||
*/
|
||||
class Pref<T> internal constructor(
|
||||
val name: String,
|
||||
val default: T,
|
||||
internal val key: Preferences.Key<*>,
|
||||
internal val read: (Preferences) -> T,
|
||||
internal val write: (MutablePreferences, T) -> Unit,
|
||||
) {
|
||||
/** A view of this preference as [R]; shares the key, derives the default through [decode]. */
|
||||
fun <R> map(decode: (T) -> R, encode: (R) -> T): Pref<R> = Pref(
|
||||
name = name,
|
||||
default = decode(default),
|
||||
key = key,
|
||||
read = { decode(read(it)) },
|
||||
write = { preferences, value -> write(preferences, encode(value)) },
|
||||
)
|
||||
}
|
||||
|
||||
fun booleanPref(name: String, default: Boolean): Pref<Boolean> =
|
||||
scalarPref(booleanPreferencesKey(name), name, default)
|
||||
|
||||
fun intPref(name: String, default: Int): Pref<Int> =
|
||||
scalarPref(intPreferencesKey(name), name, default)
|
||||
|
||||
fun longPref(name: String, default: Long): Pref<Long> =
|
||||
scalarPref(longPreferencesKey(name), name, default)
|
||||
|
||||
fun floatPref(name: String, default: Float): Pref<Float> =
|
||||
scalarPref(floatPreferencesKey(name), name, default)
|
||||
|
||||
fun stringPref(name: String, default: String): Pref<String> =
|
||||
scalarPref(stringPreferencesKey(name), name, default)
|
||||
|
||||
fun stringSetPref(name: String, default: Set<String> = emptySet()): Pref<Set<String>> =
|
||||
scalarPref(stringSetPreferencesKey(name), name, default)
|
||||
|
||||
/** Absent reads back as `null`; writing `null` removes the key. */
|
||||
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(
|
||||
name = name,
|
||||
default = default,
|
||||
key = key,
|
||||
read = { it[key] ?: default },
|
||||
write = { preferences, value -> preferences[key] = value },
|
||||
)
|
||||
|
||||
/**
|
||||
* Absence *is* the value here, so there is no default to fall back to and
|
||||
* writing `null` removes the key rather than storing a sentinel for it.
|
||||
*/
|
||||
private fun <T : Any> nullablePref(key: Preferences.Key<T>, name: String): Pref<T?> =
|
||||
Pref(
|
||||
name = name,
|
||||
default = null,
|
||||
key = key,
|
||||
read = { it[key] },
|
||||
write = { preferences, value ->
|
||||
if (value == null) preferences.remove(key) else preferences[key] = value
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* The typed view over a DataStore. The store's *name* and its construction stay
|
||||
* with the app; this owns only the typed read/write vocabulary.
|
||||
*/
|
||||
class PrefStore(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
/** The raw snapshot stream — the escape hatch for composing several keys in one map. */
|
||||
val data: Flow<Preferences> get() = dataStore.data
|
||||
|
||||
/** [pref]'s value, distinct-until-changed, so an unrelated key's write doesn't re-emit. */
|
||||
fun <T> flow(pref: Pref<T>): Flow<T> = dataStore.data.map(pref.read).distinctUntilChanged()
|
||||
|
||||
suspend fun <T> get(pref: Pref<T>): T = pref.read(dataStore.data.first())
|
||||
|
||||
suspend fun <T> set(pref: Pref<T>, value: T) {
|
||||
dataStore.edit { pref.write(it, value) }
|
||||
}
|
||||
|
||||
/** Atomic read-modify-write; returns the stored value after the edit. */
|
||||
suspend fun <T> update(pref: Pref<T>, transform: (T) -> T): T =
|
||||
pref.read(dataStore.edit { pref.write(it, transform(pref.read(it))) })
|
||||
|
||||
/** Removes [pref]'s key, so it reads back as its default. */
|
||||
suspend fun <T> clear(pref: Pref<T>) {
|
||||
dataStore.edit { it.remove(pref.key) }
|
||||
}
|
||||
|
||||
/** Removes every key in the store. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
/** Raw multi-key edit, for the cases the typed API can't express. */
|
||||
suspend fun edit(transform: suspend (MutablePreferences) -> Unit) {
|
||||
dataStore.edit { transform(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* The family's shared appearance preferences. The key names are the on-disk
|
||||
* contract with Calendula and Agendula, so they are asserted literally.
|
||||
*/
|
||||
class AppearancePrefsTest {
|
||||
|
||||
private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("test.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the theme mode pref is stored under theme_mode and follows the system by default`() {
|
||||
val pref = AppearancePrefs.themeMode
|
||||
|
||||
assertThat(pref.name to pref.default).isEqualTo("theme_mode" to ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dynamic colour pref is stored under dynamic_color and defaults to on`() {
|
||||
val pref = AppearancePrefs.dynamicColor
|
||||
|
||||
assertThat(pref.name to pref.default).isEqualTo("dynamic_color" to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh store reports the family's default appearance`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
|
||||
val appearance = store.appearance().first()
|
||||
|
||||
assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored theme mode shows up in the appearance`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
|
||||
store.set(AppearancePrefs.themeMode, ThemeMode.DARK)
|
||||
|
||||
assertThat(store.appearance().first())
|
||||
.isEqualTo(Appearance(ThemeMode.DARK, dynamicColor = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored dynamic colour choice shows up in the appearance`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
|
||||
store.set(AppearancePrefs.dynamicColor, false)
|
||||
|
||||
assertThat(store.appearance().first())
|
||||
.isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an appearance a sibling app wrote is read back`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
|
||||
store.edit { it[stringPreferencesKey("theme_mode")] = "LIGHT" }
|
||||
|
||||
assertThat(store.appearance().first().themeMode).isEqualTo(ThemeMode.LIGHT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the appearance stream does not re-emit for an unrelated key`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
|
||||
store.appearance().test {
|
||||
awaitItem()
|
||||
|
||||
store.edit { it[intPreferencesKey("unrelated")] = 1 }
|
||||
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* `toEnum` is the read path for every enum-valued preference: whatever is on disk,
|
||||
* it has to produce an entry rather than throw.
|
||||
*/
|
||||
class EnumParsingTest {
|
||||
|
||||
@Test
|
||||
fun `toEnum returns the entry whose name matches exactly`() {
|
||||
val stored = "DARK"
|
||||
|
||||
val parsed = stored.toEnum(ThemeMode.SYSTEM)
|
||||
|
||||
assertThat(parsed).isEqualTo(ThemeMode.DARK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEnum falls back to the default when nothing is stored`() {
|
||||
val stored: String? = null
|
||||
|
||||
val parsed = stored.toEnum(ThemeMode.SYSTEM)
|
||||
|
||||
assertThat(parsed).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEnum falls back to the default for an unknown value`() {
|
||||
val stored = "FUNDAY"
|
||||
|
||||
val parsed = stored.toEnum(ThemeMode.SYSTEM)
|
||||
|
||||
assertThat(parsed).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEnum falls back to the default for the empty string`() {
|
||||
val stored = ""
|
||||
|
||||
val parsed = stored.toEnum(ThemeMode.SYSTEM)
|
||||
|
||||
assertThat(parsed).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEnum matching is case sensitive`() {
|
||||
val stored = "dark"
|
||||
|
||||
val parsed = stored.toEnum(ThemeMode.SYSTEM)
|
||||
|
||||
assertThat(parsed).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEnum round-trips every entry name`() {
|
||||
val entries = ThemeMode.entries
|
||||
|
||||
val parsed = entries.map { it.name.toEnum(ThemeMode.SYSTEM) }
|
||||
|
||||
assertThat(parsed).containsExactlyElementsIn(entries).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* The typed read/write vocabulary over a real DataStore, one temp file per test.
|
||||
* The store runs on the test scheduler so every write and the emission it causes
|
||||
* are ordered against the assertions.
|
||||
*/
|
||||
class PrefStoreTest {
|
||||
|
||||
private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
produceFile = { tempDir.resolve("test.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an unwritten boolean reads back as its default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = booleanPref("b", true)
|
||||
|
||||
val value = store.get(pref)
|
||||
|
||||
assertThat(value).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a boolean round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = booleanPref("b", true)
|
||||
|
||||
store.set(pref, false)
|
||||
|
||||
assertThat(store.get(pref)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an int reads its default then a written negative value`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = intPref("i", 7)
|
||||
assertThat(store.get(pref)).isEqualTo(7)
|
||||
|
||||
store.set(pref, -3)
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(-3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long round-trips its maximum value`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = longPref("l", 0L)
|
||||
|
||||
store.set(pref, Long.MAX_VALUE)
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(Long.MAX_VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a float round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = floatPref("f", 1f)
|
||||
|
||||
store.set(pref, 63.5f)
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(63.5f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty string is a stored value, not an absent one`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = stringPref("s", "fallback")
|
||||
assertThat(store.get(pref)).isEqualTo("fallback")
|
||||
|
||||
store.set(pref, "")
|
||||
|
||||
assertThat(store.get(pref)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a string set reads its default then exactly what was written`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = stringSetPref("set", setOf("a"))
|
||||
assertThat(store.get(pref)).containsExactly("a")
|
||||
|
||||
store.set(pref, setOf("x", "y"))
|
||||
|
||||
assertThat(store.get(pref)).containsExactly("x", "y")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly stored empty set is distinct from the default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = stringSetPref("set", setOf("a"))
|
||||
|
||||
store.set(pref, emptySet())
|
||||
|
||||
assertThat(store.get(pref)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an enum round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = enumPref("mode", ThemeMode.SYSTEM)
|
||||
|
||||
store.set(pref, ThemeMode.LIGHT)
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(ThemeMode.LIGHT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = enumPref("mode", ThemeMode.SYSTEM)
|
||||
|
||||
store.edit { it[stringPreferencesKey("mode")] = "NOPE" }
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = enumPref("mode", ThemeMode.SYSTEM)
|
||||
|
||||
store.edit { it[stringPreferencesKey("mode")] = "" }
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nullable string reads null when unset and the value once written`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = nullableStringPref("opt")
|
||||
assertThat(store.get(pref)).isNull()
|
||||
|
||||
store.set(pref, "v")
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo("v")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing null to a nullable string removes the key`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = nullableStringPref("opt")
|
||||
store.set(pref, "v")
|
||||
|
||||
store.set(pref, null)
|
||||
|
||||
assertThat(store.data.first().contains(stringPreferencesKey("opt"))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing null to a nullable long removes the key`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = nullableLongPref("id")
|
||||
store.set(pref, 5L)
|
||||
|
||||
store.set(pref, null)
|
||||
|
||||
assertThat(store.data.first().contains(longPreferencesKey("id"))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear restores a single pref to its default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = intPref("i", 7)
|
||||
store.set(pref, 42)
|
||||
|
||||
store.clear(pref)
|
||||
|
||||
assertThat(store.get(pref)).isEqualTo(7)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearAll restores every pref to its default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val flag = booleanPref("b", true)
|
||||
val count = intPref("i", 7)
|
||||
val label = stringPref("s", "fallback")
|
||||
store.set(flag, false)
|
||||
store.set(count, 42)
|
||||
store.set(label, "written")
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertThat(listOf(store.get(flag), store.get(count), store.get(label)))
|
||||
.containsExactly(true, 7, "fallback").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update transforms the stored value`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = intPref("n", 0)
|
||||
store.set(pref, 5)
|
||||
|
||||
val returned = store.update(pref) { it + 1 }
|
||||
|
||||
assertThat(listOf(returned, store.get(pref))).containsExactly(6, 6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update of an unset pref transforms its default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = intPref("n", 10)
|
||||
|
||||
val returned = store.update(pref) { it * 2 }
|
||||
|
||||
assertThat(returned).isEqualTo(20)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a flow does not re-emit when an unrelated key is written`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val watched = booleanPref("watched", false)
|
||||
val other = intPref("other", 0)
|
||||
|
||||
store.flow(watched).test {
|
||||
assertThat(awaitItem()).isFalse()
|
||||
|
||||
store.set(other, 99)
|
||||
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a flow does not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val watched = booleanPref("watched", false)
|
||||
store.set(watched, true)
|
||||
|
||||
store.flow(watched).test {
|
||||
assertThat(awaitItem()).isTrue()
|
||||
|
||||
store.set(watched, true)
|
||||
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a flow emits exactly one item carrying a changed value`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val watched = booleanPref("watched", false)
|
||||
|
||||
store.flow(watched).test {
|
||||
awaitItem()
|
||||
|
||||
store.set(watched, true)
|
||||
|
||||
assertThat(awaitItem()).isTrue()
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a typed pref and its raw key name are the same storage slot`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newStore(tempDir)
|
||||
val pref = booleanPref("b", true)
|
||||
|
||||
store.edit { it[booleanPreferencesKey("b")] = false }
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The `Pref` factories, as metadata only — a name and a default, with no store
|
||||
* behind them.
|
||||
*/
|
||||
class PrefTest {
|
||||
|
||||
@Test
|
||||
fun `booleanPref reports its name and default`() {
|
||||
val pref = booleanPref("flag", true)
|
||||
|
||||
assertThat(pref.name to pref.default).isEqualTo("flag" to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the scalar factories each report their name and default`() {
|
||||
val prefs = listOf(
|
||||
intPref("i", 7),
|
||||
longPref("l", 9L),
|
||||
floatPref("f", 1.5f),
|
||||
stringPref("s", "fallback"),
|
||||
stringSetPref("set", setOf("a")),
|
||||
)
|
||||
|
||||
val described = prefs.map { it.name to it.default }
|
||||
|
||||
assertThat(described).containsExactly(
|
||||
"i" to 7,
|
||||
"l" to 9L,
|
||||
"f" to 1.5f,
|
||||
"s" to "fallback",
|
||||
"set" to setOf("a"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stringSetPref defaults to the empty set`() {
|
||||
val pref = stringSetPref("s")
|
||||
|
||||
assertThat(pref.default).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the nullable factories default to null`() {
|
||||
val prefs = listOf(nullableStringPref("s"), nullableLongPref("l"), nullableBooleanPref("b"))
|
||||
|
||||
val defaults = prefs.map { it.default }
|
||||
|
||||
assertThat(defaults).containsExactly(null, null, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enumPref reports its name and default entry`() {
|
||||
val pref = enumPref("mode", ThemeMode.DARK)
|
||||
|
||||
assertThat(pref.name to pref.default).isEqualTo("mode" to ThemeMode.DARK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map keeps the name and derives the default through the decoder`() {
|
||||
val pref = intPref("n", 60)
|
||||
|
||||
val mapped = pref.map({ it.toString() }, { it.toInt() })
|
||||
|
||||
assertThat(mapped.name to mapped.default).isEqualTo("n" to "60")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.floret.prefs
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** The stored theme choice resolved against the device's light/dark setting. */
|
||||
class ThemeModeTest {
|
||||
|
||||
@Test
|
||||
fun `isDark follows a dark system when the mode is SYSTEM`() {
|
||||
val mode = ThemeMode.SYSTEM
|
||||
|
||||
val dark = mode.isDark(systemDark = true)
|
||||
|
||||
assertThat(dark).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isDark follows a light system when the mode is SYSTEM`() {
|
||||
val mode = ThemeMode.SYSTEM
|
||||
|
||||
val dark = mode.isDark(systemDark = false)
|
||||
|
||||
assertThat(dark).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isDark lets LIGHT override a dark system`() {
|
||||
val mode = ThemeMode.LIGHT
|
||||
|
||||
val dark = mode.isDark(systemDark = true)
|
||||
|
||||
assertThat(dark).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isDark lets DARK override a light system`() {
|
||||
val mode = ThemeMode.DARK
|
||||
|
||||
val dark = mode.isDark(systemDark = false)
|
||||
|
||||
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()
|
||||
|
||||
assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.floret.reminders
|
||||
|
||||
/**
|
||||
* One reminder lead time and the date it counts back from: the target's start
|
||||
* when [fromStart], otherwise its due/end date. An app whose targets have one
|
||||
* date only never sets [fromStart], and its leads are plain minutes.
|
||||
*/
|
||||
data class ReminderLead(val minutes: Int, val fromStart: Boolean = false) {
|
||||
|
||||
/** The stored form: `30` before due, `s30` before start. */
|
||||
fun toToken(): String = if (fromStart) "$START_PREFIX$minutes" else minutes.toString()
|
||||
|
||||
companion object {
|
||||
private const val START_PREFIX = "s"
|
||||
|
||||
/** [toToken] read back, or null when [token] is not one. */
|
||||
fun parse(token: String): ReminderLead? {
|
||||
val text = token.trim()
|
||||
val fromStart = text.startsWith(START_PREFIX)
|
||||
val minutes = text.removePrefix(START_PREFIX).toIntOrNull() ?: return null
|
||||
return ReminderLead(minutes, fromStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct, start's first, then ascending — the canonical form stored and resolved. */
|
||||
@JvmName("normalizeReminderLeads")
|
||||
fun List<ReminderLead>.normalizeReminders(): List<ReminderLead> =
|
||||
distinct().sortedWith(compareBy({ !it.fromStart }, { it.minutes }))
|
||||
|
||||
/** Each lead as one counting back from the due/end date. */
|
||||
fun List<Int>.asReminderLeads(): List<ReminderLead> = map { ReminderLead(it) }
|
||||
|
||||
/** As [reminderOverrideFor], for an override map that names each lead's date. */
|
||||
@JvmName("reminderLeadOverrideFor")
|
||||
fun Map<Long, List<ReminderLead>>.reminderOverrideFor(id: Long): ReminderOverride = when {
|
||||
!containsKey(id) -> ReminderOverride.Inherit
|
||||
getValue(id).isEmpty() -> ReminderOverride.None
|
||||
else -> ReminderOverride.Minutes.of(getValue(id))
|
||||
}
|
||||
|
||||
/** As [applyReminderOverride], for an override map that names each lead's date. */
|
||||
@JvmName("applyReminderLeadOverride")
|
||||
fun MutableMap<Long, List<ReminderLead>>.applyReminderOverride(id: Long, override: ReminderOverride) {
|
||||
when (override) {
|
||||
ReminderOverride.Inherit -> remove(id)
|
||||
ReminderOverride.None -> put(id, emptyList())
|
||||
is ReminderOverride.Minutes -> put(id, override.leads.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** As [reminderOverrideForMinutes], for leads that name their date. */
|
||||
fun reminderOverrideForLeads(leads: List<ReminderLead>, allowInherit: Boolean): ReminderOverride {
|
||||
val norm = leads.normalizeReminders()
|
||||
return when {
|
||||
norm.isNotEmpty() -> ReminderOverride.Minutes.of(norm)
|
||||
allowInherit -> ReminderOverride.Inherit
|
||||
else -> ReminderOverride.None
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -15,10 +15,31 @@ sealed interface ReminderOverride {
|
||||
/** Explicit "no reminder" for this target, regardless of the global default. */
|
||||
data object None : ReminderOverride
|
||||
|
||||
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
|
||||
data class Minutes(val minutes: List<Int>) : ReminderOverride
|
||||
/**
|
||||
* Specific lead times in minutes before due (non-empty, distinct, ascending),
|
||||
* plus those counting back from the start instead ([fromStart], same form).
|
||||
*/
|
||||
data class Minutes(val minutes: List<Int>, val fromStart: List<Int> = emptyList()) : ReminderOverride {
|
||||
/** Every lead, naming the date it counts from. */
|
||||
val leads: List<ReminderLead>
|
||||
get() = fromStart.map { ReminderLead(it, fromStart = true) } + minutes.asReminderLeads()
|
||||
|
||||
companion object {
|
||||
/** [leads] split by the date they count from. */
|
||||
fun of(leads: List<ReminderLead>): Minutes {
|
||||
val norm = leads.normalizeReminders()
|
||||
return Minutes(
|
||||
minutes = norm.filter { !it.fromStart }.map { it.minutes },
|
||||
fromStart = norm.filter { it.fromStart }.map { it.minutes },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
@@ -28,7 +49,7 @@ fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
* means no reminder. Mirrors the absent/empty/value semantics of the override
|
||||
* map.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
|
||||
fun <T> Map<Long, List<T>>.reminderLeadsFor(id: Long, default: List<T>): List<T> =
|
||||
if (containsKey(id)) getValue(id) else default
|
||||
|
||||
/**
|
||||
|
||||
+27
@@ -42,6 +42,33 @@ class ReminderOverrideCodec(
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* As [parse], for a map whose leads name their date ([ReminderLead.toToken]).
|
||||
* A value written by [serialize] reads as leads counting back from due.
|
||||
*/
|
||||
fun parseLeads(stored: String?): Map<Long, List<ReminderLead>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(entrySep).mapNotNull { entry ->
|
||||
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
when (val value = parts[1]) {
|
||||
noneToken -> id to emptyList()
|
||||
else -> value.split(listSep).mapNotNull(ReminderLead::parse)
|
||||
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/** Encode a map read by [parseLeads] back to its stored string form. */
|
||||
fun serializeLeads(map: Map<Long, List<ReminderLead>>): String =
|
||||
map.entries.joinToString(entrySep) { (id, leads) ->
|
||||
val value =
|
||||
if (leads.isEmpty()) noneToken
|
||||
else leads.normalizeReminders().joinToString(listSep) { it.toToken() }
|
||||
"$id$keyValueSep$value"
|
||||
}
|
||||
|
||||
/** Encode the map back to its stored string form. */
|
||||
fun serialize(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(entrySep) { (id, minutes) ->
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.floret.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderLeadTest {
|
||||
|
||||
private val codec = ReminderOverrideCodec.DEFAULT
|
||||
|
||||
@Test
|
||||
fun `round-trips leads that name their date`() {
|
||||
val map = mapOf(
|
||||
12L to listOf(ReminderLead(1_440, fromStart = true), ReminderLead(30)),
|
||||
7L to emptyList(),
|
||||
3L to listOf(ReminderLead(-540, fromStart = true)),
|
||||
)
|
||||
assertThat(codec.parseLeads(codec.serializeLeads(map))).isEqualTo(map)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value written as plain minutes reads as leads before due`() {
|
||||
val stored = codec.serialize(mapOf(12L to listOf(30, 60)))
|
||||
assertThat(codec.parseLeads(stored)).isEqualTo(mapOf(12L to listOf(ReminderLead(30), ReminderLead(60))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override splits its leads by date`() {
|
||||
val override = ReminderOverride.Minutes.of(listOf(ReminderLead(60), ReminderLead(10, fromStart = true), ReminderLead(0)))
|
||||
assertThat(override).isEqualTo(ReminderOverride.Minutes(minutes = listOf(0, 60), fromStart = listOf(10)))
|
||||
assertThat(override.leads).containsExactly(ReminderLead(10, fromStart = true), ReminderLead(0), ReminderLead(60)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an override map of leads applies and reads back`() {
|
||||
val map = mutableMapOf<Long, List<ReminderLead>>()
|
||||
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(30), fromStart = listOf(5)))
|
||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30), fromStart = listOf(5)))
|
||||
map.applyReminderOverride(1L, ReminderOverride.None)
|
||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty choice of leads falls back as the minutes one does`() {
|
||||
assertThat(reminderOverrideForLeads(emptyList(), allowInherit = true)).isEqualTo(ReminderOverride.Inherit)
|
||||
assertThat(reminderOverrideForLeads(emptyList(), allowInherit = false)).isEqualTo(ReminderOverride.None)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.chrono.IsoChronology
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
@@ -22,15 +23,41 @@ private fun Instant.atSystemZone() =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
|
||||
/** Medium localized date, e.g. "18 Jun 2026". */
|
||||
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
|
||||
/**
|
||||
* The zone an instant should be read in to recover the date it denotes.
|
||||
*
|
||||
* All-day items are date-only in iCalendar and are stored as **UTC midnight** of
|
||||
* their calendar date (the convention the tasks/calendar providers read back).
|
||||
* Rendering one in the device zone shows the previous day for anyone west of
|
||||
* UTC — so all-day values resolve against UTC, timed values against the device.
|
||||
*/
|
||||
private fun Instant.atDisplayZone(allDay: Boolean) =
|
||||
if (allDay) java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(ZoneOffset.UTC)
|
||||
else atSystemZone()
|
||||
|
||||
/** Short localized time, e.g. "14:30". */
|
||||
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
||||
/** Medium localized date, e.g. "18 Jun 2026". */
|
||||
fun Instant.formatDate(allDay: Boolean = false): String =
|
||||
atDisplayZone(allDay).format(dateFormatter)
|
||||
|
||||
private val timeFormatter24: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
private val timeFormatter12: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a")
|
||||
|
||||
/**
|
||||
* Short time, e.g. "14:30". [is24Hour] forces a 12- or 24-hour clock; null keeps
|
||||
* the locale's own short pattern.
|
||||
*/
|
||||
fun Instant.formatTime(is24Hour: Boolean? = null): String {
|
||||
val formatter = when (is24Hour) {
|
||||
null -> timeFormatter
|
||||
true -> timeFormatter24
|
||||
false -> timeFormatter12.withLocale(Locale.getDefault())
|
||||
}
|
||||
return atSystemZone().format(formatter)
|
||||
}
|
||||
|
||||
/** Date alone for all-day items, otherwise date + time. */
|
||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
||||
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
|
||||
fun Instant.formatDateTime(allDay: Boolean, is24Hour: Boolean? = null): String =
|
||||
if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime(is24Hour)}"
|
||||
|
||||
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" /
|
||||
// "Jun 20" — derived from the pattern so day/month order still follows the locale.
|
||||
@@ -48,11 +75,11 @@ private val dateNoYearFormatter: DateTimeFormatter = run {
|
||||
* current year, and the time is dropped when it carries no information (all-day
|
||||
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
|
||||
*/
|
||||
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
|
||||
val zdt = atSystemZone()
|
||||
fun Instant.formatDateTimeCompact(allDay: Boolean, is24Hour: Boolean? = null): String {
|
||||
val zdt = atDisplayZone(allDay)
|
||||
val datePart =
|
||||
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
|
||||
else formatDate()
|
||||
else formatDate(allDay)
|
||||
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
|
||||
else "$datePart · ${formatTime()}"
|
||||
else "$datePart · ${formatTime(is24Hour)}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.TimeZone
|
||||
import kotlin.time.Instant
|
||||
|
||||
class DateTimeFormatTest {
|
||||
|
||||
private val originalZone: TimeZone = TimeZone.getDefault()
|
||||
|
||||
@AfterEach
|
||||
fun restoreZone() = TimeZone.setDefault(originalZone)
|
||||
|
||||
private val july20: LocalDate = LocalDate.of(2026, 7, 20)
|
||||
|
||||
/** The all-day storage form: UTC midnight of the calendar date. */
|
||||
private val allDayJuly20: Instant = Instant.fromEpochMilliseconds(
|
||||
july20.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||
)
|
||||
|
||||
/** What the locale renders for a given date — so the test doesn't pin a format. */
|
||||
private fun medium(date: LocalDate): String =
|
||||
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
|
||||
|
||||
@Test
|
||||
fun `an all-day date reads the same whatever the device zone`() {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")) // UTC-4 in July
|
||||
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
|
||||
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin")) // UTC+2 in July
|
||||
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
|
||||
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland")) // UTC+12
|
||||
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading an all-day value as timed slips a day west of UTC`() {
|
||||
// Guards the regression directly: this is what the all-day flag prevents.
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
|
||||
assertThat(allDayJuly20.formatDate()).isEqualTo(medium(july20.minusDays(1)))
|
||||
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed values still render in the device zone`() {
|
||||
// 2026-07-20T23:30Z — the next day already, in Auckland.
|
||||
val lateUtc = Instant.fromEpochMilliseconds(
|
||||
july20.atTime(23, 30).atZone(ZoneOffset.UTC).toInstant().toEpochMilli(),
|
||||
)
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland"))
|
||||
assertThat(lateUtc.formatDate()).isEqualTo(medium(july20.plusDays(1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatDateTime drops the time for all-day and keeps it otherwise`() {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"))
|
||||
assertThat(allDayJuly20.formatDateTime(allDay = true)).isEqualTo(medium(july20))
|
||||
assertThat(allDayJuly20.formatDateTime(allDay = false)).contains("·")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact form is zone-correct for all-day too`() {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
|
||||
val compact = allDayJuly20.formatDateTimeCompact(allDay = true)
|
||||
// No time part, and the day must not have slipped to the 19th.
|
||||
assertThat(compact).doesNotContain("·")
|
||||
assertThat(compact).contains("20")
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -74,6 +74,9 @@ 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. |
|
||||
|
||||
@@ -93,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`, `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
|
||||
@@ -101,6 +104,18 @@ Two kinds of module:
|
||||
> **not** a separate `org.jetbrains.kotlin.android` plugin (applying it errors).
|
||||
> They set `jvmTarget` directly in a `kotlin { compilerOptions { … } }` block.
|
||||
|
||||
> **An Android module with no Compose in it applies only `com.android.library`**
|
||||
> — `core-prefs` is the first such module. The Compose plugin is added per
|
||||
> module, when the module actually has composables.
|
||||
|
||||
> **`core-di` is the one module carrying an annotation processor** — the KSP
|
||||
> plugin plus `hilt-compiler`. A `@Module @InstallIn(...)` only reaches a
|
||||
> consuming app's Hilt component if the *library* is processed: the app's
|
||||
> processor reads `@AggregatedDeps` metadata off the classpath, it does not
|
||||
> reprocess library sources. As a non-Android module it uses `hilt-core` +
|
||||
> `hilt-compiler` rather than the Android Hilt Gradle plugin. Every other module
|
||||
> stays DI-free.
|
||||
|
||||
Versions live in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml),
|
||||
kept in lockstep with the apps' catalogs.
|
||||
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
floret-kit is the shared **Material 3 Expressive design system + plumbing** for
|
||||
the Floret family of Android apps
|
||||
([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
|
||||
[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future
|
||||
[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula),
|
||||
[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future
|
||||
siblings). It is consumed from source as a git submodule + Gradle composite
|
||||
build. See the top-level [`../README.md`](../README.md) for the pitch.
|
||||
|
||||
|
||||
+16
-5
@@ -15,20 +15,29 @@ divergent or app-specific code where it lives.
|
||||
| `core-reminders` | JVM | Agendula (and Calendula's pending branch) |
|
||||
| `core-locale` | Android | Agendula, Calendula |
|
||||
| `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) |
|
||||
|
||||
See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each.
|
||||
|
||||
### Follow-ups on what's extracted
|
||||
|
||||
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`** — Clockula
|
||||
triggered the extraction (the third app that made the duplication hurt) and is
|
||||
its only consumer today. Both siblings still carry their own `ThemeMode`,
|
||||
`toEnum()` and `@IoDispatcher`. The stored key names are identical
|
||||
(`theme_mode`, `dynamic_color`), so each migration is a code change with **no
|
||||
data migration** — deliberately out of scope for the extraction, and done by
|
||||
each sibling when it next re-pins the kit.
|
||||
|
||||
---
|
||||
|
||||
## Deferred / not yet worth it
|
||||
|
||||
- **`core-prefs`** (DataStore base: `ThemeMode`, `dynamicColor`, the typed
|
||||
wrapper, `toEnum()`) and **`core-di`** (`@IoDispatcher` + provider) — genuinely
|
||||
shared but **low value**: pure relocation, no new capability, the shared
|
||||
surface is tiny once the store name and bindings stay app-local. Sensible to
|
||||
defer until a third app makes the duplication hurt.
|
||||
- **`ReminderFormatting`** (`ReminderUnit` + `reminderLeadTimeLabel` +
|
||||
`decomposeReminder`) — the labels are `@Composable` over app-specific
|
||||
`R.plurals`, and the presets differ per app. Extract later behind a
|
||||
@@ -66,6 +75,8 @@ See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each.
|
||||
## Consumers & cadence
|
||||
|
||||
- **Agendula** consumes the kit on `main` (active development).
|
||||
- **Clockula** consumes the kit on `main` (active development); the first
|
||||
consumer of `core-prefs` and `core-di`.
|
||||
- **Calendula** (ships via official F-Droid reproducible builds) has a pending
|
||||
adoption branch. Bringing it onto the kit is where the canonical-`GroupedSurface`
|
||||
convergence becomes visible, and where the F-Droid reproducibility checklist
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// glance — the family's shared home-screen-widget plumbing and M3 Expressive
|
||||
// widget vocabulary, built on Jetpack Glance: the theme factory, the rounded
|
||||
// WidgetSurface (solid/tinted/transparent backgrounds with the system corner
|
||||
// radius), a header + expressive action-button recipe, a position-aware
|
||||
// grouped-row primitive for Glance (cornerRadius has no per-corner overload, so
|
||||
// this comes from four shape drawables instead), and the midnight-rollover +
|
||||
// update-receiver plumbing every widget needs. App-agnostic: each app supplies
|
||||
// its own colour schemes, data and receivers.
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.jeanlucmakiola.floret.glance"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
all { it.useJUnitPlatform() }
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// components supplies Position/positionOf (the row-shape math) and pastelize.
|
||||
implementation(project(":components"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
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,71 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.material3.ColorProviders
|
||||
|
||||
/**
|
||||
* A widget's own light/dark decision, independent of the app's night-resource
|
||||
* qualifier: [SYSTEM] follows the device, [LIGHT]/[DARK] pin one regardless.
|
||||
* An app's per-widget "Light" / "Dark" override maps straight to these; its
|
||||
* "Follow app" option resolves to whichever of the three the app's own theme
|
||||
* setting currently means, then passes that resolved value in.
|
||||
*/
|
||||
enum class WidgetThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/**
|
||||
* Glance counterpart of [FloretExpressiveTheme][de.jeanlucmakiola.floret.identity.FloretExpressiveTheme]:
|
||||
* Material You on API 31+ when [dynamicColor] is on, the app-supplied
|
||||
* [lightScheme] / [darkScheme] otherwise. On [WidgetThemeMode.SYSTEM] it draws
|
||||
* only from colour-role tokens (`GlanceTheme.colors`), so light/dark follows the
|
||||
* system without a redraw; [WidgetThemeMode.LIGHT] / [WidgetThemeMode.DARK] pin
|
||||
* one scheme for both themes.
|
||||
*/
|
||||
@Composable
|
||||
fun FloretGlanceTheme(
|
||||
lightScheme: ColorScheme,
|
||||
darkScheme: ColorScheme,
|
||||
dynamicColor: Boolean,
|
||||
mode: WidgetThemeMode,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val context = LocalContext.current
|
||||
val colors = when (mode) {
|
||||
WidgetThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else ColorProviders(light = lightScheme, dark = darkScheme)
|
||||
WidgetThemeMode.LIGHT -> ColorProviders(if (dynamic) dynamicLightColorScheme(context) else lightScheme)
|
||||
WidgetThemeMode.DARK -> ColorProviders(if (dynamic) dynamicDarkColorScheme(context) else darkScheme)
|
||||
}
|
||||
CompositionLocalProvider(LocalWidgetThemeMode provides mode, LocalWidgetDynamicColor provides dynamic) {
|
||||
GlanceTheme(colors = colors, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** The [WidgetThemeMode] the nearest [FloretGlanceTheme] was given. */
|
||||
val LocalWidgetThemeMode = compositionLocalOf { WidgetThemeMode.SYSTEM }
|
||||
|
||||
/** Whether the nearest [FloretGlanceTheme] draws Material You colours. */
|
||||
val LocalWidgetDynamicColor = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* A colour an app computes per theme (an event colour, say), resolved like the
|
||||
* theme around it: a day/night pair on [WidgetThemeMode.SYSTEM], only [day] or
|
||||
* only [night] when the widget pins a theme, so it never contradicts the
|
||||
* pinned scheme it sits on.
|
||||
*/
|
||||
@Composable
|
||||
fun themedColor(day: Color, night: Color): ColorProvider = when (LocalWidgetThemeMode.current) {
|
||||
WidgetThemeMode.SYSTEM -> ColorProvider(day = day, night = night)
|
||||
WidgetThemeMode.LIGHT -> ColorProvider(day)
|
||||
WidgetThemeMode.DARK -> ColorProvider(night)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Redraws an app's widgets when their day or language goes stale, and keeps
|
||||
* [WidgetRollover]'s midnight alarm armed. A subclass supplies the receiver(s)
|
||||
* to check for placed instances, the rollover action + `PendingIntent`, how to
|
||||
* redraw, and — for a widget whose data lives outside the app (a calendar
|
||||
* provider, say) — any extra action that should trigger a redraw without
|
||||
* re-arming the alarm, via [extraHandledActions]. Setting [extraRearmInterval]
|
||||
* lets those extra actions re-arm too, at most once per interval: the backstop
|
||||
* for an inexact alarm the system dropped in a restricted standby bucket, since a
|
||||
* data change is the one signal that keeps arriving.
|
||||
*
|
||||
* Exported for the system broadcasts; an extra redraw from another app is
|
||||
* harmless.
|
||||
*/
|
||||
abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() {
|
||||
|
||||
/** The action this app's own rollover alarm fires with. */
|
||||
protected abstract val rolloverAction: String
|
||||
|
||||
/** Every widget receiver class whose placed instances this app's widgets span. */
|
||||
protected abstract val widgetReceiverClasses: List<Class<*>>
|
||||
|
||||
/** Built once per call, so each firing targets the same alarm as the last. */
|
||||
protected abstract fun rolloverPendingIntent(context: Context): PendingIntent
|
||||
|
||||
/** Redraws every widget this receiver is responsible for. */
|
||||
protected abstract suspend fun redraw(context: Context)
|
||||
|
||||
/** Actions, beyond the shared set, that should redraw (never re-arm) — e.g. a content-provider change. */
|
||||
protected open val extraHandledActions: Set<String> = emptySet()
|
||||
|
||||
/** When set, an [extraHandledActions] broadcast also re-arms, throttled to once per this interval. */
|
||||
protected open val extraRearmInterval: Duration? = null
|
||||
|
||||
/**
|
||||
* Whether to skip the redraw for this broadcast, e.g. a `PROVIDER_CHANGED`
|
||||
* that only echoes a write the app has already redrawn for. Re-arming is
|
||||
* unaffected.
|
||||
*/
|
||||
protected open fun skipRedraw(action: String): Boolean = false
|
||||
|
||||
// `by lazy`, not eager: an eager initializer runs during *this* class's
|
||||
// construction, before the subclass's `override val rolloverAction`
|
||||
// initializer has run. `internal` (not `private`) so the wiring is directly
|
||||
// testable without reflecting into a `Lazy` delegate field.
|
||||
private val rearmOnlyActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
|
||||
internal val rearmActions by lazy {
|
||||
rearmOnlyActions + setOf(
|
||||
rolloverAction,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
Intent.ACTION_DATE_CHANGED,
|
||||
)
|
||||
}
|
||||
internal val handledActions by lazy { rearmActions + Intent.ACTION_LOCALE_CHANGED + extraHandledActions }
|
||||
|
||||
final override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action !in handledActions) return
|
||||
val appContext = context.applicationContext
|
||||
val rearm = intent.action in rearmActions || (intent.action in extraHandledActions && extraRearmDue())
|
||||
val redraw = intent.action !in rearmOnlyActions && !skipRedraw(intent.action.orEmpty())
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (rearm) WidgetRollover.sync(appContext, widgetReceiverClasses, ::rolloverPendingIntent)
|
||||
if (redraw && WidgetRollover.hasPlacedWidgets(appContext, widgetReceiverClasses)) {
|
||||
redraw(appContext)
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extraRearmDue(): Boolean {
|
||||
val interval = extraRearmInterval ?: return false
|
||||
return rearmThrottle.tryAcquire(SystemClock.elapsedRealtime(), interval.inWholeMilliseconds)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Process-wide: the system builds a new receiver instance per broadcast.
|
||||
val rearmThrottle = RearmThrottle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets one caller through per interval; the first call always passes. */
|
||||
internal class RearmThrottle {
|
||||
private val last = AtomicLong(0L)
|
||||
|
||||
fun tryAcquire(nowMillis: Long, intervalMillis: Long): Boolean {
|
||||
val previous = last.get()
|
||||
val due = previous == 0L || nowMillis - previous >= intervalMillis
|
||||
return due && last.compareAndSet(previous, nowMillis)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.color.ColorProviders
|
||||
import androidx.glance.unit.ColorProvider
|
||||
|
||||
/**
|
||||
* A colour for a view a widget draws as plain [RemoteViews] (embedded through
|
||||
* `AndroidRemoteViews`), resolved like the Glance theme around it.
|
||||
*/
|
||||
sealed interface RemoteColor {
|
||||
/** A colour resource with its own night variant: the host resolves it, so it follows palette and light/dark changes. */
|
||||
data class Resource(@ColorRes val resId: Int) : RemoteColor
|
||||
|
||||
/** A light/dark pair; equal when the widget pins one theme. */
|
||||
data class DayNight(@ColorInt val day: Int, @ColorInt val night: Int) : RemoteColor
|
||||
}
|
||||
|
||||
/** The theme roles [remoteColor] hands to plain RemoteViews. */
|
||||
enum class WidgetColorRole(@ColorRes internal val resId: Int) {
|
||||
ON_SURFACE(R.color.floret_widget_on_surface),
|
||||
ON_SURFACE_VARIANT(R.color.floret_widget_on_surface_variant),
|
||||
PRIMARY(R.color.floret_widget_primary),
|
||||
ON_PRIMARY(R.color.floret_widget_on_primary),
|
||||
ON_SECONDARY_CONTAINER(R.color.floret_widget_on_secondary_container),
|
||||
SURFACE_VARIANT(R.color.floret_widget_surface_variant),
|
||||
WIDGET_BACKGROUND(R.color.floret_widget_widget_background),
|
||||
}
|
||||
|
||||
/**
|
||||
* [role] as the nearest [FloretGlanceTheme] and [WidgetSurface] draw it: the
|
||||
* system palette's resource when the theme follows Material You and the device,
|
||||
* else the theme's own light/dark pair. Text roles on a [WidgetBackground.TINTED]
|
||||
* surface move to `onSecondaryContainer`, as `GlanceTheme.colors` does there.
|
||||
*/
|
||||
@Composable
|
||||
fun remoteColor(role: WidgetColorRole): RemoteColor {
|
||||
val tinted = LocalWidgetBackground.current == WidgetBackground.TINTED
|
||||
val resolved = if (tinted && (role == WidgetColorRole.ON_SURFACE || role == WidgetColorRole.ON_SURFACE_VARIANT)) {
|
||||
WidgetColorRole.ON_SECONDARY_CONTAINER
|
||||
} else {
|
||||
role
|
||||
}
|
||||
val mode = LocalWidgetThemeMode.current
|
||||
if (mode == WidgetThemeMode.SYSTEM && LocalWidgetDynamicColor.current && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
return RemoteColor.Resource(resolved.resId)
|
||||
}
|
||||
val provider = GlanceTheme.colors.provider(resolved)
|
||||
val contexts = dayNightContexts()
|
||||
return when (mode) {
|
||||
WidgetThemeMode.SYSTEM -> RemoteColor.DayNight(
|
||||
day = provider.getColor(contexts.first).toArgb(),
|
||||
night = provider.getColor(contexts.second).toArgb(),
|
||||
)
|
||||
WidgetThemeMode.LIGHT -> provider.getColor(contexts.first).toArgb().let { RemoteColor.DayNight(it, it) }
|
||||
WidgetThemeMode.DARK -> provider.getColor(contexts.second).toArgb().let { RemoteColor.DayNight(it, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** An app-computed [day]/[night] colour (an event colour, say) for plain RemoteViews, pinned like [themedColor]. */
|
||||
fun WidgetThemeMode.remoteColor(day: Color, night: Color): RemoteColor.DayNight = when (this) {
|
||||
WidgetThemeMode.SYSTEM -> RemoteColor.DayNight(day.toArgb(), night.toArgb())
|
||||
WidgetThemeMode.LIGHT -> RemoteColor.DayNight(day.toArgb(), day.toArgb())
|
||||
WidgetThemeMode.DARK -> RemoteColor.DayNight(night.toArgb(), night.toArgb())
|
||||
}
|
||||
|
||||
/** Sets [viewId]'s text colour to [color]. */
|
||||
fun RemoteViews.setTextColor(context: Context, viewId: Int, color: RemoteColor) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> setTextColor(viewId, color.current(context))
|
||||
color is RemoteColor.Resource -> setColorStateList(viewId, "setTextColor", color.resId)
|
||||
color is RemoteColor.DayNight -> setColorInt(viewId, "setTextColor", color.day, color.night)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tints [viewId]'s image with [color]. Always the tint list from API 31 on, so a
|
||||
* host reapplying a new rendering over the last never stacks a filter on a tint.
|
||||
*/
|
||||
fun RemoteViews.setImageTint(context: Context, viewId: Int, color: RemoteColor) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> setInt(viewId, "setColorFilter", color.current(context))
|
||||
color is RemoteColor.Resource -> setColorStateList(viewId, "setImageTintList", color.resId)
|
||||
color is RemoteColor.DayNight -> setColorStateList(
|
||||
viewId,
|
||||
"setImageTintList",
|
||||
ColorStateList.valueOf(color.day),
|
||||
ColorStateList.valueOf(color.night),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RemoteColor.current(context: Context): Int = when (this) {
|
||||
is RemoteColor.Resource -> context.getColor(resId)
|
||||
is RemoteColor.DayNight -> if (context.isNight()) night else day
|
||||
}
|
||||
|
||||
private fun Context.isNight(): Boolean =
|
||||
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
@Composable
|
||||
private fun dayNightContexts(): Pair<Context, Context> {
|
||||
val context = LocalContext.current
|
||||
return remember(context) { context.withNight(false) to context.withNight(true) }
|
||||
}
|
||||
|
||||
private fun Context.withNight(night: Boolean): Context {
|
||||
val config = Configuration(resources.configuration).apply {
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
return createConfigurationContext(config)
|
||||
}
|
||||
|
||||
private fun ColorProviders.provider(role: WidgetColorRole): ColorProvider = when (role) {
|
||||
WidgetColorRole.ON_SURFACE -> onSurface
|
||||
WidgetColorRole.ON_SURFACE_VARIANT -> onSurfaceVariant
|
||||
WidgetColorRole.PRIMARY -> primary
|
||||
WidgetColorRole.ON_PRIMARY -> onPrimary
|
||||
WidgetColorRole.ON_SECONDARY_CONTAINER -> onSecondaryContainer
|
||||
WidgetColorRole.SURFACE_VARIANT -> surfaceVariant
|
||||
WidgetColorRole.WIDGET_BACKGROUND -> widgetBackground
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.unit.ColorProvider
|
||||
|
||||
/**
|
||||
* A task row's leading "complete" tap target: a round hit area around the check
|
||||
* glyph. [checked] fills it with `primary` — the frame a widget shows between
|
||||
* the tap and the row going away, since RemoteViews can't animate the change.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetCheckButton(
|
||||
contentDescription: String,
|
||||
action: Action,
|
||||
tint: ColorProvider,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
checked: Boolean = false,
|
||||
) {
|
||||
val filled = if (checked) GlanceModifier.background(GlanceTheme.colors.primary) else GlanceModifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(TargetSize)
|
||||
.cornerRadius(TargetSize / 2)
|
||||
.then(filled)
|
||||
.clickable(action),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.floret_ic_widget_check),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(if (checked) GlanceTheme.colors.onPrimary else tint),
|
||||
modifier = GlanceModifier.size(IconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TargetSize = 28.dp
|
||||
private val IconSize = 16.dp
|
||||
@@ -0,0 +1,59 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
|
||||
/**
|
||||
* A list widget's "nothing left" state, framed as an achievement rather than
|
||||
* an absence: [title] and an optional [subtitle] in a tertiary card. Sits in
|
||||
* its own container, like [WidgetMessage], so it stays readable on
|
||||
* [WidgetBackground.TRANSPARENT].
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetDoneState(title: String, subtitle: String? = null, modifier: GlanceModifier = GlanceModifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.cornerRadius(24.dp)
|
||||
.background(GlanceTheme.colors.tertiaryContainer)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onTertiaryContainer,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 2,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onTertiaryContainer,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The Glance counterpart of `components`' `GroupedSurface`: a tonal container
|
||||
* whose corners come from [position], so a run of rows reads as one rounded
|
||||
* card. Glance's `cornerRadius` modifier takes one radius for the whole shape —
|
||||
* no per-corner overload — so the asymmetric shapes are four tinted drawables
|
||||
* (`floret_widget_row_*`) rather than a computed
|
||||
* [androidx.compose.ui.graphics.Shape]. There is no press-morph here (Glance has
|
||||
* no interaction source); the radii match
|
||||
* [de.jeanlucmakiola.floret.components.groupedShape]'s resting values.
|
||||
*
|
||||
* [color] defaults to `surfaceVariant` — the nearest tonal-card role Glance's
|
||||
* `ColorProviders` exposes (no `surfaceContainerHigh` there). On a [WidgetBackground.TRANSPARENT]
|
||||
* root this container is what keeps the row's text readable over an arbitrary
|
||||
* wallpaper (calendula#292): every row stays opaque regardless of the surface.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetGroupedRow(
|
||||
position: Position,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
color: ColorProvider = GlanceTheme.colors.surfaceVariant,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val shape = when (position) {
|
||||
Position.Alone -> R.drawable.floret_widget_row_alone
|
||||
Position.Top -> R.drawable.floret_widget_row_top
|
||||
Position.Middle -> R.drawable.floret_widget_row_middle
|
||||
Position.Bottom -> R.drawable.floret_widget_row_bottom
|
||||
}
|
||||
val gap = if (position == Position.Top || position == Position.Middle) 2.dp else 0.dp
|
||||
// The gap has to live on a Box with no background of its own: Android draws a
|
||||
// View's background to its full bounds regardless of that View's own padding
|
||||
// (padding only insets children), so putting both modifiers on one Box left
|
||||
// the tinted shape covering the gap and rows touching.
|
||||
Box(modifier = modifier.fillMaxWidth().padding(bottom = gap)) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.background(ImageProvider(shape), colorFilter = ColorFilter.tint(color)),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.RowScope
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
|
||||
/** Visual weight of a [WidgetActionButton]. */
|
||||
enum class WidgetActionStyle { TONAL, FILLED }
|
||||
|
||||
/**
|
||||
* A widget's title row: the title (clickable to [titleAction], when given),
|
||||
* weighted to fill, and a trailing slot for [WidgetActionButton]s. On a
|
||||
* [WidgetBackground.TRANSPARENT] root the title gets its own small pill behind
|
||||
* it, so it stays legible over an arbitrary wallpaper (calendula#292); on a
|
||||
* filled root it sits directly on the surface, as before, [titleInset] in from
|
||||
* the start to line it up with the content below. The pill sits flush instead,
|
||||
* like the actions on the other side, its own padding insetting the text.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetHeader(
|
||||
title: String,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
titleAction: Action? = null,
|
||||
titleInset: Dp = 0.dp,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val transparentTitle = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val clickable = if (titleAction != null) GlanceModifier.clickable(titleAction) else GlanceModifier
|
||||
if (transparentTitle) {
|
||||
// The pill hugs the title; a spacer takes the rest of the row so it
|
||||
// doesn't stretch into a full-width bar.
|
||||
Box(
|
||||
modifier = clickable
|
||||
.cornerRadius(12.dp)
|
||||
.background(GlanceTheme.colors.widgetBackground)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
HeaderText(title)
|
||||
}
|
||||
Spacer(GlanceModifier.defaultWeight())
|
||||
} else {
|
||||
Box(modifier = GlanceModifier.defaultWeight().then(clickable).padding(start = titleInset)) {
|
||||
HeaderText(title)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderText(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard trailing action of a [WidgetHeader]: a flat pill
|
||||
* ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding
|
||||
* the rows below it. Filled by default; a header with several actions makes
|
||||
* the secondary ones [WidgetActionStyle.TONAL].
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetHeaderAction(
|
||||
icon: ImageProvider,
|
||||
contentDescription: String,
|
||||
action: Action,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
style: WidgetActionStyle = WidgetActionStyle.FILLED,
|
||||
) {
|
||||
WidgetActionButton(
|
||||
icon = icon,
|
||||
contentDescription = contentDescription,
|
||||
action = action,
|
||||
modifier = modifier,
|
||||
style = style,
|
||||
size = HeaderActionWidth,
|
||||
height = HeaderActionHeight,
|
||||
)
|
||||
}
|
||||
|
||||
/** A [WidgetHeaderAction] that carries a short [text] (a day number, a count) instead of an icon. */
|
||||
@Composable
|
||||
fun WidgetHeaderTextAction(
|
||||
text: String,
|
||||
contentDescription: String,
|
||||
action: Action,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
style: WidgetActionStyle = WidgetActionStyle.FILLED,
|
||||
) {
|
||||
ActionPill(
|
||||
style = style,
|
||||
width = HeaderActionWidth,
|
||||
height = HeaderActionHeight,
|
||||
action = action,
|
||||
modifier = modifier.semantics { this.contentDescription = contentDescription },
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = style.content(), fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetActionStyle.container(): ColorProvider = when (this) {
|
||||
WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer
|
||||
WidgetActionStyle.FILLED -> GlanceTheme.colors.primary
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetActionStyle.content(): ColorProvider = when (this) {
|
||||
WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer
|
||||
WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary
|
||||
}
|
||||
|
||||
/** The standard space between two actions in a [WidgetHeader]'s trailing slot. */
|
||||
@Composable
|
||||
fun RowScope.WidgetHeaderActionGap() {
|
||||
Spacer(GlanceModifier.width(HeaderActionGap))
|
||||
}
|
||||
|
||||
/** The fixed-size, full-pill container both icon and text actions are drawn in. */
|
||||
@Composable
|
||||
private fun ActionPill(
|
||||
style: WidgetActionStyle,
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
action: Action,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.cornerRadius(height / 2)
|
||||
.background(style.container())
|
||||
.clickable(action),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private val HeaderActionWidth = 36.dp
|
||||
private val HeaderActionHeight = 28.dp
|
||||
private val HeaderActionGap = 4.dp
|
||||
|
||||
/**
|
||||
* A widget's primary tappable action: a [size] × [height] target (a 48dp
|
||||
* circle by default) in a full-pill shape, [WidgetActionStyle.TONAL]
|
||||
* (`primaryContainer`) by default or [WidgetActionStyle.FILLED] (`primary`)
|
||||
* for the one action a widget wants to lead with. With [label] it widens into
|
||||
* an extended pill ("+ New task") for a widget wide enough to carry one;
|
||||
* without it, it stays [size] wide. A header that only has room to spare
|
||||
* above a short list can pass a shorter [height] than [size] — a flatter pill
|
||||
* rather than a full circle — to stay out of the rows' way.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetActionButton(
|
||||
icon: ImageProvider,
|
||||
contentDescription: String,
|
||||
action: Action,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
style: WidgetActionStyle = WidgetActionStyle.TONAL,
|
||||
label: String? = null,
|
||||
size: Dp = 48.dp,
|
||||
height: Dp = size,
|
||||
) {
|
||||
val container = style.container()
|
||||
val content = style.content()
|
||||
val iconSize = height * 0.65f
|
||||
if (label == null) {
|
||||
ActionPill(style = style, width = size, height = height, action = action, modifier = modifier) {
|
||||
ActionIcon(icon, contentDescription, content, iconSize = iconSize)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(height)
|
||||
.cornerRadius(height / 2)
|
||||
.background(container)
|
||||
.clickable(action)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ActionIcon(icon, contentDescription, content, iconSize = iconSize)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = content, fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionIcon(icon: ImageProvider, contentDescription: String, tint: ColorProvider, iconSize: Dp) {
|
||||
Image(
|
||||
provider = icon,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(tint),
|
||||
modifier = GlanceModifier.size(iconSize),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
|
||||
/**
|
||||
* A widget's empty or unavailable state: centred text in its own tonal
|
||||
* container, so it reads on [WidgetBackground.TRANSPARENT] exactly as the
|
||||
* populated rows do.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetMessage(text: String, modifier: GlanceModifier = GlanceModifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.cornerRadius(16.dp)
|
||||
.background(GlanceTheme.colors.surfaceVariant)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
|
||||
import androidx.glance.appwidget.GlanceRemoteViews
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* [color] with a rounded hole cut into it, [inset] in from the sides, through
|
||||
* which whatever the window draws behind the app shows: the wallpaper, in an
|
||||
* activity themed with `windowShowWallpaper`. For a widget config screen, so a
|
||||
* transparent widget previews against the real home screen.
|
||||
*/
|
||||
@Composable
|
||||
fun WallpaperWindow(
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
inset: Dp = 16.dp,
|
||||
cornerRadius: Dp = 28.dp,
|
||||
content: @Composable BoxWithConstraintsScope.() -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawBehind {
|
||||
drawRect(color)
|
||||
val side = inset.toPx()
|
||||
drawRoundRect(
|
||||
color = Color.Black,
|
||||
topLeft = Offset(side, 0f),
|
||||
size = Size(size.width - side * 2, size.height),
|
||||
cornerRadius = CornerRadius(cornerRadius.toPx()),
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
.padding(horizontal = inset),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Glance [content] composition rendered to RemoteViews and inflated here, so
|
||||
* a config screen previews the real widget rather than a copy of it. Laid out
|
||||
* at the widget's own [size], shrunk only to fit [maxSize]'s width so its text
|
||||
* reads as it does on the home screen, and cropped below [maxSize]'s height,
|
||||
* keeping the top and closing the cut with the widget's own corner radius. A picture, not a
|
||||
* control: touches stop here. [placeholder] shows until the first render and
|
||||
* [failure] if it can't render; [keys] re-render it when they change.
|
||||
*
|
||||
* Below Android 12, RemoteViews applies a lazy list only inside a real widget
|
||||
* host, so [content] should draw lists as plain columns here.
|
||||
*/
|
||||
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
|
||||
@Composable
|
||||
fun GlanceWidgetPreview(
|
||||
size: DpSize,
|
||||
maxSize: DpSize,
|
||||
vararg keys: Any?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: @Composable () -> Unit = {},
|
||||
failure: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var views by remember { mutableStateOf<RemoteViews?>(null) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(size, *keys) {
|
||||
try {
|
||||
views = GlanceRemoteViews().compose(context, size) { content() }.remoteViews
|
||||
failed = false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
failed = views == null
|
||||
}
|
||||
}
|
||||
val scale = minOf(1f, maxSize.width / size.width)
|
||||
val corner = widgetCornerRadius() * scale
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.width * scale, minOf(size.height * scale, maxSize.height))
|
||||
.clip(RoundedCornerShape(corner)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val current = views
|
||||
when {
|
||||
current != null -> AndroidView(
|
||||
factory = { FrameLayout(it) },
|
||||
update = { frame ->
|
||||
frame.removeAllViews()
|
||||
frame.addView(current.apply(frame.context, frame))
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentSize(Alignment.TopCenter, unbounded = true)
|
||||
.requiredSize(size)
|
||||
.graphicsLayer(scaleX = scale, scaleY = scale, transformOrigin = TransformOrigin(0.5f, 0f))
|
||||
.clearAndSetSemantics {}
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent().changes.forEach { it.consume() }
|
||||
}
|
||||
},
|
||||
)
|
||||
failed -> failure()
|
||||
else -> placeholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The corner [WidgetSurface] draws: the system widget radius on API 31+, its fallback below. */
|
||||
@Composable
|
||||
private fun widgetCornerRadius(): Dp =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
dimensionResource(android.R.dimen.system_app_widget_background_radius)
|
||||
} else {
|
||||
LegacyWidgetCornerRadius
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A determinate ring, since Glance 1.2's own `CircularProgressIndicator` is
|
||||
* indeterminate only — drawn as a bitmap (round-capped arc over its track)
|
||||
* rather than a Compose `Shape`, the only way to get a determinate ring into
|
||||
* a `RemoteViews` tree. [progress] is clamped to `0f..1f`; [content] (a
|
||||
* count or label) is centred over it.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetProgressRing(
|
||||
progress: Float,
|
||||
size: Dp,
|
||||
trackColor: ColorProvider,
|
||||
indicatorColor: ColorProvider,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
strokeWidth: Dp = size * STROKE_FRACTION,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = context.resources.displayMetrics.density
|
||||
val pixels = min((size.value * density).roundToInt(), MAX_BITMAP_PX)
|
||||
val strokePixels = strokeWidth.value * density
|
||||
val track = trackColor.getColor(context).toArgb()
|
||||
val indicator = indicatorColor.getColor(context).toArgb()
|
||||
val bitmap = ringBitmap(pixels, strokePixels, progress.coerceIn(0f, 1f), track, indicator)
|
||||
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||
Image(
|
||||
provider = ImageProvider(bitmap),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(size),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ringBitmap(pixels: Int, strokePx: Float, progress: Float, trackArgb: Int, indicatorArgb: Int): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(pixels, pixels, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
val inset = strokePx / 2
|
||||
val bounds = RectF(inset, inset, pixels - inset, pixels - inset)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = strokePx
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
paint.color = trackArgb
|
||||
canvas.drawOval(bounds, paint)
|
||||
if (progress > 0f) {
|
||||
paint.color = indicatorArgb
|
||||
canvas.drawArc(bounds, START_ANGLE, SWEEP_DEGREES * progress, false, paint)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private const val STROKE_FRACTION = 0.1f
|
||||
private const val START_ANGLE = -90f
|
||||
private const val SWEEP_DEGREES = 360f
|
||||
|
||||
/** Keeps the bitmap well under the RemoteViews binder transaction limit. */
|
||||
private const val MAX_BITMAP_PX = 512
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A widget family's own wake-up for the next local midnight, so a "Today" /
|
||||
* date-relative label flips over on the day boundary. `ACTION_DATE_CHANGED`
|
||||
* isn't delivered to manifest receivers since Android 8, which is why this
|
||||
* exists. One inexact alarm at a time, re-armed by every firing:
|
||||
* `setAndAllowWhileIdle` needs no permission and survives doze, and a rollover
|
||||
* a few minutes late is invisible on a sleeping screen.
|
||||
*
|
||||
* App-agnostic: [widgetReceiverClasses] names the receiver(s) whose placed
|
||||
* instances decide whether the alarm is worth keeping armed, and
|
||||
* [rolloverPendingIntent] builds the `PendingIntent` the app's own
|
||||
* `FloretWidgetUpdateReceiver` subclass will receive it on.
|
||||
*/
|
||||
object WidgetRollover {
|
||||
|
||||
/** Fire just after midnight, so an early delivery doesn't still read the old date. */
|
||||
val ROLLOVER_SLACK = 5.seconds
|
||||
|
||||
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||
|
||||
/** Arm the next rollover, or cancel it when no widget of [widgetReceiverClasses] is placed. Idempotent. */
|
||||
fun sync(
|
||||
context: Context,
|
||||
widgetReceiverClasses: List<Class<*>>,
|
||||
rolloverPendingIntent: (Context) -> PendingIntent,
|
||||
) {
|
||||
val appContext = context.applicationContext
|
||||
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = rolloverPendingIntent(appContext)
|
||||
if (!hasPlacedWidgets(appContext, widgetReceiverClasses)) {
|
||||
alarmManager.cancel(pendingIntent)
|
||||
return
|
||||
}
|
||||
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant just after the next local midnight following [now] in [zone].
|
||||
* Uses the actual start of day, so it holds where DST skips midnight or a
|
||||
* date-line move skips a whole date.
|
||||
*/
|
||||
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||
val date = now.toLocalDateTime(zone).date
|
||||
var days = 1
|
||||
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||
if (candidate > now) return candidate
|
||||
days++
|
||||
}
|
||||
// Unreachable for any real zone; an hour rather than the slack so it can't loop.
|
||||
return now + 1.hours
|
||||
}
|
||||
|
||||
fun hasPlacedWidgets(context: Context, widgetReceiverClasses: List<Class<*>>): Boolean {
|
||||
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||
return widgetReceiverClasses.any { manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.unit.ColorProvider
|
||||
|
||||
/**
|
||||
* M3 Expressive shapes for widgets. RemoteViews can't clip to an arbitrary
|
||||
* outline, so each is a tinted vector drawn behind the content instead.
|
||||
*/
|
||||
enum class WidgetShape(internal val drawable: Int) {
|
||||
COOKIE(R.drawable.floret_widget_shape_cookie),
|
||||
SUNNY(R.drawable.floret_widget_shape_sunny),
|
||||
}
|
||||
|
||||
/** A [size] square filled with [shape] in [color], with [content] centred on top. */
|
||||
@Composable
|
||||
fun WidgetShapedBox(
|
||||
shape: WidgetShape,
|
||||
color: ColorProvider,
|
||||
size: Dp,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
onClick: Action? = null,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier
|
||||
Box(modifier = modifier.size(size).then(clickable), contentAlignment = Alignment.Center) {
|
||||
Image(
|
||||
provider = ImageProvider(shape.drawable),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
modifier = GlanceModifier.size(size),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** A [shape]-backed icon button — a widget's hero action, louder than a pill. */
|
||||
@Composable
|
||||
fun WidgetShapedIconButton(
|
||||
shape: WidgetShape,
|
||||
icon: ImageProvider,
|
||||
contentDescription: String,
|
||||
action: Action,
|
||||
color: ColorProvider,
|
||||
iconTint: ColorProvider,
|
||||
size: Dp,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
WidgetShapedBox(shape = shape, color = color, size = size, modifier = modifier, onClick = action) {
|
||||
Image(
|
||||
provider = icon,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(iconTint),
|
||||
modifier = GlanceModifier.size(size * 0.42f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.appWidgetBackground
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProviders
|
||||
import androidx.glance.color.colorProviders
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
|
||||
/**
|
||||
* How a widget's root fills in behind its content. [TINTED] is the softer tone
|
||||
* requested for the M3 Expressive pass (calendula#90); [TRANSPARENT] draws no
|
||||
* fill at all, so rows must stay in their own opaque [WidgetGroupedRow]
|
||||
* container to keep contrast on an arbitrary wallpaper (calendula#292).
|
||||
*/
|
||||
enum class WidgetBackground { SOLID, TINTED, TRANSPARENT }
|
||||
|
||||
/** The [WidgetBackground] the nearest [WidgetSurface] was given, for a child that adapts to it. */
|
||||
val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID }
|
||||
|
||||
/** Falls back to this radius below API 31, where there's no system widget-corner dimension. */
|
||||
internal val LegacyWidgetCornerRadius = 20.dp
|
||||
|
||||
/**
|
||||
* A widget's root container: fills the host, takes the system's rounded corner
|
||||
* on API 31+ (a fixed fallback below that), and fills with [background].
|
||||
* `appWidgetBackground()` lets the launcher draw its own selection/press
|
||||
* affordance behind the widget; it carries no colour of its own, so the fill is
|
||||
* layered on top of it here.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetSurface(
|
||||
background: WidgetBackground,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val radius = modifier.appWidgetBackground().let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
it.cornerRadius(android.R.dimen.system_app_widget_background_radius)
|
||||
} else {
|
||||
it.cornerRadius(LegacyWidgetCornerRadius)
|
||||
}
|
||||
}
|
||||
val filled = when (background) {
|
||||
WidgetBackground.SOLID -> radius.background(GlanceTheme.colors.widgetBackground)
|
||||
WidgetBackground.TINTED -> radius.background(GlanceTheme.colors.secondaryContainer)
|
||||
WidgetBackground.TRANSPARENT -> radius
|
||||
}
|
||||
CompositionLocalProvider(LocalWidgetBackground provides background) {
|
||||
Box(modifier = filled.fillMaxSize()) {
|
||||
if (background == WidgetBackground.TINTED) {
|
||||
GlanceTheme(colors = GlanceTheme.colors.onTint(), content = content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme with its surface roles moved onto `secondaryContainer`, so text a
|
||||
* widget draws in `onSurface` / `onSurfaceVariant` reads in
|
||||
* `onSecondaryContainer` on a [WidgetBackground.TINTED] fill.
|
||||
*/
|
||||
private fun ColorProviders.onTint(): ColorProviders = colorProviders(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
error = error,
|
||||
errorContainer = errorContainer,
|
||||
onError = onError,
|
||||
onErrorContainer = onErrorContainer,
|
||||
background = secondaryContainer,
|
||||
onBackground = onSecondaryContainer,
|
||||
surface = secondaryContainer,
|
||||
onSurface = onSecondaryContainer,
|
||||
surfaceVariant = surfaceVariant,
|
||||
onSurfaceVariant = onSecondaryContainer,
|
||||
outline = outline,
|
||||
inverseOnSurface = inverseOnSurface,
|
||||
inverseSurface = inverseSurface,
|
||||
inversePrimary = inversePrimary,
|
||||
widgetBackground = widgetBackground,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs this element with the widget surface colour on a
|
||||
* [WidgetBackground.TRANSPARENT] root, so what it holds stays readable over an
|
||||
* arbitrary wallpaper (calendula#292). A no-op on a filled root.
|
||||
*/
|
||||
@Composable
|
||||
fun GlanceModifier.wallpaperBacking(radius: Dp = 16.dp): GlanceModifier =
|
||||
if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
cornerRadius(radius).background(GlanceTheme.colors.widgetBackground)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.unit.ColorProvider
|
||||
|
||||
/**
|
||||
* A fully-rounded tonal block — the Lists widget's per-list tile and the Week
|
||||
* widget's per-day column share this shape. Unlike [WidgetGroupedRow] it is
|
||||
* always [de.jeanlucmakiola.floret.components.Position.Alone]-shaped (every
|
||||
* tile stands on its own, with its own gap to its neighbours), so it uses a
|
||||
* plain `cornerRadius` rather than the position-aware shape drawables.
|
||||
*/
|
||||
@Composable
|
||||
fun WidgetTile(
|
||||
color: ColorProvider,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
onClick: Action? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.cornerRadius(TileRadius)
|
||||
.background(color)
|
||||
.then(clickable)
|
||||
.padding(TilePadding),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private val TileRadius = 16.dp
|
||||
private val TilePadding = 8.dp
|
||||
@@ -0,0 +1,222 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.GlanceState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private val RefreshStampKey = longPreferencesKey("floret_refresh_stamp")
|
||||
|
||||
/**
|
||||
* Redraws every placed instance of this widget, resolving the ids through
|
||||
* [AppWidgetManager] rather than Glance's own provider-name map. `updateAll`
|
||||
* reads that map, and in a process that hasn't hosted a widget session yet (one
|
||||
* woken by a tap after a reboot, or the app right after a save) it comes back
|
||||
* empty, so the redraw is silently dropped.
|
||||
*
|
||||
* With [bumpRefreshStamp], each instance's [currentRefreshStamp] moves first.
|
||||
* An `update` on a running session only recomposes and never re-runs
|
||||
* `provideGlance`, so a widget that keys its data load on the stamp reloads
|
||||
* there too. Needs a `PreferencesGlanceStateDefinition` widget.
|
||||
*/
|
||||
suspend fun GlanceAppWidget.updatePlaced(
|
||||
context: Context,
|
||||
receiverClass: Class<*>,
|
||||
bumpRefreshStamp: Boolean = false,
|
||||
) {
|
||||
val appContext = context.applicationContext
|
||||
val appWidgetManager = AppWidgetManager.getInstance(appContext) ?: return
|
||||
val ids = appWidgetManager.getAppWidgetIds(ComponentName(appContext, receiverClass))
|
||||
if (ids.isEmpty()) return
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
ids.forEach { appWidgetId ->
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (bumpRefreshStamp) updateWithRefresh(appContext, glanceId) else update(appContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one instance's [currentRefreshStamp], then redraws it, so a running
|
||||
* session reloads too. [edit] runs in the same state transaction, for a caller
|
||||
* that changes the instance's own state along with it.
|
||||
*/
|
||||
suspend fun GlanceAppWidget.updateWithRefresh(
|
||||
context: Context,
|
||||
glanceId: GlanceId,
|
||||
edit: (MutablePreferences) -> Unit = {},
|
||||
) {
|
||||
updateAppWidgetState(context, glanceId) {
|
||||
edit(it)
|
||||
it[RefreshStampKey] = nextRefreshStamp(it[RefreshStampKey])
|
||||
}
|
||||
update(context, glanceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A receiver's `onRestored`: moves each restored instance's Glance state from
|
||||
* its old id to the one the host gave it, which Glance doesn't do itself, so
|
||||
* per-instance settings survive a backup restore or device transfer.
|
||||
*
|
||||
* Reads the stores through Glance's restricted [GlanceState]: the public state
|
||||
* calls need a GlanceId, which Glance only gives out for ids still bound, and
|
||||
* opening the files directly would race Glance's own DataStore on them.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun GlanceAppWidgetReceiver.restoreState(context: Context, oldIds: IntArray, newIds: IntArray) {
|
||||
val appContext = context.applicationContext
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
oldIds.zip(newIds).forEach { (old, new) ->
|
||||
val stored = GlanceState.getValue(appContext, PreferencesGlanceStateDefinition, stateKey(old))
|
||||
GlanceState.updateValue(appContext, PreferencesGlanceStateDefinition, stateKey(new)) {
|
||||
stored.toMutablePreferences()
|
||||
}
|
||||
GlanceState.deleteStore(appContext, PreferencesGlanceStateDefinition, stateKey(old))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Widget state restore failed", e)
|
||||
} finally {
|
||||
pending?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Glance's own name for an app widget's state store. */
|
||||
private fun stateKey(appWidgetId: Int) = "appWidget-$appWidgetId"
|
||||
|
||||
/**
|
||||
* A receiver's `onUpdate`, in place of `super.onUpdate`: the system's periodic
|
||||
* update also reloads each instance's data, rather than only recomposing a
|
||||
* running session. Never after `super`, which has already taken the
|
||||
* broadcast's [goAsync] handle.
|
||||
*/
|
||||
fun GlanceAppWidgetReceiver.reloadOnUpdate(context: Context, appWidgetIds: IntArray) {
|
||||
val appContext = context.applicationContext
|
||||
val pending = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
appWidgetIds.forEach { glanceAppWidget.updateWithRefresh(appContext, glanceManager.getGlanceIdBy(it)) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Widget update failed", e)
|
||||
} finally {
|
||||
pending?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "FloretGlance"
|
||||
|
||||
internal fun nextRefreshStamp(current: Long?): Long = (current ?: 0L) + 1
|
||||
|
||||
/** This instance's refresh stamp, moved by [updateWithRefresh]; a key for [WidgetData.current]. */
|
||||
@Composable
|
||||
fun currentRefreshStamp(): Long = currentState(RefreshStampKey) ?: 0L
|
||||
|
||||
/** [currentRefreshStamp] outside the composition, for `provideGlance` to read before its first load. */
|
||||
suspend fun refreshStamp(context: Context, glanceId: GlanceId): Long =
|
||||
getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)[RefreshStampKey] ?: 0L
|
||||
|
||||
/**
|
||||
* A widget's data, created in `provideGlance` with what it loaded there and
|
||||
* the keys it loaded under (typically [refreshStamp] plus whatever the load
|
||||
* depends on). One instance is shared by every size a `SizeMode.Exact` or
|
||||
* `Responsive` widget composes, so a resize doesn't start a size from stale
|
||||
* data and a redraw loads once, not once per size.
|
||||
*/
|
||||
class WidgetData<T>(initial: T, vararg loadedUnder: Any?) {
|
||||
private val value = mutableStateOf(initial)
|
||||
private var loadedKeys: List<Any?> = loadedUnder.toList()
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* The data for [keys], running [load] whenever they differ from the last
|
||||
* successful load's. The last value stays on screen while a load runs or
|
||||
* after one fails; the next key change tries again.
|
||||
*/
|
||||
@Composable
|
||||
fun current(vararg keys: Any?, load: suspend () -> T): T {
|
||||
val wanted = keys.toList()
|
||||
LaunchedEffect(wanted) {
|
||||
mutex.withLock {
|
||||
if (wanted == loadedKeys) return@withLock
|
||||
try {
|
||||
value.value = load()
|
||||
loadedKeys = wanted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Widget data load failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return value.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses bursts of "the data changed" into one [redraw] per [debounce]
|
||||
* window, on [scope]. For an app's own writes, which shouldn't wait for (or
|
||||
* may never get) a system broadcast. A failing redraw is logged and dropped;
|
||||
* the next request tries again.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class WidgetRefresher(
|
||||
scope: CoroutineScope,
|
||||
debounce: Duration = 300.milliseconds,
|
||||
private val redraw: suspend () -> Unit,
|
||||
) {
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
requests.receiveAsFlow().debounce(debounce).collect {
|
||||
try {
|
||||
redraw()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Widget redraw failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Redraw soon. */
|
||||
fun request() {
|
||||
requests.trySend(Unit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- Rounded-square frame, matching M3 Checkbox's unchecked shape rather than a
|
||||
circle, so the widget's tap-to-complete affordance reads the same as the
|
||||
app's own task checkbox. -->
|
||||
<path android:fillColor="@android:color/white" android:fillType="evenOdd"
|
||||
android:pathData="M6,3 H18 A3,3 0 0 1 21,6 V18 A3,3 0 0 1 18,21 H6 A3,3 0 0 1 3,18 V6 A3,3 0 0 1 6,3 Z
|
||||
M6.5,5 H17.5 A1.5,1.5 0 0 1 19,6.5 V17.5 A1.5,1.5 0 0 1 17.5,19 H6.5 A1.5,1.5 0 0 1 5,17.5 V6.5 A1.5,1.5 0 0 1 6.5,5 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- Two checked rows, unlike floret_ic_widget_check's single mark — reads as
|
||||
"tasks exist here" rather than "this one task is done". -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M22,7 H13 V9 H22 V7 Z
|
||||
M22,15 H13 V17 H22 V15 Z
|
||||
M5.54,11 L2,7.46 L3.41,6.05 L5.54,8.17 L9.78,3.93 L11.19,5.34 L5.54,11 Z
|
||||
M5.54,19 L2,15.46 L3.41,14.05 L5.54,16.17 L9.78,11.93 L11.19,13.34 L5.54,19 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A single-row group: full radius on every corner. Tinted at use (WidgetGroupedRow). -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="22dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Bottom of a group: small radius at the inner (top) corners, full at the outer (bottom) corners. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners
|
||||
android:topLeftRadius="6dp"
|
||||
android:topRightRadius="6dp"
|
||||
android:bottomLeftRadius="22dp"
|
||||
android:bottomRightRadius="22dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Middle of a group: small radius on every corner. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Top of a group: full radius at the outer (top) corners, small at the inner (bottom) corners. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners
|
||||
android:topLeftRadius="22dp"
|
||||
android:topRightRadius="22dp"
|
||||
android:bottomLeftRadius="6dp"
|
||||
android:bottomRightRadius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="100" android:viewportHeight="100">
|
||||
<!-- M3 Expressive "cookie" shape: a circle with nine soft scallops. White fill, tinted at the use site. -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M50.00,0.00 L50.87,0.05 L51.74,0.21 L52.60,0.47 L53.44,0.83 L54.26,1.27 L55.07,1.79 L55.85,2.38 L56.60,3.02 L57.33,3.70 L58.04,4.41 L58.72,5.12 L59.39,5.83 L60.04,6.53 L60.67,7.19 L61.30,7.81 L61.94,8.38 L62.57,8.88 L63.22,9.32 L63.88,9.68 L64.57,9.98 L65.28,10.19 L66.02,10.34 L66.80,10.42 L67.61,10.44 L68.46,10.41 L69.34,10.35 L70.25,10.25 L71.20,10.13 L72.16,10.02 L73.15,9.91 L74.14,9.82 L75.14,9.77 L76.13,9.76 L77.11,9.81 L78.06,9.93 L78.97,10.12 L79.85,10.39 L80.67,10.74 L81.44,11.18 L82.14,11.70 L82.77,12.30 L83.34,12.98 L83.82,13.73 L84.24,14.54 L84.59,15.41 L84.87,16.33 L85.09,17.28 L85.26,18.26 L85.38,19.25 L85.47,20.24 L85.53,21.23 L85.58,22.20 L85.63,23.15 L85.69,24.07 L85.78,24.95 L85.90,25.79 L86.06,26.58 L86.27,27.33 L86.55,28.04 L86.89,28.70 L87.29,29.33 L87.77,29.92 L88.31,30.48 L88.92,31.02 L89.59,31.54 L90.30,32.06 L91.07,32.57 L91.86,33.09 L92.68,33.62 L93.50,34.17 L94.32,34.74 L95.12,35.34 L95.88,35.97 L96.60,36.64 L97.25,37.34 L97.83,38.08 L98.33,38.84 L98.73,39.64 L99.04,40.47 L99.24,41.32 L99.34,42.19 L99.33,43.07 L99.23,43.96 L99.02,44.85 L98.73,45.74 L98.36,46.62 L97.91,47.49 L97.41,48.34 L96.87,49.18 L96.30,50.00 L95.71,50.80 L95.12,51.58 L94.55,52.33 L94.01,53.08 L93.51,53.81 L93.06,54.53 L92.68,55.24 L92.36,55.95 L92.11,56.67 L91.95,57.40 L91.85,58.14 L91.84,58.89 L91.89,59.67 L92.01,60.48 L92.19,61.30 L92.41,62.16 L92.67,63.04 L92.94,63.95 L93.23,64.88 L93.50,65.83 L93.76,66.80 L93.99,67.77 L94.16,68.75 L94.28,69.72 L94.33,70.67 L94.30,71.61 L94.19,72.52 L93.99,73.39 L93.69,74.22 L93.30,75.00 L92.82,75.73 L92.25,76.40 L91.59,77.01 L90.87,77.56 L90.07,78.06 L89.22,78.49 L88.32,78.87 L87.38,79.21 L86.43,79.50 L85.47,79.76 L84.50,79.99 L83.55,80.21 L82.63,80.43 L81.74,80.65 L80.88,80.88 L80.08,81.15 L79.32,81.45 L78.62,81.79 L77.97,82.18 L77.38,82.63 L76.83,83.14 L76.33,83.71 L75.88,84.34 L75.45,85.03 L75.05,85.78 L74.67,86.58 L74.30,87.42 L73.93,88.29 L73.55,89.19 L73.15,90.09 L72.73,91.00 L72.27,91.89 L71.78,92.75 L71.25,93.57 L70.67,94.33 L70.05,95.03 L69.38,95.65 L68.66,96.19 L67.90,96.64 L67.10,96.98 L66.26,97.23 L65.39,97.38 L64.50,97.43 L63.59,97.38 L62.66,97.25 L61.73,97.03 L60.79,96.75 L59.86,96.40 L58.94,96.01 L58.04,95.59 L57.15,95.15 L56.28,94.71 L55.44,94.28 L54.61,93.88 L53.81,93.51 L53.02,93.19 L52.25,92.94 L51.49,92.75 L50.74,92.63 L50.00,92.59 L49.26,92.63 L48.51,92.75 L47.75,92.94 L46.98,93.19 L46.19,93.51 L45.39,93.88 L44.56,94.28 L43.72,94.71 L42.85,95.15 L41.96,95.59 L41.06,96.01 L40.14,96.40 L39.21,96.75 L38.27,97.03 L37.34,97.25 L36.41,97.38 L35.50,97.43 L34.61,97.38 L33.74,97.23 L32.90,96.98 L32.10,96.64 L31.34,96.19 L30.62,95.65 L29.95,95.03 L29.33,94.33 L28.75,93.57 L28.22,92.75 L27.73,91.89 L27.27,91.00 L26.85,90.09 L26.45,89.19 L26.07,88.29 L25.70,87.42 L25.33,86.58 L24.95,85.78 L24.55,85.03 L24.12,84.34 L23.67,83.71 L23.17,83.14 L22.62,82.63 L22.03,82.18 L21.38,81.79 L20.68,81.45 L19.92,81.15 L19.12,80.88 L18.26,80.65 L17.37,80.43 L16.45,80.21 L15.50,79.99 L14.53,79.76 L13.57,79.50 L12.62,79.21 L11.68,78.87 L10.78,78.49 L9.93,78.06 L9.13,77.56 L8.41,77.01 L7.75,76.40 L7.18,75.73 L6.70,75.00 L6.31,74.22 L6.01,73.39 L5.81,72.52 L5.70,71.61 L5.67,70.67 L5.72,69.72 L5.84,68.75 L6.01,67.77 L6.24,66.80 L6.50,65.83 L6.77,64.88 L7.06,63.95 L7.33,63.04 L7.59,62.16 L7.81,61.30 L7.99,60.48 L8.11,59.67 L8.16,58.89 L8.15,58.14 L8.05,57.40 L7.89,56.67 L7.64,55.95 L7.32,55.24 L6.94,54.53 L6.49,53.81 L5.99,53.08 L5.45,52.33 L4.88,51.58 L4.29,50.80 L3.70,50.00 L3.13,49.18 L2.59,48.34 L2.09,47.49 L1.64,46.62 L1.27,45.74 L0.98,44.85 L0.77,43.96 L0.67,43.07 L0.66,42.19 L0.76,41.32 L0.96,40.47 L1.27,39.64 L1.67,38.84 L2.17,38.08 L2.75,37.34 L3.40,36.64 L4.12,35.97 L4.88,35.34 L5.68,34.74 L6.50,34.17 L7.32,33.62 L8.14,33.09 L8.93,32.57 L9.70,32.06 L10.41,31.54 L11.08,31.02 L11.69,30.48 L12.23,29.92 L12.71,29.33 L13.11,28.70 L13.45,28.04 L13.73,27.33 L13.94,26.58 L14.10,25.79 L14.22,24.95 L14.31,24.07 L14.37,23.15 L14.42,22.20 L14.47,21.23 L14.53,20.24 L14.62,19.25 L14.74,18.26 L14.91,17.28 L15.13,16.33 L15.41,15.41 L15.76,14.54 L16.18,13.73 L16.66,12.98 L17.23,12.30 L17.86,11.70 L18.56,11.18 L19.33,10.74 L20.15,10.39 L21.03,10.12 L21.94,9.93 L22.89,9.81 L23.87,9.76 L24.86,9.77 L25.86,9.82 L26.85,9.91 L27.84,10.02 L28.80,10.13 L29.75,10.25 L30.66,10.35 L31.54,10.41 L32.39,10.44 L33.20,10.42 L33.98,10.34 L34.72,10.19 L35.43,9.98 L36.12,9.68 L36.78,9.32 L37.43,8.88 L38.06,8.38 L38.70,7.81 L39.33,7.19 L39.96,6.53 L40.61,5.83 L41.28,5.12 L41.96,4.41 L42.67,3.70 L43.40,3.02 L44.15,2.38 L44.93,1.79 L45.74,1.27 L46.56,0.83 L47.40,0.47 L48.26,0.21 L49.13,0.05 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="100" android:viewportHeight="100">
|
||||
<!-- M3 Expressive "sunny" shape: a softly pointed eight-ray burst. White fill, tinted at the use site. -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M50.00,0.00 L50.87,0.03 L51.74,0.12 L52.61,0.27 L53.46,0.48 L54.31,0.75 L55.14,1.06 L55.97,1.41 L56.77,1.81 L57.56,2.24 L58.34,2.70 L59.10,3.17 L59.85,3.66 L60.58,4.16 L61.30,4.66 L62.02,5.15 L62.72,5.63 L63.42,6.10 L64.12,6.54 L64.82,6.96 L65.52,7.36 L66.23,7.72 L66.95,8.05 L67.68,8.35 L68.42,8.63 L69.18,8.87 L69.95,9.09 L70.74,9.29 L71.55,9.47 L72.38,9.63 L73.21,9.79 L74.07,9.95 L74.93,10.11 L75.80,10.27 L76.67,10.45 L77.55,10.65 L78.42,10.88 L79.29,11.14 L80.14,11.43 L80.97,11.75 L81.78,12.12 L82.57,12.54 L83.32,13.00 L84.04,13.50 L84.72,14.05 L85.36,14.64 L85.95,15.28 L86.50,15.96 L87.00,16.68 L87.46,17.43 L87.88,18.22 L88.25,19.03 L88.57,19.86 L88.86,20.71 L89.12,21.58 L89.35,22.45 L89.55,23.33 L89.73,24.20 L89.89,25.07 L90.05,25.93 L90.21,26.79 L90.37,27.62 L90.53,28.45 L90.71,29.26 L90.91,30.05 L91.13,30.82 L91.37,31.58 L91.65,32.32 L91.95,33.05 L92.28,33.77 L92.64,34.48 L93.04,35.18 L93.46,35.88 L93.90,36.58 L94.37,37.28 L94.85,37.98 L95.34,38.70 L95.84,39.42 L96.34,40.15 L96.83,40.90 L97.30,41.66 L97.76,42.44 L98.19,43.23 L98.59,44.03 L98.94,44.86 L99.25,45.69 L99.52,46.54 L99.73,47.39 L99.88,48.26 L99.97,49.13 L100.00,50.00 L99.97,50.87 L99.88,51.74 L99.73,52.61 L99.52,53.46 L99.25,54.31 L98.94,55.14 L98.59,55.97 L98.19,56.77 L97.76,57.56 L97.30,58.34 L96.83,59.10 L96.34,59.85 L95.84,60.58 L95.34,61.30 L94.85,62.02 L94.37,62.72 L93.90,63.42 L93.46,64.12 L93.04,64.82 L92.64,65.52 L92.28,66.23 L91.95,66.95 L91.65,67.68 L91.37,68.42 L91.13,69.18 L90.91,69.95 L90.71,70.74 L90.53,71.55 L90.37,72.38 L90.21,73.21 L90.05,74.07 L89.89,74.93 L89.73,75.80 L89.55,76.67 L89.35,77.55 L89.12,78.42 L88.86,79.29 L88.57,80.14 L88.25,80.97 L87.88,81.78 L87.46,82.57 L87.00,83.32 L86.50,84.04 L85.95,84.72 L85.36,85.36 L84.72,85.95 L84.04,86.50 L83.32,87.00 L82.57,87.46 L81.78,87.88 L80.97,88.25 L80.14,88.57 L79.29,88.86 L78.42,89.12 L77.55,89.35 L76.67,89.55 L75.80,89.73 L74.93,89.89 L74.07,90.05 L73.21,90.21 L72.38,90.37 L71.55,90.53 L70.74,90.71 L69.95,90.91 L69.18,91.13 L68.42,91.37 L67.68,91.65 L66.95,91.95 L66.23,92.28 L65.52,92.64 L64.82,93.04 L64.12,93.46 L63.42,93.90 L62.72,94.37 L62.02,94.85 L61.30,95.34 L60.58,95.84 L59.85,96.34 L59.10,96.83 L58.34,97.30 L57.56,97.76 L56.77,98.19 L55.97,98.59 L55.14,98.94 L54.31,99.25 L53.46,99.52 L52.61,99.73 L51.74,99.88 L50.87,99.97 L50.00,100.00 L49.13,99.97 L48.26,99.88 L47.39,99.73 L46.54,99.52 L45.69,99.25 L44.86,98.94 L44.03,98.59 L43.23,98.19 L42.44,97.76 L41.66,97.30 L40.90,96.83 L40.15,96.34 L39.42,95.84 L38.70,95.34 L37.98,94.85 L37.28,94.37 L36.58,93.90 L35.88,93.46 L35.18,93.04 L34.48,92.64 L33.77,92.28 L33.05,91.95 L32.32,91.65 L31.58,91.37 L30.82,91.13 L30.05,90.91 L29.26,90.71 L28.45,90.53 L27.62,90.37 L26.79,90.21 L25.93,90.05 L25.07,89.89 L24.20,89.73 L23.33,89.55 L22.45,89.35 L21.58,89.12 L20.71,88.86 L19.86,88.57 L19.03,88.25 L18.22,87.88 L17.43,87.46 L16.68,87.00 L15.96,86.50 L15.28,85.95 L14.64,85.36 L14.05,84.72 L13.50,84.04 L13.00,83.32 L12.54,82.57 L12.12,81.78 L11.75,80.97 L11.43,80.14 L11.14,79.29 L10.88,78.42 L10.65,77.55 L10.45,76.67 L10.27,75.80 L10.11,74.93 L9.95,74.07 L9.79,73.21 L9.63,72.38 L9.47,71.55 L9.29,70.74 L9.09,69.95 L8.87,69.18 L8.63,68.42 L8.35,67.68 L8.05,66.95 L7.72,66.23 L7.36,65.52 L6.96,64.82 L6.54,64.12 L6.10,63.42 L5.63,62.72 L5.15,62.02 L4.66,61.30 L4.16,60.58 L3.66,59.85 L3.17,59.10 L2.70,58.34 L2.24,57.56 L1.81,56.77 L1.41,55.97 L1.06,55.14 L0.75,54.31 L0.48,53.46 L0.27,52.61 L0.12,51.74 L0.03,50.87 L0.00,50.00 L0.03,49.13 L0.12,48.26 L0.27,47.39 L0.48,46.54 L0.75,45.69 L1.06,44.86 L1.41,44.03 L1.81,43.23 L2.24,42.44 L2.70,41.66 L3.17,40.90 L3.66,40.15 L4.16,39.42 L4.66,38.70 L5.15,37.98 L5.63,37.28 L6.10,36.58 L6.54,35.88 L6.96,35.18 L7.36,34.48 L7.72,33.77 L8.05,33.05 L8.35,32.32 L8.63,31.58 L8.87,30.82 L9.09,30.05 L9.29,29.26 L9.47,28.45 L9.63,27.62 L9.79,26.79 L9.95,25.93 L10.11,25.07 L10.27,24.20 L10.45,23.33 L10.65,22.45 L10.88,21.58 L11.14,20.71 L11.43,19.86 L11.75,19.03 L12.12,18.22 L12.54,17.43 L13.00,16.68 L13.50,15.96 L14.05,15.28 L14.64,14.64 L15.28,14.05 L15.96,13.50 L16.68,13.00 L17.43,12.54 L18.22,12.12 L19.03,11.75 L19.86,11.43 L20.71,11.14 L21.58,10.88 L22.45,10.65 L23.33,10.45 L24.20,10.27 L25.07,10.11 L25.93,9.95 L26.79,9.79 L27.62,9.63 L28.45,9.47 L29.26,9.29 L30.05,9.09 L30.82,8.87 L31.58,8.63 L32.32,8.35 L33.05,8.05 L33.77,7.72 L34.48,7.36 L35.18,6.96 L35.88,6.54 L36.58,6.10 L37.28,5.63 L37.98,5.15 L38.70,4.66 L39.42,4.16 L40.15,3.66 L40.90,3.17 L41.66,2.70 L42.44,2.24 L43.23,1.81 L44.03,1.41 L44.86,1.06 L45.69,0.75 L46.54,0.48 L47.39,0.27 L48.26,0.12 L49.13,0.03 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The dynamic roles Glance's own theme maps, for widget views drawn as plain RemoteViews. -->
|
||||
<resources>
|
||||
<color name="floret_widget_on_surface">@android:color/system_neutral1_100</color>
|
||||
<color name="floret_widget_on_surface_variant">@android:color/system_neutral2_200</color>
|
||||
<color name="floret_widget_primary">@android:color/system_accent1_200</color>
|
||||
<color name="floret_widget_on_primary">@android:color/system_accent1_800</color>
|
||||
<color name="floret_widget_on_secondary_container">@android:color/system_accent2_100</color>
|
||||
<color name="floret_widget_surface_variant">@android:color/system_neutral2_700</color>
|
||||
<color name="floret_widget_widget_background">@android:color/system_accent2_800</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallbacks only: floret reads these roles from the system palette, on API 31+ (values-v31). -->
|
||||
<resources>
|
||||
<color name="floret_widget_on_surface">#FFE4E1E6</color>
|
||||
<color name="floret_widget_on_surface_variant">#FFC6C5D0</color>
|
||||
<color name="floret_widget_primary">#FFBAC3FF</color>
|
||||
<color name="floret_widget_on_primary">#FF08218A</color>
|
||||
<color name="floret_widget_on_secondary_container">#FFDEE1F9</color>
|
||||
<color name="floret_widget_surface_variant">#FF46464F</color>
|
||||
<color name="floret_widget_widget_background">#FF2B2D3A</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The dynamic roles Glance's own theme maps, for widget views drawn as plain RemoteViews. -->
|
||||
<resources>
|
||||
<color name="floret_widget_on_surface">@android:color/system_neutral1_900</color>
|
||||
<color name="floret_widget_on_surface_variant">@android:color/system_neutral2_700</color>
|
||||
<color name="floret_widget_primary">@android:color/system_accent1_600</color>
|
||||
<color name="floret_widget_on_primary">@android:color/system_accent1_0</color>
|
||||
<color name="floret_widget_on_secondary_container">@android:color/system_accent2_900</color>
|
||||
<color name="floret_widget_surface_variant">@android:color/system_neutral2_100</color>
|
||||
<color name="floret_widget_widget_background">@android:color/system_accent2_50</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallbacks only: floret reads these roles from the system palette, on API 31+ (values-v31). -->
|
||||
<resources>
|
||||
<color name="floret_widget_on_surface">#FF1B1B1F</color>
|
||||
<color name="floret_widget_on_surface_variant">#FF46464F</color>
|
||||
<color name="floret_widget_primary">#FF4355B9</color>
|
||||
<color name="floret_widget_on_primary">#FFFFFFFF</color>
|
||||
<color name="floret_widget_on_secondary_container">#FF171B2C</color>
|
||||
<color name="floret_widget_surface_variant">#FFE3E1EC</color>
|
||||
<color name="floret_widget_widget_background">#FFF3F3FA</color>
|
||||
</resources>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Exercises the action-set wiring via reflection, since it's computed from `protected` members. */
|
||||
class FloretWidgetUpdateReceiverTest {
|
||||
|
||||
private class Fake(override val extraHandledActions: Set<String> = emptySet()) : FloretWidgetUpdateReceiver() {
|
||||
override val rolloverAction = "app.widget.ROLLOVER"
|
||||
override val widgetReceiverClasses: List<Class<*>> = emptyList()
|
||||
override fun rolloverPendingIntent(context: Context): PendingIntent = error("not exercised")
|
||||
override suspend fun redraw(context: Context) = Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rollover action is handled and re-arms`() {
|
||||
val receiver = Fake()
|
||||
assertThat(receiver.handledActions).contains("app.widget.ROLLOVER")
|
||||
assertThat(receiver.rearmActions).contains("app.widget.ROLLOVER")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a language change redraws without re-arming`() {
|
||||
val receiver = Fake()
|
||||
assertThat(receiver.handledActions).contains(Intent.ACTION_LOCALE_CHANGED)
|
||||
assertThat(receiver.rearmActions).doesNotContain(Intent.ACTION_LOCALE_CHANGED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything that loses the alarm re-arms it`() {
|
||||
assertThat(Fake().rearmActions).containsAtLeast(
|
||||
"app.widget.ROLLOVER",
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
Intent.ACTION_DATE_CHANGED,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extra action is handled but never re-arms`() {
|
||||
val receiver = Fake(extraHandledActions = setOf("app.PROVIDER_CHANGED"))
|
||||
assertThat(receiver.handledActions).contains("app.PROVIDER_CHANGED")
|
||||
assertThat(receiver.rearmActions).doesNotContain("app.PROVIDER_CHANGED")
|
||||
}
|
||||
}
|
||||
|
||||
class RearmThrottleTest {
|
||||
|
||||
@Test
|
||||
fun `the first call passes`() {
|
||||
assertThat(RearmThrottle().tryAcquire(nowMillis = 1_000, intervalMillis = 60_000)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calls inside the interval are refused`() {
|
||||
val throttle = RearmThrottle()
|
||||
throttle.tryAcquire(1_000, 60_000)
|
||||
assertThat(throttle.tryAcquire(30_000, 60_000)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a call after the interval passes again`() {
|
||||
val throttle = RearmThrottle()
|
||||
throttle.tryAcquire(1_000, 60_000)
|
||||
assertThat(throttle.tryAcquire(61_000, 60_000)).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class WidgetRefresherTest {
|
||||
|
||||
@Test
|
||||
fun `a burst of requests redraws once`() = runTest {
|
||||
var redraws = 0
|
||||
val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) { redraws++ }
|
||||
repeat(5) { refresher.request() }
|
||||
advanceTimeBy(1_000)
|
||||
assertThat(redraws).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requests further apart than the window redraw each time`() = runTest {
|
||||
var redraws = 0
|
||||
val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) { redraws++ }
|
||||
refresher.request()
|
||||
advanceTimeBy(500)
|
||||
refresher.request()
|
||||
advanceTimeBy(1_000)
|
||||
assertThat(redraws).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing redraw doesn't stop later ones`() = runTest {
|
||||
var calls = 0
|
||||
val refresher = WidgetRefresher(backgroundScope, 300.milliseconds) {
|
||||
calls++
|
||||
if (calls == 1) error("boom")
|
||||
}
|
||||
refresher.request()
|
||||
advanceTimeBy(500)
|
||||
refresher.request()
|
||||
advanceTimeBy(1_000)
|
||||
assertThat(calls).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.jeanlucmakiola.floret.glance
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */
|
||||
class WidgetRolloverTest {
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
private fun at(local: String, zone: TimeZone): Instant =
|
||||
LocalDateTime.parse(local).toInstant(zone)
|
||||
|
||||
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
||||
WidgetRollover.nextRolloverAt(at(local, zone), zone)
|
||||
|
||||
// --- the ordinary day ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `midday rolls over at the coming midnight`() {
|
||||
val next = nextRollover("2026-08-27T12:00:00")
|
||||
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
||||
val next = nextRollover("2026-08-27T23:59:59")
|
||||
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at midnight exactly the target is the next day, never the current instant`() {
|
||||
// Re-arming after a firing must move a whole day on, or the widget wakes
|
||||
// itself in a tight loop.
|
||||
val now = at("2026-08-28T00:00:00", berlin)
|
||||
val next = WidgetRollover.nextRolloverAt(now, berlin)
|
||||
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-arming from the slack instant itself moves a full day on`() {
|
||||
// What actually happens in practice: the receiver runs at midnight + slack.
|
||||
val now = at("2026-08-28T00:00:05", berlin)
|
||||
assertThat(WidgetRollover.nextRolloverAt(now, berlin))
|
||||
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the result is always in the future for every minute of a day`() {
|
||||
val zone = berlin
|
||||
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
||||
// in the past, i.e. an alarm that fires immediately, forever.
|
||||
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
||||
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
||||
while (probe < end) {
|
||||
assertThat(WidgetRollover.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
||||
probe += 1.minutes
|
||||
}
|
||||
}
|
||||
|
||||
// --- daylight saving -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
||||
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
||||
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
||||
val now = at("2024-03-30T12:00:00", berlin)
|
||||
val next = WidgetRollover.nextRolloverAt(now, berlin)
|
||||
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
||||
assertThat(next - now).isLessThan(24.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fall back does not overshoot into the repeated hour`() {
|
||||
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
||||
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
||||
val now = at("2024-10-26T12:00:00", berlin)
|
||||
val next = WidgetRollover.nextRolloverAt(now, berlin)
|
||||
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zone that repeats midnight takes the first start of day`() {
|
||||
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
||||
// began twice. Pinned as the accepted trade: the widget runs an hour
|
||||
// ahead until the next redraw. No live zone does this since 2019.
|
||||
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
||||
val next = WidgetRollover.nextRolloverAt(at("2018-02-16T12:00:00", saoPaulo), saoPaulo)
|
||||
val local = next.toLocalDateTime(saoPaulo)
|
||||
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
||||
assertThat(local.hour).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
||||
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
||||
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
||||
val havana = TimeZone.of("America/Havana")
|
||||
val next = WidgetRollover.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
||||
val local = next.toLocalDateTime(havana)
|
||||
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
||||
assertThat(local.hour).isEqualTo(1)
|
||||
assertThat(local.minute).isEqualTo(0)
|
||||
// And it is genuinely the first instant of that date, not a guess.
|
||||
assertThat(next).isEqualTo(
|
||||
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + WidgetRollover.ROLLOVER_SLACK,
|
||||
)
|
||||
}
|
||||
|
||||
// --- timezone changes ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the same instant rolls over at different times in different zones`() {
|
||||
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
||||
// follows the zone, not a cached offset.
|
||||
val instant = at("2026-08-27T12:00:00", berlin)
|
||||
val tokyo = TimeZone.of("Asia/Tokyo")
|
||||
val berlinNext = WidgetRollover.nextRolloverAt(instant, berlin)
|
||||
val tokyoNext = WidgetRollover.nextRolloverAt(instant, tokyo)
|
||||
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
||||
assertThat(tokyoNext).isLessThan(berlinNext)
|
||||
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a half-hour offset zone still lands on its own midnight`() {
|
||||
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
||||
val next = WidgetRollover.nextRolloverAt(at("2026-08-27T12:00:00", kathmandu), kathmandu)
|
||||
val local = next.toLocalDateTime(kathmandu)
|
||||
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
||||
assertThat(local.hour).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
# Version catalog for floret-kit. Kept in lockstep with the consuming apps'
|
||||
# catalogs (same AGP/Kotlin/Compose + test stack) until the catalog itself is shared.
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
agp = "9.4.1"
|
||||
kotlin = "2.4.20"
|
||||
coreKtx = "1.19.1"
|
||||
appcompat = "1.8.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
||||
material3 = "1.5.0-alpha21"
|
||||
junit = "6.1.0"
|
||||
junitPlatform = "6.1.0"
|
||||
composeBom = "2026.09.00"
|
||||
# Material 3 Expressive APIs live in the 1.5 alpha line (no stable 1.5 yet); pinned to override the BOM.
|
||||
material3 = "1.5.0-alpha29"
|
||||
junit = "6.1.3"
|
||||
junitPlatform = "6.1.3"
|
||||
ksp = "2.3.9"
|
||||
hilt = "2.59.2"
|
||||
datastore = "1.2.1"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
turbine = "1.2.0"
|
||||
# Glance: kept in lockstep with the apps' widget stack.
|
||||
glance = "1.2.0"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
truth = "1.4.5"
|
||||
|
||||
[libraries]
|
||||
@@ -23,6 +31,16 @@ androidx-foundation = { group = "androidx.compose.foundation", name = "foundatio
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
androidx-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
|
||||
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
|
||||
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||
hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||
|
||||
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
||||
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
||||
@@ -33,3 +51,4 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+4
-2
@@ -1,8 +1,10 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
@@ -29,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -172,7 +171,6 @@ fi
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
|
||||
Vendored
+52
-34
@@ -19,12 +19,39 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
@rem Catch executions from older scripts and ensure they exit cleanly.
|
||||
@rem This can be removed once we can be reasonably confident that few people
|
||||
@rem will be migrating directly to this new wrapper.
|
||||
goto afterSafetyNet
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
||||
goto exitWithErrorLevel
|
||||
:afterSafetyNet
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
1>&2 echo.
|
||||
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
1>&2 echo.
|
||||
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
|
||||
1>&2 echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
goto exitWithErrorLevel
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -3,22 +3,31 @@ package de.jeanlucmakiola.floret.identity
|
||||
import android.provider.Settings
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
@@ -59,7 +68,10 @@ fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMoti
|
||||
* The standard Android predictive-back transform for a full-screen surface: as
|
||||
* the back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||
* gesture invokes [onBack]; cancelling springs it back.
|
||||
* gesture invokes [onBack] and keeps the preview transform, so the surface
|
||||
* leaves from where the finger let go instead of snapping back to full size —
|
||||
* pair it with [predictiveBackExit] on the surface's own exit. Cancelling
|
||||
* springs it back.
|
||||
*
|
||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||
@@ -70,26 +82,48 @@ fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMoti
|
||||
*
|
||||
* @param enabled gate the handler — e.g. set false while a screen's own inner
|
||||
* back (a sub-section) should take the gesture instead.
|
||||
* @param visible whether the surface is currently shown — typically the
|
||||
* `visible` of the `AnimatedVisibility` it lives in. The preview is held while
|
||||
* the surface leaves and dropped the moment it is shown again, so reopening it
|
||||
* mid-exit never starts from the shrunk state; a back that leaves it visible
|
||||
* settles back. Null when the caller doesn't track it: the preview is then held
|
||||
* for a fixed time before settling back. A surface that is leaving no longer
|
||||
* takes back, so a second press reaches the screen underneath.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.predictiveBack(
|
||||
onBack: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
visible: Boolean? = null,
|
||||
reduceMotion: Boolean = rememberReduceMotion(),
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(0f) }
|
||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||
val currentVisible by rememberUpdatedState(visible)
|
||||
|
||||
PredictiveBackHandler(enabled = enabled) { events ->
|
||||
LaunchedEffect(visible) {
|
||||
if (visible == true) progress.snapTo(0f)
|
||||
}
|
||||
|
||||
PredictiveBackHandler(enabled = enabled && visible != false) { events ->
|
||||
try {
|
||||
events.collect { event ->
|
||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||
}
|
||||
onBack()
|
||||
progress.snapTo(0f)
|
||||
} catch (_: CancellationException) {
|
||||
progress.animateTo(0f)
|
||||
if (currentVisible == null) {
|
||||
delay(COMMIT_SETTLE_DELAY_MILLIS)
|
||||
progress.animateTo(0f)
|
||||
} else {
|
||||
// Two frames guarantee the recomposition that onBack triggered.
|
||||
repeat(2) { withFrameNanos { } }
|
||||
if (currentVisible == true) progress.animateTo(0f)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// A cancelled gesture also cancels this job, so the spring-back must not be.
|
||||
withContext(NonCancellable) { progress.animateTo(0f) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,3 +138,26 @@ fun Modifier.predictiveBack(
|
||||
clip = p > 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a committed preview is held before it settles back when the caller
|
||||
* doesn't pass `visible` — past any exit the surface would play.
|
||||
*/
|
||||
private const val COMMIT_SETTLE_DELAY_MILLIS = 1_000L
|
||||
|
||||
/**
|
||||
* The exit for a surface closed through [predictiveBack]: it fades while it keeps
|
||||
* shrinking from the preview's scale, rather than growing back and sliding off
|
||||
* at full size. Also reads right for a close without a gesture. Fade only under
|
||||
* reduced motion.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun predictiveBackExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||
val fade = fadeOut(MaterialTheme.motionScheme.defaultEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
fade + scaleOut(MaterialTheme.motionScheme.defaultSpatialSpec(), targetScale = 0.9f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -10,8 +10,19 @@ import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* The resolved light/dark decision the current [FloretExpressiveTheme] is running
|
||||
* with, carried down the tree so components can align window chrome (e.g. status-
|
||||
* and navigation-bar icon appearance) with the *runtime* theme rather than the
|
||||
* system night resource qualifier — the two diverge whenever an app overrides
|
||||
* light/dark from settings. Defaults to `false` (light) outside a Floret theme.
|
||||
*/
|
||||
val LocalFloretDarkTheme = staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* The family's Material 3 Expressive theme factory. Honours system light/dark
|
||||
* and Material You dynamic colour (API 31+), falling back to the app-supplied
|
||||
@@ -23,6 +34,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* 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
|
||||
@@ -31,6 +45,7 @@ fun FloretExpressiveTheme(
|
||||
darkScheme: ColorScheme,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
pureBlack: Boolean = false,
|
||||
typography: Typography = Typography(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
@@ -41,12 +56,14 @@ fun FloretExpressiveTheme(
|
||||
}
|
||||
darkTheme -> darkScheme
|
||||
else -> lightScheme
|
||||
}
|
||||
}.let { if (darkTheme && pureBlack) it.toPureBlack() else it }
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) {
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user