Removes AI-writing patterns across the docs, store listing and templates: dash-heavy rhythm, not-X-but-Y contrasts, one-line kickers and decorative bold. Facts, code, links and headings are unchanged. Also fixes leftovers copied from the sibling apps: the crash report template promised "no calendar content", and CONTRIBUTING described Agendula's task-provider layout, test seams and commit style.
621 lines
38 KiB
Markdown
621 lines
38 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)
|
||
|
||
✅ **M10 done.** Clockula now has a settings hub (theme, dynamic colour,
|
||
language, the alarm/timer/clock defaults every new alarm or timer inherits,
|
||
help), a documented, versioned JSON backup (`docs/BACKUP.md`) that exports and
|
||
restores alarms/timers/world clocks and settings through SAF (configuration
|
||
only, never ring state, never a timer's running anchors) as a whole-state
|
||
replace the screen is explicit about before the user confirms, a "why might my
|
||
alarm not ring?" self-check that reads real device state (exact-alarm and
|
||
notification permissions, full-screen intent, battery optimisation, DND, alarm
|
||
volume, a known aggressive-OEM allowlist, and the system's own next alarm
|
||
compared against what Clockula computed) and deep-links to the exact settings
|
||
page for each, and three Glance home-screen widgets (next alarm, timer,
|
||
clock) that mirror existing state and redraw event-driven, on every write,
|
||
with no ticker of their own. M11 (release readiness) 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 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
|
||
gap: 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 can never silence the future, because suppression also
|
||
requires the candidate to be at-or-before now. 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: 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. Keeping it that
|
||
way meant moving everything that decides something 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, 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; 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; M5's seven carry the
|
||
same caveat.
|
||
|
||
Two cases in the new suite are left red. Both are defects in the test, not in
|
||
the code, so they are a design conversation rather than a behaviour gap:
|
||
`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), and
|
||
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 as well as displayed. Resuming writes
|
||
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
|
||
bank the same floored reading; otherwise 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; M5's
|
||
seven and M6's four carry the same caveat.
|
||
|
||
### ✅ 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` is the app's one
|
||
deliberate showpiece.
|
||
|
||
✅ 1 139 JVM tests, 171 of them new, and still no Robolectric, kept now
|
||
for a fourth screen. 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, because 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;
|
||
M5's seven, M6's four and M7's four carry the same caveat.
|
||
|
||
### ✅ 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.
|
||
|
||
✅ 1 420 JVM tests, 281 of them new, and still no Robolectric, kept now
|
||
for a *boundary*, which is the last place a project usually gives in, because
|
||
"it is an `Intent`" sounds like it needs a device. It does not: the door is
|
||
split so that everything which decides is pure (`AlarmClockRequests`,
|
||
`AlarmMatching`, `IntentExtras`, `InteropText`, `InteropDeepLinks`) and the
|
||
Android half is two policy-free files: a `Bundle` reader and an `Intent`
|
||
builder. `AlarmClockHandler` orchestrates with no `android.*` import, as
|
||
all three engines do, and is tested over both real engines through a new
|
||
`InteropHarness`. It is deliberately not a composition of the existing
|
||
harnesses, since each builds a DataStore over the same file name and DataStore
|
||
refuses a second instance over a live one. Four new `ArchitectureRulesTest`
|
||
rules and a new `ManifestRulesTest`, because the manifest is where this
|
||
milestone could be silently wrong and none of it fails a compiler. The five
|
||
new instrumentation tests bring the compiled total to 45.
|
||
|
||
`ACTION_DISMISS_TIMER`, the seventh action, named neither in this checklist
|
||
nor in `PLAN.md` §6's table, was built anyway: "the full contract" is the
|
||
milestone's own first sentence, and shipping six of seven and calling it full
|
||
would be the kind of quiet gap this loop exists to avoid.
|
||
|
||
The decisions worth knowing: one range rule settles every extra. A value
|
||
that decides when something will ring in the *future* is dropped out of
|
||
range, and a value that modifies what the user is doing *now* is clamped, so
|
||
hour 25 never becomes an enabled 23:00 alarm while a 1 000-minute snooze
|
||
becomes the 60 minutes `ClockPrefs` would have allowed; a `Bundle` is
|
||
modelled honestly as `Map<String, Any?>` with four *total* accessors,
|
||
because hostile input is an `Int` where a `String` was documented far more
|
||
often than it is an out-of-range number, and the platform's typed getters
|
||
cannot tell "absent" from "wrong type"; `SKIP_UI` never means skip
|
||
validation, so an incomplete `SET_ALARM` creates the row from the extras that
|
||
*were* valid, at the next whole hour, and lands the user in its editor, since
|
||
M5 locked that the editor edits a persisted row and nothing is ever written
|
||
and hidden; `ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest", and
|
||
an ambiguous 0..12 hour with no `IS_PM` carries *both* readings as candidates
|
||
so the question only reaches the user when the data cannot settle it;
|
||
`ALARM_SEARCH_MODE_ALL` never asks (taken literally, the contract's "show
|
||
the results" would make the mode useless), while every other search with two
|
||
or more matches opens the milestone's one new surface, an M3 basic dialog with
|
||
a `ListItem` list where tapping a row *is* the answer.
|
||
|
||
A correctness find, fixed here: the `AlarmClockInfo` `showIntent` (the
|
||
thing the system opens when the user taps the status-bar alarm icon or the
|
||
lockscreen's next-alarm line) pointed at the *ring screen* with
|
||
`CLEAR_TASK`, so tapping "my next alarm" opened a ring screen for an alarm
|
||
that was not ringing, which resolved to `Finished` and closed itself. It now
|
||
opens `MainActivity` on the Alarms tab, and a build rule fails the build if
|
||
`AndroidAlarmScheduler` ever names `AlarmRingActivity` again. That intent is
|
||
what "next-alarm publishing" means to a user.
|
||
|
||
The schema went to v3: `delete_after_use` on `alarms` and on `timers`, two
|
||
`ALTER TABLE`s in `MIGRATION_2_3`, `3.json` exported and committed, `1.json`
|
||
and `2.json` untouched. The contract says in two places that a `SKIP_UI`
|
||
alarm or timer should be removed once dismissed, and without the column a
|
||
daily "wake me at 6:30" turns the Alarms tab into a graveyard.
|
||
The flag is set only on the `SKIP_UI` create path and honoured inside the two
|
||
engines, under their locks.
|
||
|
||
Four things outside the checklist were changed anyway, and are recorded in
|
||
`ARCHITECTURE.md`: `AlarmEngine` gained `dismissUpcoming(id)` and a
|
||
`minutesOverride` on `snooze`, because both move the ring slot, the
|
||
auto-silence backstop and the next-alarm registration, and a handler calling
|
||
three repositories in a row would race a fire broadcast; `TimerEngine` gained
|
||
`dismissAllExpired()` and `dismissExpired(id)` (the first is the "stop all"
|
||
the UI still deliberately does not offer, because that rule was about a thumb
|
||
on a ring and a programmatic request has stated exactly what it is discarding);
|
||
`domain/text/TextFolding` was extracted and `ZoneSearch` delegates to it,
|
||
with `ZoneSearchTest` passing unmodified as the proof; and the shell's
|
||
`openTab` became `openRequest: ShellRequest?`, since three of the contract's
|
||
outcomes are not "select a tab".
|
||
|
||
No new `<uses-permission>`: the one new permission string is
|
||
`android:permission` *on the door*, a requirement on the caller. No new
|
||
dependency coordinate and no floret-kit change; the kit stays at 0.6.0,
|
||
untouched, because the one new surface is material3's own `AlertDialog`.
|
||
|
||
Knowingly open: no voice-interaction follow-on flows
|
||
(`VoiceInteractor.CompleteVoiceRequest`/`PickOptionRequest`), so
|
||
`android.intent.category.VOICE` is deliberately not declared and Clockula
|
||
accepts a `clockula://` deeplink while publishing none; no "most closely
|
||
matched" time search; no toast on a `SKIP_UI` write (the status-bar icon is
|
||
the platform's own receipt); no `delete_after_use` surface in either editor, so
|
||
an assistant-set alarm the user edits stays transient, as it does in Google
|
||
Clock; no `EXTRA_MESSAGE` carried into the timer setup panel when no length
|
||
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less, since it
|
||
belongs to M10's self-check screen. The five new instrumentation tests and the
|
||
two new migration cases compile in the gate but have never run on
|
||
hardware, as no device was attached; M5's seven, M6's four, M7's four and
|
||
M8's four carry the same caveat.
|
||
|
||
### ✅ M10 — Settings, backup, the self-check, and widgets
|
||
- 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
|
||
`docs/BACKUP.md`.
|
||
- The "why might my alarm not ring?" self-check screen (`PLAN.md` §4):
|
||
real device state, deep links to the exact settings pages.
|
||
- Three Glance home-screen widgets: next alarm, timer, clock (`PLAN.md` §9).
|
||
- **Tests:** backup round-trip and unknown-field tolerance; self-check rule
|
||
logic per permission/state combination; widget model selection/formatting.
|
||
|
||
### ⬜ 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.
|
||
- **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.
|