Files
clockula/docs/ROADMAP.md
T
2026-09-22 10:24:12 +02:00

457 lines
28 KiB
Markdown

# Clockula — roadmap
The **status** view, and the work queue. Design rationale for every decision here
lives in [`PLAN.md`](PLAN.md); the shape of the code as it stands will live in
[`ARCHITECTURE.md`](ARCHITECTURE.md) from M2 onward.
Status legend: ✅ done · 🚧 in progress · ⬜ not started
---
## Current state (one line)
✅ **M7 done.** The Stopwatch tab is real: start, pause and reset, a readout to
hundredths whose fraction is drawn smaller than its seconds, laps with their
split and their running total newest-first with the fastest and slowest marked,
the lap in progress as its own counting row, and a silent `specialUse`
foreground service whose Lap and Pause work with the app closed — its ticking
drawn by the system, so it costs no battery to keep accurate. A stopwatch that
meets a reboot comes back **paused at what it banked**, with every lap intact,
rather than inventing the time it lost; a clock change cannot move it at all.
M0's promise that the big-readout typography would be settled against a working
stopwatch is kept, and every readout in the app now sits in fixed columns. One
tab is still an empty state: **M8** (world clock — the zone list, ICU names,
offsets and the analog face) is next.
---
## How the loop works through this
One milestone per iteration, auto-continuing. Each iteration:
1. Pick the **first milestone not marked ✅**.
2. Implement it **completely** — every checklist item under it.
3. Build, and run the tests the milestone names. A milestone is not done until
they pass.
4. **Commit** (floret-kit submodule first where it changed, then Clockula's
pointer bump — separate commits, granular, lowercase area prefix).
5. Mark the milestone ✅ here, update **Current state**, and commit that too.
6. Continue to the next milestone.
Rules that hold across every iteration:
- **Never let a Room entity or `@Query` string leak above the data layer**
(`PLAN.md` §0).
- **Never** add a foojay toolchain resolver, anywhere, not even in a comment.
- Commit each finished unit of work; never accumulate a WIP pile.
- If a milestone turns out to be wrong, say so and amend `PLAN.md` — do not
silently build something else.
- `ARCHITECTURE.md` and `CHANGELOG.md` are updated as part of the milestone that
changes them, not in a later cleanup pass.
---
## v1 milestones
### ✅ M0 — Skeleton & pipeline
The project exists, builds, is themed, and ships itself.
- Gradle project: `settings.gradle.kts`, version catalog copied from Agendula
(drop `:provider`, add Room + KSP), AGP 9.x conventions, `vcsInfo` off on
release, no foojay resolver.
- floret-kit as a git submodule + `includeBuild`, with the gitignored
`floret-kit/local.properties` arrangement documented.
- Hilt, Compose, `MainActivity`, `FloretExpressiveTheme` reseeded to `#6B7A5C`
(`ui/theme/Color.kt` + `Theme.kt`), hand-tuned light/dark schemes.
- `core-crash` wired; `core-locale` + `res/xml/locales_config.xml`.
- Codeberg repo, canonical from the first commit. Full pipeline per `PLAN.md` §10:
`ci.yaml`, `translations.yaml`, `release.yaml` (Agendula spine + Calendula `play`
job), `renovate.yml`, issue/PR templates, F-Droid metadata, fastlane tree.
- `README.md`, `LICENSE` (MIT), `CHANGELOG.md`, `.editorconfig`, `.gitattributes`,
`.gitignore`, `docs/README.md` index.
- **Done when:** it builds, installs, shows a themed placeholder, and CI is green.
✅ `assembleDebug`, `lintDebug` and `testDebugUnitTest` all pass;
`scripts/check_reproducible_release.sh` reports every invariant holding.
Two notes for later: the store listing and `fastlane` changelogs are written
but no screenshots exist yet (M11), and `docs/ARCHITECTURE.md` is still
unwritten by design — M2 writes it, once there is an architecture to
describe.
### ✅ M1 — floret-kit: `core-prefs` + `core-di`
Pay the extraction the kit's roadmap has been waiting for a third app to trigger.
- In the kit: `core-prefs` (`ThemeMode`, `dynamicColor`, typed DataStore wrapper,
`toEnum()`) and `core-di` (`@IoDispatcher` + provider), each with tests, module
docs, `CHANGELOG` and `ROADMAP`/`ARCHITECTURE` updates.
- Clockula consumes both. Migrating Calendula and Agendula is explicitly **out of
scope** — note it in the kit's roadmap as a follow-up.
- **Done when:** the kit's tests pass, Clockula builds against the new modules,
and both repos are committed (submodule first, then the pointer bump).
✅ floret-kit 0.4.0. `core-prefs` (56 kit tests) gives the family a typed
`Pref<T>`/`PrefStore` over DataStore; reads degrade to the default instead of
throwing, and `flow()` is distinct-until-changed. The appearance key names are
frozen at what Calendula and Agendula already write, so the sibling migration —
a follow-up in the kit's roadmap, not here — needs no data migration.
`core-di` carries `hilt-compiler` itself, since an `@InstallIn` module only
reaches a consumer's component if its own library was processed; Clockula's
`assembleDebug` is the integration test for that binding. Clockula's theme now
reads the stored preference, with the DataStore built on a corruption handler
so an unreadable prefs file cannot brick startup.
### ✅ M2 — Data layer
The whole storage stack, headless. No UI.
- Room: `alarms`, `timers`, `world_clocks`, `stopwatch_laps` (`PLAN.md` §5),
schema exported and committed, DAOs.
- Domain models (plain Kotlin) + mappers. Repositories exposing Flows.
- DataStore prefs (theme, dynamic colour, defaults, stopwatch running state).
- DI modules.
- **Tests:** mappers, repository behaviour, the elapsed-realtime vs wall-clock
distinction under a simulated clock change.
- Write the first `ARCHITECTURE.md`.
- **Done when:** the schema is exported and committed, no Room type or query
string appears above `data/`, and the build, lint and unit tests pass.
✅ Four tables (`alarms`, `timers`, `world_clocks`, `stopwatch_laps`), schema
v1 exported to `app/schemas` so migrations can be tested from the beginning.
The seam that matters: the app talks to four repository interfaces speaking
domain types and Flows, and `ArchitectureRulesTest` fails the build if Room, an
entity or a query string ever appears above `data/`. Time is a parameter rather
than an ambient read — `WallClock` and `ElapsedRealtimeClock` are distinct
types, so a running timer anchors on the monotonic clock and keeps a wall-clock
value only as a post-reboot fallback, and the stopwatch keeps none at all. That
leaves one honest gap, written down rather than glossed: once a new boot's
uptime climbs past a stored anchor the reboot goes unnoticed, so closing it
needs a persisted boot id and M3's `BOOT_COMPLETED` receiver. Every
read-modify-write runs inside a DAO transaction, and the stopwatch run record
is written whole in one DataStore edit, so concurrent writers cannot swallow
each other's edits or persist a torn record. 151 JVM tests over fake DAOs and
injected fake clocks; the migration test is the only thing needing a device.
### ✅ M3 — Alarm engine
The hard part (`PLAN.md` §4), still headless apart from the ring screen's
plumbing.
- Next-fire resolution: local time-of-day + repeat mask → next instant in the
device zone; `skipNextOccurrence`; snooze.
- `AlarmScheduler` over `setAlarmClock`, single-next-alarm registration,
`USE_EXACT_ALARM` with a `SCHEDULE_EXACT_ALARM` fallback path.
- Receivers: fire, `BOOT_COMPLETED`, `TIME_SET`, `TIMEZONE_CHANGED`,
`MY_PACKAGE_REPLACED`.
- Ringing foreground service: audio focus, `USAGE_ALARM`, volume ramp, vibration,
ringtone URIs; survives process death and reboot mid-ring.
- Full-screen-intent notification + `canUseFullScreenIntent()` handling, degrading
to a high-priority heads-up notification that still rings — never to silence.
- **Tests:** DST spring-forward and fall-back, every repeat configuration, skip,
snooze-vs-next-occurrence. These are the tests the app lives or dies on.
✅ 326 JVM tests, 172 of them new. The engine is a plain Kotlin class that
reaches Android through four interfaces it does not implement, so the
behaviour the app lives or dies on is pinned without an emulator —
`ArchitectureRulesTest` fails the build if `android.*` ever appears in
`domain/`, `AlarmEngine.kt` or `system/`. Occurrences are generated by walking
local dates through `ZonedDateTime.of` rather than by adding 24 hours to an
instant, which is the whole DST story: a 02:30 alarm rings at 03:30 on a
spring-forward night instead of vanishing, and once rather than twice on a
fall-back night. Asserted for Berlin and New York and across all 128 repeat
masks. Two rules keep constant re-resolution honest: a two-minute grace window
so a late fire still counts, and a `handled_occurrence` watermark that can
suppress a re-fire but — because suppression also requires the candidate to be
at-or-before now — can never silence the future. Skip needed a watermark of its
own, since a bare flag eats a second alarm once the skipped occurrence passes.
At most one alarm rings and a newly-firing one takes over, so closing a cycle
touches the shared ring and auto-silence slots only when that alarm is the one
ringing; every write to `alarm_states` runs under one mutex, because a cold
start triggered by the fire broadcast otherwise races its own reschedule pass.
The "never to silence" chain is a testable invariant, not a hope: full-screen
intent, else a heads-up notification that still rings, else the default
ringtone, else forced vibration. Schema v2 adds `alarm_states`, and the boot id
closes M2's blind spot for running timers **and** the stopwatch — a scope move
from M7, recorded in `ARCHITECTURE.md` §5. Knowingly open: the migration and
the other 12 instrumentation tests compile but have never run on hardware, as
no device was attached; an auto-silenced alarm is silently missed, with no
missed-alarm notification until someone asks for one.
### ✅ M4 — App shell
- Navigation host, four tabs, M3 navigation bar, adaptive rail on wide layouts.
- The **live pill** (`PLAN.md` §9) — shared running-state surface, pause + stop
from any tab.
- The ring screen UI: over-lockscreen, turn-screen-on, snooze/dismiss, optional
dismiss challenge that cannot strand the user.
- Motion from the kit's `identity` module; predictive back.
✅ 480 JVM tests, 154 of them new, and still **no Robolectric** — which had to
be earned rather than kept. Everything that decides something moved out of the
composables: `ShellNavigation` (what a tab tap does to the back stack, and
that back from a tab goes to Alarms while back from Alarms leaves the app),
`LivePillSelector` (which of several running things the pill is about),
`ClockFormat`, and `ChallengeGate`. The adaptive part is
`NavigationSuiteScaffold` taking its own default — the short bar on a phone,
the wide rail on a tablet — rather than a hand-rolled width branch that would
miss the tabletop posture. The pill shows for paused and expired timers too,
not only running ones: pausing from the pill must not make the pill disappear
under the thumb that pressed it. Its value comes from the snapshot, so a timer
that reaches zero flips to "finished" before M6's expiry write lands. The
challenge's escape hatch opens on three wrong answers **or** sixty seconds of
ringing, for every challenge kind, asserted as one parameterised invariant;
snooze is never gated; a completed hold *is* the dismissal, and an
accessibility click completes the hold, because a challenge TalkBack cannot
answer is exactly the stranding the requirement forbids. floret-kit went to
0.5.0 for one change: `CollapsingScaffold`'s `onBack` is nullable, so a
top-level tab draws no back arrow that would pop nothing.
Knowingly open: M4 ships no in-app way to *start* a timer or the stopwatch, so
the live pill cannot be demonstrated by hand until M6/M7 — it is proven by its
unit tests and by an instrumentation test that seeds the repository directly,
and no debug button was added to paper over it. The nine new instrumentation
tests compile in the gate but have never run on hardware, as no device was
attached. `POST_NOTIFICATIONS` is asked for once on first launch; explaining a
denial, and the exact-alarm and full-screen-intent grant deep links, moved
wholly to M10's self-check, which is the screen with room to say why.
### ✅ M5 — Alarms screen
List, create, edit, delete, enable/disable. Time picker, repeat-day selector,
label, ringtone picker, vibrate, snooze settings, per-alarm overrides. Next-fire
line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
✅ 624 JVM tests, 144 of them new, and **still no Robolectric** — kept for a
whole *form*, which is where a screen usually reaches for one. Everything a
tap decides is a pure object (`AlarmListRows`, `NextFireFormat`,
`RepeatDaysSummary`, `AlarmDefaults`, `AlarmOverrides`, `RingtonePicker`,
`AudioSourcePolicy`, `AlarmRoutes`) or a ViewModel method tested over the
**real** `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
build fact rather than a habit.
The decisions worth knowing: the next-fire line comes from
`AlarmEngine.upcoming`, which grew a `refresh: Flow<Unit>` cadence parameter
so the countdown advances without a database write and the screen re-derives
none of the engine's time arithmetic; the list is ordered by **time of day**,
not by next fire, so rows never swap under a thumb at 07:00; every per-alarm
override is an `OptionPicker` whose first row is "App default (On)", because
the stored value is ternary and a `Switch` cannot say "I have not chosen";
silence is an explicit sentinel (`clockula://silent`) that resolves to an
empty audio-source list and therefore forces vibration, so a "silent" alarm
is still an alarm; an unreadable sound is reported, never rewritten, because
an unmounted card comes back; and every editor write is a transactional
read-modify-write, so a one-shot alarm that disabled itself underneath an open
editor is not re-enabled by the next unrelated tap.
The correctness find: disabling or deleting a **ringing** alarm used to clear
`ringingSince` without stopping the ring service — whose auto-silence backstop
then disarms itself on its own null guard — so the alarm would have sounded
until the 15-minute wake-lock timeout. Both new ViewModels now dismiss through
the engine first, conditionally, so a merely snoozed alarm keeps its snooze.
floret-kit went to **0.6.0** for one change: `CollapsingScaffold` takes a
`floatingActionButton` slot, forwarded to the underlying `Scaffold`, rather
than each app re-deriving the inset maths in a `Box`.
Knowingly open: no hand reordering of alarms (the time order answers it, and
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
chip after a delete (traded for a confirmation that names the alarm), no
repeat presets, and no per-alarm auto-silence — still M10 at the earliest.
Editing the app-wide defaults every override row offers to follow is M10's
settings screen. The seven new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached.
### ✅ M6 — Timers
Multiple concurrent timers, presets, labels, add/pause/reset/+1min. Foreground
service with notification controls, expiry ringing reusing M3's audio path.
Elapsed-realtime anchored.
✅ 843 JVM tests, 219 of them new, and **still no Robolectric** — kept for a
second screen *and* for a foreground service, which is the other place a
project usually gives in. Every decision is a pure object (`TimerReadings`,
`TimerExpiry`, `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`,
`TimerDurationEntry`, `TimerListRows`, `TimerRoutes`) or a method on the
engine or one of the two ViewModels, tested over the **real**
`TimerRepositoryImpl` and the **real** `TimerEngine`. `TimerService` holds no
policy and no state beyond one flag — even its two `delay` amounts come from
the engine, so the timings are asserted in a JVM test. Three new
`ArchitectureRulesTest` rules pin it, one of which makes a *second*
`MediaPlayer` a build failure. The four new instrumentation tests bring the
compiled total to 32.
The decisions worth knowing: the expiry slot is **one** registration on the
`ELAPSED_REALTIME_WAKEUP` base — so a `TIME_SET` cannot warp a running timer,
and a timer never populates `getNextAlarmClock()`, which belongs to the user's
next *alarm* — and its fire is an **idempotent sweep carrying no id**, so a
late delivery expires everything due, an early one writes nothing, and the
anchors *are* the watermark; the service adds a second, independent trigger
for the same sweep, because neither has to be reliable alone; one
`systemExempted` foreground service covers the countdown and the ring, alive
on the live pill's own "active, not running" predicate, and the countdown
holds **no wake lock** (the alarm slot is what wakes the device); an alarm
ringing **wins the audio** and the timer's ring is *deferred, not lost*,
because the arbitration is a pure function of current state rather than an
event — so the reverse case is symmetric and free; two timers expiring
together share one ring session and one ten-minute window, and each is stopped
on its own, because silently resetting both destroys the information "which
one finished"; auto-silence stops the noise and leaves the row EXPIRED, which
is the load-bearing difference from an alarm; presets are app-wide in
DataStore with out-of-range values **dropped, never clamped**; and the
notification's countdown is the platform chronometer, so the service posts
once per state change rather than once per second for forty-five minutes.
Two things the checklist did not name were changed anyway, and are recorded in
`ARCHITECTURE.md`: **"+1 min" on an expired timer now resumes it** with
exactly the extra, in one transaction (M2 left it paused, written before any
UI existed and pinned by no test — and resuming is the only way to avoid
emitting an intermediate PAUSED frame to the pill and the row); and **the live
pill's timer actions now go through `TimerEngine`**, because after M6 there is
an AlarmManager registration and a service that have to move with the state.
The pill's contract is otherwise unchanged: M6 *extracted* its precedence into
`TimerReadings` so the pill, the notification and the ring share one ordering,
and `LivePillSelectorTest` passing **unmodified** is the proof.
No schema change (the database stays at v2; the ring session's one volatile
fact is a DataStore record, which is `PLAN.md` §5's own record-versus-rows
split and keeps it out of M10's backup), **no new permission** (a timer never
takes over the screen — no full-screen intent, no ring activity), and **no
floret-kit change**: the kit earned `CollapsingScaffold`'s
`floatingActionButton` slot at 0.6.0 for exactly this shape of tab, and M6
consumes it.
Knowingly open: no hand reordering of timers (`sort_order` and
`TimerRepository.reorder` stay caller-less, as they do for alarms), no
per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has
one nullable settings column and four more would be a schema change), no
"stop all" when several timers are expired, no undo after a delete (traded for
a confirmation, as with alarms), no ramp on a timer's ring, no presets
management screen and no editing of `default_timer_duration_millis` or
`default_timer_ringtone_uri` — M10's settings screen. `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the
in-process hook `ShellNavigation.tabForAction` exists, wired to one action;
M9 owns the rest. The four new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached — the same caveat
M5's seven carry.
Two cases in the new suite are left **red** and are a design conversation
rather than a behaviour gap; both are defects in the test, not in the code:
`TimerIntentsTest` §5.17 #3 asserts `containsNoDuplicates` over a list that
contains the *same* `requestCodeFor(REQUEST_PAUSE, 1L)` call twice by
construction, which no deterministic function can satisfy; and `TimerPrefsTest`
§5.10 #11 opens a second DataStore over a live file in the same scope, which
DataStore refuses by design — as `SettingsPrefsTest` itself documents.
### ✅ M7 — Stopwatch
Start/stop/reset, laps with splits and cumulative times, best/worst lap emphasis.
Foreground service so it survives backgrounding; laps persist across process
death. This is where the big-readout typography gets settled for the whole app.
The post-reboot repair is **not** here — M3 took it (`ARCHITECTURE.md` §5);
M7 owns only how the paused result is presented.
✅ 968 JVM tests, 125 of them new, and **still no Robolectric** — kept now for
a third screen and a third service. Everything the tab decides is a pure
object (`StopwatchFormat`, `StopwatchReadings`, `LapStats`, `StopwatchLaps`,
`StopwatchNotificationPolicy`, `StopwatchRows`) or a method on the engine or
the ViewModel, tested over the **real** `StopwatchRepositoryImpl` across the
real `appendLap` transaction and a real DataStore under a `@TempDir`.
`StopwatchService` holds no policy and **no state at all** — not even the
timers' one `alreadyAlerted` flag, because nothing here ever alerts — and it
has no `delay`. Two new `ArchitectureRulesTest` rules and two extended; the
four new instrumentation tests bring the compiled total to 36.
The decisions worth knowing: the foreground service is **`specialUse`**, not
`systemExempted`, because a stopwatch is not alarm functionality — it
schedules nothing, wakes nothing and rings nothing, and claiming the type
would have the app tell the platform something untrue; it holds **no wake
lock, ever**, since the elapsed value is derived from a persisted monotonic
anchor at read time; the notification is the **platform chronometer counting
up**, with its base `now - elapsed` **derived at post time and never stored**,
which is how the run record keeps its "no wall-clock field whatsoever" rule;
its channel is `IMPORTANCE_LOW` and silent, because nothing here ever
finishes, so there is no alert-once rule and no session window anywhere in the
path; the readout is hundredths and it is **two strings**, so the
fastest-changing glyphs are not the loudest ones; best and worst need **three**
laps, ties go to the earliest, and all-equal means neither; laps are capped at
**999**, guarded in the engine over one `COUNT(*)` so appending never
materialises the rows; and the tab ticks at ~60 Hz **only while running**, so
a paused or idle stopwatch costs nothing.
Three things the checklist did not name were changed anyway, and are recorded
in `ARCHITECTURE.md`: **the live pill's stopwatch actions now go through
`StopwatchEngine`** (M6 recorded this as knowingly open), with the pill's own
contract unchanged and `LivePillSelectorTest` passing **unmodified** as the
proof; **`LivePillSelector` was re-pointed at a new pure `StopwatchReadings`**,
the same extraction M6 made for timers, so the pill, the shade and the tab
cannot answer "what does this read right now" three different ways; and
**`ui/theme/Type.kt` stopped being `Typography()`** — tabular figures on the
display, headline and title roles, app-wide, with no call-site change
anywhere, plus `ClockulaReadoutDefaults` naming the three roles a readout uses.
The correctness find: a run that met a reboot mid-lap had `accumulated = 0`
while its laps still recorded forty seconds, so the honest reading is
**floored at the last lap's total** as well as at zero — a readout sitting
below a lap the same run printed is not one anyone can believe, and the laps
are real measurements that are never deleted to tidy the arithmetic. Recorded
in `ARCHITECTURE.md` §5. The review found the other half of it: the floor has
to be **banked, not only displayed** — resuming writes
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
bank the same floored reading — or the record ticks from below its own last
lap, the shade's free-running chronometer counts while the tab sits frozen,
and the next lap records a cumulative below the previous one. The same review
pass moved the engine's verbs onto **the reading's** mode rather than the
stored row's, so a stale record's Resume button resumes instead of hitting a
silent no-op. `pauseAfterReboot` itself is still untouched.
No schema change (the database stays at v2; `LapDao` gains one `COUNT(*)` and
nothing else), **one** new permission — `FOREGROUND_SERVICE_SPECIAL_USE`, the
one that names what the service actually does — and **no floret-kit change**:
the kit stays at 0.6.0, untouched.
Knowingly open: no lap export, sharing, deletion or editing, and no "previous
run" history; no Lap button on the live pill (it has two controls by design);
no user-configurable lap cap or readout precision; no reordering, grouping or
filtering of laps; no countdown-to-start, split-lap alarm, sound or vibration
from the stopwatch (a build rule enforces the silence); no analog stopwatch
face and no `MaterialShapes` showpiece, which `PLAN.md` §8 reserves for the
world-clock face, the ring screen and timer progress. `StopwatchReading.anchorIsStale`
is still not surfaced, the same answer M6 gave for timers. Of `AlarmClock`'s
intent contract only the in-process hook `ShellNavigation.tabForAction` grew a
second action; M9 owns the rest. The four new instrumentation tests compile in
the gate but have **never run on hardware**, as no device was attached — the
same caveat M5's seven and M6's four carry.
### ⬜ M8 — World clock
IANA zone list with search, ICU-localised city and zone display names, offset and
day-difference relative to home, reorder, home-zone handling. The analog face
built from `MaterialShapes` / `androidx.graphics.shapes` — the app's one
deliberate showpiece.
### ⬜ M9 — System interop
The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
`SET_TIMER`, `SHOW_ALARMS`, `SHOW_TIMERS`, `DISMISS_ALARM`, `SNOOZE_ALARM`, plus
next-alarm publishing. Hostile-input validation at the intent boundary.
- **Tests:** extra validation, including malformed and out-of-range input.
### ⬜ M10 — Settings, backup, and the self-check
- Settings composed from kit components: theme, dynamic colour, language,
alarm/timer/clock defaults, about + crash reporting.
- JSON backup export/import via SAF (`PLAN.md` §7), schema documented in the repo.
- The **"why might my alarm not ring?"** self-check screen (`PLAN.md` §4) —
real device state, deep links to the exact settings pages.
- **Tests:** backup round-trip and unknown-field tolerance.
### ⬜ M11 — Release readiness
Translations wired to Weblate, fastlane metadata and screenshots, F-Droid
reproducibility guard verified against the submodule, instrumentation smoke
tests, accessibility pass (TalkBack on the ring screen especially), on-device
reliability soak across a few real nights. Then 1.0.0.
---
## Post-v1
Deferred deliberately, in rough priority order:
- **Dock / screensaver mode** — a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.
- **Glance home-screen widget** — next alarm and running timer.
- **Quick Settings tile** — timer control and next alarm from the shade.
- **Bedtime / sleep schedule** — wind-down and wake, with DND handoff. A feature
in its own right, realistically v2.
- **`core-notification` in floret-kit** — revisit once Clockula's real
notification surface is known (`PLAN.md` §3).
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`** — tracked in
the kit's roadmap, not here.
- **Wear OS companion** — unscoped.