Files
clockula/docs/ROADMAP.md
T
2026-09-12 13:08:05 +02:00

15 KiB

Clockula — roadmap

The status view, and the work queue. Design rationale for every decision here lives in PLAN.md; the shape of the code as it stands will live in 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.