Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
265 lines
15 KiB
Markdown
265 lines
15 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)
|
|
|
|
✅ **M4 done.** Clockula has a face. Four tabs under an M3 Expressive navigation
|
|
bar that becomes a wide rail on a tablet, with the back-stack policy and the
|
|
live pill's whole decision written as pure functions rather than as composable
|
|
state; the ring screen is real, with a maths or hold challenge that always has a
|
|
way out — three wrong answers or sixty seconds, whichever comes first. The tabs
|
|
themselves are still empty states: **M5** (the alarms screen — list, editor,
|
|
time picker, repeat days, per-alarm overrides) 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`.
|
|
|
|
### ⬜ 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.
|
|
|
|
### ⬜ 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.
|
|
|
|
### ⬜ 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.
|