531 lines
33 KiB
Markdown
531 lines
33 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)
|
||
|
||
✅ **M8 done.** The World clock tab is real, and **every tab now has its own
|
||
content** — no empty state is left anywhere in the app. A hero analog face shows
|
||
your home zone, and its dial morphs between a circle and a sun with the hour
|
||
there, so a glance answers "is it the middle of the night" without a line of
|
||
text saying so — the app's one deliberate `MaterialShapes` showpiece, spent on
|
||
information rather than decoration. Cities come from the device's own tzdata,
|
||
named and localised through ICU behind a seam, searched by word-prefix over
|
||
folded text so ASCII finds São Paulo; each row carries its time, its offset and
|
||
its day difference **relative to home**, all read at the instant so both sides
|
||
of a daylight-saving change are right on the day. The list reorders by drag or
|
||
by a move-up/move-down action a screen reader can reach, and the home zone is
|
||
set by hand or left following the device. **M9** (system interop — the full
|
||
`android.provider.AlarmClock` contract and its hostile-input validation) 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.
|
||
|
||
✅ 1 139 JVM tests, 171 of them new, and **still no Robolectric** — kept now
|
||
for a fourth screen, and this was the milestone most tempted to break it,
|
||
because ICU and `Canvas` both look like they need a device. They do not:
|
||
everything ICU answers is behind `ZoneNames` (faked, with per-method call
|
||
counters and a `failEverything` flag), everything the face draws is
|
||
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction — two
|
||
pure objects asserted at every minute of the day without a pixel — and
|
||
everything the list and the picker decide is `WorldClockRows`,
|
||
`ZonePickerRows`, `ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and
|
||
`WorldClocks`. Every write is a `WorldClockViewModel` method over the **real**
|
||
`WorldClockRepositoryImpl` across `FakeWorldClockDao` (so `addIfAbsent` and
|
||
`reorder`'s real transactions run) and a real DataStore under a `@TempDir`.
|
||
Three new `ArchitectureRulesTest` rules; the four new instrumentation tests
|
||
bring the compiled total to 40.
|
||
|
||
The decisions worth knowing: **there is no engine and must not be one** — a
|
||
world clock owns no AlarmManager slot, no service and no ring session, so a
|
||
fourth engine would be three empty seams and a `Mutex` guarding nothing; the
|
||
tab reads the **wall clock**, and re-reads both the instant and the device
|
||
zone on **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
|
||
free (no new receiver branch); the zone **ids** stay pure (through a new
|
||
`ZoneProvider.available()`) while only the **names** cross the ICU seam, which
|
||
is one file, `runCatching`-guarded end to end and pinned there by a build
|
||
rule; the catalog keeps only region-based ids that are **their own canonical
|
||
id** — so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
|
||
Asia/Kolkata and no table of ours can rot — unless the canonical id is not on
|
||
the device at all, since ICU and tzdata are separately updatable APEX modules
|
||
that can disagree, plus `UTC` by name; search is
|
||
**word-prefix over folded text**, so "erl" deliberately does not find Berlin;
|
||
the offset and day are computed **at the instant** from both zones' rules, the
|
||
same discipline `AlarmOccurrences` uses, so Berlin↔Sydney is ten hours in
|
||
January and eight in July; labels are **data, not strings**, so Kathmandu is
|
||
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
|
||
cadence is **200 ms** with the state truncated to the second (face) and the
|
||
minute (rows) and `distinctUntilChanged` over it; the list is capped at
|
||
**24**, with the cap spelled out on the screen once it is reached, because
|
||
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
|
||
reduce-motion snaps the morph but **never stops the second hand**,
|
||
because a clock whose second hand stops is a broken clock.
|
||
|
||
Three things outside the checklist were changed anyway, and are recorded in
|
||
`ARCHITECTURE.md`: **`ZoneProvider` gained `available()`**, because
|
||
`ZoneId.getAvailableZoneIds()` at a call site is the same ambient read
|
||
`PLAN.md` §5 bans for `now()`; **`ui/common/AlarmTimeFormatter.kt` gained
|
||
`rememberLocalTimeFormatter`** with the existing function delegating to it, so
|
||
M5's "one 12/24-hour formatter" survives a fourth caller instead of growing a
|
||
second answer; and **`ShellDefaults.EmptyTabScreen` was deleted**, its own
|
||
KDoc having said it existed only until each of M5–M8 replaced its own tab —
|
||
the world clock was its last caller.
|
||
|
||
No schema change at all (the database stays at v2; `WorldClockEntity`, its
|
||
DAO, its mapper and its repository are not edited), **no new permission and no
|
||
manifest entry**, **one** new dependency coordinate —
|
||
`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
|
||
resolved transitively, because M8 imports that package directly — and **no
|
||
floret-kit change**: the kit stays at 0.6.0, untouched, with
|
||
`ReorderableColumn`, `GroupedSurface`, `FullScreenPicker`, `InlineTextField`
|
||
and `CollapsingScaffold`'s FAB slot already covering the screen.
|
||
|
||
Knowingly open: no rename UI for `world_clocks.label` — the column is honoured
|
||
on read and M10's backup import will carry labels; no automatic "home" row
|
||
while travelling (a separate feature with its own preference, and the hero
|
||
face already follows the device); no per-city detail screen, map,
|
||
sunrise/sunset or day-length bar — `DayNight` is a stated convention for a
|
||
shape morph, not an ephemeris; no live times or long zone names in the picker;
|
||
no seeded data, so a fresh install really has an empty list; no reordering by
|
||
anything but `sort_order`; and no `MaterialShapes` on the ring screen or timer
|
||
progress, which `PLAN.md` §8 also sanctions — the build rule makes widening
|
||
that allowlist a deliberate edit. 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, M6's four and M7's four carry.
|
||
|
||
### ⬜ 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.
|