Author SHA1 Message Date
makiolaj f2ffaed925 glance: header title inset, surface-variant and widget-background remote colour roles 2026-10-23 17:59:33 +02:00
makiolaj 76e07fe8cb glance: mark the state-restore helper's restricted GlanceState use as deliberate 2026-10-23 15:48:43 +02:00
makiolaj 513c135ec2 glance: RemoteColor helpers for widget views drawn as plain RemoteViews, dynamic roles as palette resources 2026-10-23 15:45:54 +02:00
makiolaj d6b55476d1 glance: tonal and text header pills, header action gap, transparent title pill hugs its text, preview at true size cropped with widget corners; components: GroupedChoiceRow side inset 2026-10-23 14:51:22 +02:00
makiolaj 6dc7bc50c5 glance: wallpaper backing, themed colours, tinted text roles, state restore, widget preview; components: GroupedChoiceRow (calendula#292) 2026-10-23 11:12:18 +02:00
Jean-Luc Makiolaandmakiolaj 96509d0f9d glance: widget refresh helpers (updatePlaced, refresh stamp, WidgetRefresher, throttled re-arm) (#18)
Widget-refresh plumbing that Calendula's 2.24.0 widget refactor needs, and that Agendula already hand-rolls.

- `GlanceAppWidget.updatePlaced(context, receiverClass)` redraws every placed instance using ids from `AppWidgetManager` + `getGlanceIdBy`. `updateAll` uses Glance's provider-name map, which is empty in a process that hasn't hosted a widget session yet, so the redraw gets dropped (calendula#18, calendula#420).
- `updatePlaced(..., bumpRefreshStamp = true)` also moves each instance's `currentRefreshStamp()`. An update to a running session only recomposes and never re-runs `provideGlance`, so a widget that keys its data load on the stamp reloads there too. This is the per-widget `data_stamp` idea from calendula#420.
- `WidgetRefresher` turns a burst of "data changed" requests into one debounced redraw, the generic part of Agendula's `TaskWidgetUpdater`. Calendula will use it for its own writes, so they don't have to wait for `PROVIDER_CHANGED`.
- `FloretWidgetUpdateReceiver.extraRearmInterval` lets extra actions also re-arm the rollover alarm, at most once per interval. This moves Calendula's 15-minute `PROVIDER_CHANGED` backstop upstream. It defaults to off, so Agendula is unchanged.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/18
2026-10-21 17:56:00 +02:00
Jean-Luc Makiola 68d6432434 Merge pull request 'feat(core-reminders): reminder leads that count from the start' (#19) from feat/reminder-leads into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/19
2026-10-21 17:01:11 +02:00
makiolaj a34a74fb8b feat(core-reminders): reminder leads that count from the start
Lets an app's reminder defaults name whether each lead counts back from the
start or the due/end date. Additive; minute-only callers are unchanged.
2026-10-21 16:54:15 +02:00
Jean-Luc Makiola 4858e00dfa Merge pull request 'fix(identity): stop a leaving surface from taking back' (#17) from fix/predictive-back-exit-gate into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/17
2026-10-16 15:06:12 +02:00
makiolaj 44ab9cd966 fix(identity): stop a leaving surface from taking back 2026-10-16 14:55:36 +02:00
Jean-Luc Makiola a1d36ba1f0 Merge pull request 'components: public StepNumber with a done state' (#16) from step-number-done into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/16
2026-10-16 14:52:53 +02:00
makiolaj 29b455341a Merge remote-tracking branch 'origin/main' into step-number-done
# Conflicts:
#	CHANGELOG.md
#	components/src/main/kotlin/de/jeanlucmakiola/floret/components/InstructionSteps.kt
2026-10-16 14:49:07 +02:00
makiolaj 3449583225 components: public StepNumber with a done state
Apps that build numbered steps from their own rows, e.g. rows that open a
setting, can reuse the disc and show a check once a step is carried out.
2026-10-16 14:48:47 +02:00
Jean-Luc Makiola 9f90027dcd Merge pull request 'core-prefs: typed snapshot access and nullableBooleanPref' (#15) from feat/prefs-snapshot-access into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/15
2026-10-16 13:10:11 +02:00
makiolaj 3b376ac2dd core-prefs: typed snapshot access and nullableBooleanPref 2026-10-16 12:55:30 +02:00
Jean-Luc Makiola 5093e1cc5f Merge pull request 'feat: shared rows, onboarding pieces and Android helpers from Calendula' (#14) from feat/calendula-shared-batch into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/14
2026-10-15 17:09:45 +02:00
makiolaj 0f7beb828d docs: list the new components in the README 2026-10-15 16:53:31 +02:00
makiolaj 394ac12236 feat(core-android): receiver, permission, alarm and Flow helpers
New Android module (no Compose, no DI) with broadcastTicks, a coroutine
BroadcastReceiver.goAsync, Context.hasPermission, AlarmManager.setWakeup and
Flow.stateWhileSubscribed.
2026-10-15 16:52:14 +02:00
makiolaj 1cc7f8f71d feat(core-reminders): ReminderOverride.minutesOrEmpty 2026-10-15 16:50:51 +02:00
makiolaj d4970ba2bc feat(core-locale): relativeTimeAgo 2026-10-15 16:50:34 +02:00
makiolaj 71212c49d2 feat(components): icon info card, onboarding headline and language row header
IconInfoCard for the detail/edit cards, OnboardingScaffold headline/supporting
plus OnboardingPrimaryButton, and header/predictiveBack on LanguagePickerRow.
2026-10-15 16:50:19 +02:00
makiolaj 762ea5fe57 feat(components): checked rows, switch rows, custom-expand row and option rows
GroupedRow gains checked, OptionPicker gains dismissOnSelect and exposes its
rows as OptionRows. New GroupedSwitchRow, CustomExpandRow, GroupedHint and
ExpandIcon. Every existing call keeps its behaviour.
2026-10-15 16:50:13 +02:00
Jean-Luc Makiola b1905dac00 Merge pull request 'feat(identity): pure-black (AMOLED) dark theme' (#13) from feat/pure-black-theme into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/13
2026-10-14 17:39:45 +02:00
makiolaj f1ded81c03 feat(identity): pure-black (AMOLED) dark theme
ColorScheme.toPureBlack() sets background, surface, surfaceDim and
surfaceContainerLowest to #000 and dims the other containers by scaling
their sRGB channels, so their hue tint carries over and grid tiles drawn
as containers over surface keep visible gaps. FloretExpressiveTheme takes
pureBlack to apply it to the dark scheme, dynamic or app-supplied.

core-prefs gains ThemeMode.BLACK (dark, pure black) and
ThemeMode.isPureBlack, stored under the same theme_mode key.
2026-10-14 17:19:31 +02:00
Jean-Luc Makiola 0b44d353d2 Merge pull request 'feat(components): revealOnFocus' (#12) from feat/reveal-on-focus into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/12
2026-10-09 12:04:21 +02:00
makiolaj 675724411e feat(components): revealOnFocus keeps a focused input group above the keyboard
On by default in InlineTextField, GroupedSurface and OptionalFormSection.
2026-10-09 11:50:25 +02:00
Jean-Luc Makiolaandmakiolaj 8b33635a85 Converge sibling branches into main (0.7.0) (#11)
Brings the branches the sibling apps actually ship from back onto main, so floret-kit stops forking per project.

Merged in:
- `vouch/settings` (what vouch pins), which already contains `feat/clockula` (what clockula pins) and `chore/deps-2026-10`: core-prefs, core-di, CollapsingScaffold FAB/no-back slots, the glance module, LocaleManager on API 33+, and the dependency bump
- `feat/glance-module`: the three glance commits clockula's branch didn't have yet (progress ring/tile/check button, expressive shapes + header pill, WidgetDoneState)

Both merged without conflicts. Version ends up at 0.7.0 (untagged).

Heads-up for consumers: the deps bump moves the toolchain to Gradle 9.8.0 / AGP 9.4.1 / Kotlin 2.4.20. Since floret-kit is a composite build, a consumer must be on Gradle >= 9.6 to pin anything from here on. Calendula builds, tests and lints against this branch with just that toolchain bump, no source changes.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/11
2026-10-07 09:36:15 +02:00
Jean-Luc Makiola 318aeab6bd Merge predictive-back fixes 2026-09-30 13:32:24 +02:00
makiolaj b7e73ee880 Spring back a cancelled back preview even though its job is cancelled 2026-09-27 12:11:10 +02:00
makiolaj 1d6b1ed2a9 Drop the back preview when the surface is shown again 2026-09-27 12:06:45 +02:00
makiolaj 1ab46897c3 Finish predictive back from the scaled preview 2026-09-27 11:21:29 +02:00
Jean-Luc Makiola db4be68dad Merge pull request 'floret-kit 0.4.0' (#8) from feat/agendula-1.0 into main 2026-09-24 16:29:03 +02:00
makiolaj ffca74791f release: floret-kit 0.4.0 2026-09-24 16:25:14 +02:00
makiolaj a344b2aa04 Merge remote-tracking branch 'origin/main' into feat/agendula-1.0
# Conflicts:
#	CHANGELOG.md
2026-09-24 16:24:52 +02:00
makiolaj 94cb850fa4 core-time: optional 12/24-hour override for formatTime and friends 2026-09-23 18:15:12 +02:00
makiolaj 8ed9b1dbdc components: ReorderableColumn resists dragging past the ends 2026-09-23 17:24:36 +02:00
makiolaj 1e49fd5067 components: ReorderableColumn drag shadow follows the card 2026-09-23 17:13:07 +02:00
makiolaj d5f5ad7b9d components: ReorderableColumn row height parameter 2026-09-23 16:51:55 +02:00
Jean-Luc Makiola 14c13bae6d Merge pull request 'fix(core-crash): file the prefilled crash issue in English' (#7) from fix/crash-issue-english into main 2026-09-12 15:14:00 +02:00
makiolaj ae5b7548d0 fix(core-crash): file the prefilled crash issue in English
The issue the crash flow prefills took its title and body from string
resources, so both followed the device language: a crash on a French
phone opened the tracker with a French issue, which is unreadable to
whoever has to triage it. The report itself was always English — only
the wrapper around it wasn't.

That text is posted rather than shown, so it is no longer a resource at
all: title and body are fixed English in CrashReportSubmit, and
CrashConfig drops issueTitle (apps pass one fewer argument and can
delete their own crash_report_issue_title / crash_report_body_* strings).
What the reporter sees in the app — the dialog, the toasts, the
clipboard label — stays translated, which is the line the kdoc and the
resource header now draw.
2026-09-12 15:12:23 +02:00
makiolaj 103a37cb28 components: numbered instruction steps
A connected list for a procedure the user has to carry out somewhere
else. Collapsible, so it can be shown again as a reference once it has
already had a screen of its own.
2026-09-09 20:57:53 +02:00
makiolaj 97b4d24724 fix(components): take the section header's metrics from Calendula's
The header was invented here rather than converged from the app that
already had one. Calendula's SectionHeader is labelLarge at top 16 /
bottom 4, and separates one group from the next with the caller's own 8dp
spacer — a hub's first header follows the About card, not another group,
so the gap cannot live on the header. Matched exactly, so the two apps'
settings hubs are the same page.
2026-09-09 15:33:53 +02:00
makiolaj 1af24841ae feat(components): a named header above a run of grouped rows
A settings page with a dozen categories in one flat run has no way to say
which of them are about how the app behaves and which are about the app
itself. GroupedSectionHeader is that label: it starts on GroupedListInset,
the edge the cards it names start on, and owns the gap to the group above,
so a page writes header-then-rows and nothing else.

primary rather than onSurfaceVariant — after the Android-15 settings
pattern Position already follows, and because a header that matches the
summaries underneath it stops separating anything.
2026-09-09 15:17:56 +02:00
makiolaj d7b7a542cd feat(components): the preview picker's plate and scaled screen preview
ScaledViewPreview measures real screen composables at a phone viewport and
scales the finished layout down; ViewPreviewFrame is the tonal plate it sits
on, cross-fading between options at the grouped rows' width and radius so
preview and options read as one stack.

Converged from Calendula's view and month-style choosers, where the pattern
was invented, and now Agendula's quick-add step. The viewport height is a
parameter here rather than a calendar-shaped constant.
2026-09-09 13:48:24 +02:00
makiolaj 692c0a84c4 feat(components): let a tinted grouped row name its content colour
GroupedRow's container had no `on*` pair, so a row given a tint kept its
text on onSurface — readable by luck rather than by the theme. Wanted by a
status row that colours itself by outcome.
2026-09-09 13:48:17 +02:00
makiolaj 3dc3448265 feat(components): let an inline text field mask its value
KeyboardType.Password only tells the IME to drop suggestions; without a
visual transformation a borderless password field renders the secret in
the clear.
2026-09-07 18:39:39 +02:00
Jean-Luc Makiola ad440e8cac Merge pull request 'Resolve the applied locale back onto a shipped tag' (#5) from fix/language-picker-tag-match into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/5
2026-08-11 18:08:04 +02:00
makiolaj c6071239ce Resolve the applied locale back onto a shipped tag
The per-app-language 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 matches the tag the picker offers — the selected row lost its
mark after a restart. currentTag(supported) maps it back.
2026-08-11 18:05:33 +02:00
makiolaj 05c79f2afc release: floret-kit 0.3.0
Two new components since 0.2.1 — the onboarding shell (OnboardingScaffold,
OnboardingProgress, BenefitRow) that Calendula's first-run wizard is built
on, and SnackChip, the compact confirmation converged from Agendula's and
Calendula's undo chips — plus grouped-list and picker richness (long-press
rows, styled row text, exported GroupedListInset, per-option summaries,
public SelectedCheck, AboutCard in a group) and two fixes: all-day values
rendering a day early west of UTC, and full-screen picker bar icons
following the XML theme rather than the running one.

The changelog had gone unwritten since 0.2.1; this backfills every entry.
2026-08-10 17:20:57 +02:00
Jean-Luc Makiola 4e0b97538a Merge pull request 'feat(components): navigation slot, full-bleed content and scrolling actions for the onboarding shell' (#4) from feat/onboarding-shell into main 2026-08-10 17:18:47 +02:00
makiolaj 1705e67eeb feat(components): let scrolling actions inherit the content inset
Pinned actions keep their own 24dp margin; scrolling ones sit inside the
content column, so the caller's inset decides how wide they run.
2026-08-10 17:17:16 +02:00
makiolaj 98dcaf09ad feat(components): let the onboarding actions scroll with the content
A step whose content is taller than the screen loses room to a pinned bar,
and the bar hides that there is more below it. scrollingActions moves the
call-to-action stack to the end of the scrolling column instead.
2026-08-10 17:17:16 +02:00
makiolaj a366ca9188 feat(components): navigation slot and full-bleed content for the onboarding shell
A step that can be stepped back from passes navigationIcon. The 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.

contentPadding lets a step built from grouped rows drop the column's own
inset, since those rows carry their own.
2026-08-10 17:17:16 +02:00
Jean-Luc Makiola 63661b9a7e Merge pull request 'feat(components): step progress for the onboarding scaffold' (#3) from feat/onboarding-progress into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/3
2026-08-10 17:13:00 +02:00
makiolaj b36c330ada feat(components): step progress for the onboarding scaffold
A multi-step flow passes a progress slot, pinned above the scrolling
content, and OnboardingProgress draws it as one capsule per step with the
caller's own localised label. topSpacing lets a step whose hero is a large
picture rather than a mark keep the content on one screen.
2026-08-08 21:01:52 +02:00
makiolaj ea860ed781 Merge feat/grouped-row-rich-text for Calendula's pin 2026-08-08 16:24:39 +02:00
makiolaj fd1f283b77 feat(components): let a grouped row carry styled text
GroupedRow took plain Strings, so a caller that needs emphasis inside the
title or summary — a search hit marking what it matched — had no way in short
of rebuilding the row. Adds an AnnotatedString overload and delegates the
String one to it.
2026-08-08 16:24:39 +02:00
makiolaj b9475688a8 Merge feat/grouped-row-long-press for Calendula's pin 2026-08-02 20:56:04 +02:00
makiolaj 3134098965 feat(components): let a grouped row take a long-press
GroupedSurface/GroupedRow gain onLongClick (with the platform long-press
haptic), for lists that enter a selection mode from a row — Calendula's
search results (#80).
2026-08-02 20:55:57 +02:00
Jean-Luc Makiola e047a2bd48 Merge pull request 'feat(components): share the undo snack chip' (#2) from feat/snack-chip into main 2026-08-02 20:45:18 +02:00
makiolaj 71a4f371b6 Merge feat/picker-richness for Calendula's pin 2026-08-02 18:27:33 +02:00
makiolaj b08fb69843 feat(components): spring the snack chip open
A slide-in is clipped to the animating bounds, so at pill size the chip
looked painted on from one edge. It now springs from its anchored corner on
the expressive fast specs, which are also quicker than the default
visibility spring.
2026-08-02 18:27:32 +02:00
makiolaj b614e427df feat(components): share the undo snack chip
Agendula's swipe-delete chip and Calendula's reschedule confirmation were
the same pill twice. Placement and timing stay with the caller; the shared
one keeps the polite live region and takes a maxWidth so the message can't
run into a bottom-end FAB.
2026-08-02 18:19:14 +02:00
Jean-Luc Makiola fc4ff6b7dc Merge pull request 'fix(core-time): render all-day values in UTC, not the device zone' (#1) from fix/all-day-utc-formatting into main
Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/1
2026-08-02 17:47:19 +02:00
makiolajandClaude Opus 5 ed1d3ca5e8 feat(components): let AboutCard join a grouped list
AboutCard drew its own Surface, so it could only stand alone. Taking a
Position and rendering through GroupedSurface lets it be the top of a group
with rows continuing underneath — for apps whose call to action reads better
as a row of its own than as a tonal button inside the card.

Defaults to Position.Alone, so existing callers are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
makiolajandClaude Opus 5 38e508b259 feat(components): export the grouped-list inset as GroupedListInset
The 16dp a grouped list keeps from the screen edge was a private literal in
GroupedRow, so everything that sits outside the cards but belongs to them —
section headers, hints, picker descriptions — had to repeat the number and
had quietly drifted to 24dp in places, leaving pages with two ragged left
margins. Exporting it gives those callers the one value to take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
makiolajandClaude Opus 5 bb3ff1f316 feat(components): give OptionPicker a per-option summary slot
A picker row often names something it doesn't spell out — a range's dates, a
reminder's firing time, what "follow the system" resolves to today. The
summary slot puts that under the label, so every option shows its effect at
once and stays comparable, without the cost of a live preview.

The KDoc now also 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
makiolaj b4d3ead8f0 Merge pull request 'fix(components): match full-screen picker bar icons to the runtime theme' (#3) from fix/picker-bar-icon-appearance into main 2026-07-24 15:24:12 +00:00
makiolajandClaude Opus 4.8 9bbef911c0 fix(components): match full-screen picker bar icons to the runtime theme
A Compose Dialog owns its own Window whose status/navigation-bar icon
appearance is seeded from the XML theme, not from the host activity's
runtime edge-to-edge state. With decorFitsSystemWindows = false the
FullScreenPicker draws under the bars, exposing icons that don't match
the active theme — dark icons on a dark picker (near-invisible), and the
mirror-image nav-bar bug in light theme.

Expose the resolved light/dark decision from FloretExpressiveTheme as
LocalFloretDarkTheme and drive the dialog window's
isAppearanceLight{Status,Navigation}Bars from it, so the appearance
tracks the in-app light/dark choice even when it diverges from the
system night resource qualifier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:11:16 +02:00
makiolajandClaude Opus 4.8 396e538903 fix(core-time): render all-day values in UTC, not the device zone
All-day items are date-only in iCalendar and are stored as UTC midnight of
their calendar date — the convention the tasks and calendar providers read
back (OpenTasks resolves them through DateTime.toAllDay(), which discards the
time and resolves the remaining date against UTC).

formatDate/formatDateTime/formatDateTimeCompact resolved every instant against
ZoneId.systemDefault(), so an all-day value rendered as the *previous* day for
anyone west of UTC: a task stored as 2026-07-20T00:00Z showed as 19 Jul in New
York. East of UTC it happened to land on the right day, which is why this went
unnoticed.

formatDate now takes an allDay flag (defaulting to false, so existing call
sites are unaffected) and all-day values resolve against UTC.

Callers must pass allDay at the call site — agendula and calendula both have
sites that need updating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:09:28 +02:00
makiolaj 75b90d1b3b Merge pull request 'feat(components): expose SelectedCheck as a family primitive' (#2) from feat/selected-check-primitive into main 2026-07-20 13:27:22 +00:00
makiolajandClaude Opus 4.8 b4954713ff feat(components): expose SelectedCheck as a family primitive
The trailing check that marks a chosen row in a full-screen picker was
private to OptionPicker, so every consumer hand-rolled its own copy.
Calendula alone carried five, and they had drifted: the kit drew
Icons.Rounded.Check, all five app copies drew Icons.Default.Check.

Make it public with KDoc so pickers share one selection affordance
instead of re-deriving it. Rounded wins, as the kit's existing choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:17:29 +02:00
makiolajandClaude Opus 4.8 df8bdbaf73 release: floret-kit 0.2.1
Adds components' `scrollable` opt-out on CollapsingScaffold (and its
FullScreenPicker passthrough), so a picker with more options than an
eagerly composed column can carry can drive its own LazyColumn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:36:44 +02:00
makiolaj 9fe655a33b Merge branch 'feat/scrollable-scaffold' into main 2026-07-17 15:36:21 +02:00
102 changed files with 5803 additions and 183 deletions
+347 -1
View File
@@ -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
View File
@@ -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.
+11 -3
View File
@@ -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
View File
@@ -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"
}
+2
View File
@@ -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)
@@ -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,
@@ -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)
+47
View File
@@ -0,0 +1,47 @@
// core-android — small Android plumbing every app in the family writes again:
// a broadcast-driven tick Flow, a coroutine `goAsync` for receivers, a
// permission check, the exact-or-inexact alarm wake-up, and the
// `stateIn(WhileSubscribed(5s))` idiom. No Compose and no DI, so only the
// Android library plugin is applied; coroutines are part of the public API.
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "de.jeanlucmakiola.floret.android"
compileSdk = 37
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
// `Flow`/`StateFlow`/`CoroutineScope` appear in the public API.
api(libs.kotlinx.coroutines.core)
implementation(libs.androidx.core.ktx)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.floret.android
import android.app.AlarmManager
import android.app.PendingIntent
import android.os.Build
/**
* True below API 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — `USE_EXACT_ALARM`, or a `SCHEDULE_EXACT_ALARM` the user hasn't
* revoked.
*/
fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* A doze-proof wake-up at [triggerAtMillis]: exact when [canScheduleExactCompat],
* otherwise the inexact allow-while-idle alarm, which needs no permission.
* Returns whether the exact alarm was set.
*/
fun AlarmManager.setWakeup(
triggerAtMillis: Long,
operation: PendingIntent,
type: Int = AlarmManager.RTC_WAKEUP,
): Boolean {
val exact = canScheduleExactCompat()
if (exact) {
setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
} else {
setAndAllowWhileIdle(type, triggerAtMillis, operation)
}
return exact
}
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.floret.android
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.onStart
/**
* Emits once on collection and again on every broadcast of [actions], through a
* not-exported receiver registered for as long as the flow is collected. Map
* each tick to a fresh read of whatever the broadcasts announce — the date, the
* zone — rather than reading the intent.
*/
fun Context.broadcastTicks(vararg actions: String): Flow<Unit> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply { actions.forEach(::addAction) }
ContextCompat.registerReceiver(this@broadcastTicks, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { unregisterReceiver(receiver) }
}.onStart { emit(Unit) }
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.floret.android
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
/**
* `stateIn(scope, WhileSubscribed(stopTimeoutMillis), initialValue)` — the
* ViewModel idiom: upstream runs while the UI collects and survives a
* configuration change inside the timeout.
*/
fun <T> Flow<T>.stateWhileSubscribed(
scope: CoroutineScope,
initialValue: T,
stopTimeoutMillis: Long = 5_000L,
): StateFlow<T> = stateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis), initialValue)
@@ -0,0 +1,9 @@
package de.jeanlucmakiola.floret.android
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
/** Whether [permission] is currently granted to this app. */
fun Context.hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.floret.android
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
/**
* Runs [block] off the main thread with the broadcast kept alive until it
* returns: `goAsync()` plus a one-off scope, finishing the pending result even if
* [block] throws. Call it from `onReceive`; the usual ~10s receiver budget still
* applies.
*/
fun BroadcastReceiver.goAsync(
context: CoroutineContext = Dispatchers.IO,
block: suspend CoroutineScope.() -> Unit,
) {
val pending = goAsync()
CoroutineScope(SupervisorJob() + context).launch {
try {
block()
} finally {
pending.finish()
}
}
}
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.floret.android
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class StateWhileSubscribedTest {
@Test
fun `starts at the initial value and follows upstream while collected`() = runTest {
val upstream = MutableStateFlow(1)
val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0)
assertThat(state.value).isEqualTo(0)
backgroundScope.launch { state.collect {} }
runCurrent()
assertThat(state.value).isEqualTo(1)
upstream.value = 2
runCurrent()
assertThat(state.value).isEqualTo(2)
}
@Test
fun `keeps upstream alive for the stop timeout after the last collector leaves`() = runTest {
val upstream = MutableStateFlow(1)
val state = upstream.stateWhileSubscribed(backgroundScope, initialValue = 0, stopTimeoutMillis = 1_000L)
val collector = backgroundScope.launch { state.collect {} }
runCurrent()
collector.cancel()
runCurrent()
assertThat(upstream.subscriptionCount.value).isEqualTo(1)
advanceTimeBy(1_001L)
runCurrent()
assertThat(upstream.subscriptionCount.value).isEqualTo(0)
}
}
@@ -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
+4 -3
View File
@@ -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)
}
}
}
+39
View File
@@ -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()
}
}
+55
View File
@@ -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
}
}
@@ -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
/**
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+65
View File
@@ -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>
@@ -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)
}
}
+28 -9
View File
@@ -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" }
Binary file not shown.
+4 -2
View File
@@ -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
Vendored
+4 -7
View File
@@ -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
View File
@@ -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%
+12
View File
@@ -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