Files
clockula/docs/ROADMAP.md
T
2026-09-11 16:05:58 +02:00

13 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)

✅ M3 done. Clockula's alarms ring. Next-fire resolution is a pure function over a repeat mask and a zone, DST-correct in both directions; skip and snooze each have a persisted watermark; the engine registers only the single next alarm via setAlarmClock, is driven by five thin receivers, and rings through a foreground service that survives process death and reboot and never degrades to silence. Schema v2 adds alarm_states, and M2's reboot blind spot is closed. M4 (the app shell — navigation host, four tabs, the live pill, and the ring screen's UI) 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.

⬜ 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.