Files
clockula/docs/PLAN.md
T
Jean-Luc MakiolaandClaude Opus 5 052c7b177d docs: lock the Clockula spec — PLAN, ROADMAP, docs index
Third floret after Calendula and Agendula: a Material 3 Expressive clock app
(alarms, timers, stopwatch, world clock).

The family thesis doesn't survive contact with a clock — there is no open
provider behind one — so PLAN opens by saying so plainly and relocates the
open-standards commitment to the three places it can actually live: the full
android.provider.AlarmClock intent contract, IANA tzdata, and a documented
JSON backup format.

Locked: four surfaces and no extras in v1, Room + DataStore + JSON export,
maximum alarm reliability including a self-check diagnostics screen, seed
#6B7A5C (completing the family's Calendula→Agendula colour rotation), four
tabs plus a live running-state pill, Codeberg-canonical from commit one, and
floret-kit from day one with core-prefs + core-di extracted up front.

ROADMAP carries M0–M11 as an ordered work queue with per-milestone done
criteria, written to be executed one milestone per loop iteration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L94fydiJC37LtxVusNQBDy
2026-09-11 11:27:57 +02:00

18 KiB

Clockula — implementation plan

The design decisions and their rationale — the "why". Locked unless explicitly overturned here. For the moving status view see ROADMAP.md; for the shape of the code as it actually stands, ARCHITECTURE.md (written from M2 onward).

A Material 3 Expressive clock app for Android: alarms, timers, stopwatch and world clock. Third floret of the bloom, after Calendula (calendar) and Agendula (tasks).

Identifiers: applicationId = de.jeanlucmakiola.clockula, app name Clockula, licence MIT, minSdk 29 / targetSdk 36 / compileSdk 37.


0. The thesis — and the honest asterisk

The family thesis is a nice M3-Expressive front end over open backends, no reinvented storage or sync. Calendula fronts CalendarContract; Agendula speaks the dmfs TaskContract.

Clockula cannot do that, and pretending otherwise would be a lie. There is no open provider behind a clock. Alarms, timers and laps are the app's own data, and Clockula is therefore the family's first app with its own storage by design, not as a fallback posture.

So the open-standards commitment is relocated, not dropped. Clockula honours it in three concrete places:

  1. The platform alarm contract. Clockula implements android.provider.AlarmClock in full (§6), so Assistant, automation apps and anything else that speaks the standard intent vocabulary can drive it. That contract is the open interface for clocks on Android — the same role CalendarContract plays for Calendula. Being a good citizen of it is the thesis, applied.
  2. IANA tzdata. World clocks are IANA zone IDs, not a bespoke city table (§5).
  3. Your data leaves whenever you want. A documented, versioned JSON backup format via SAF (§7). Data you can't take with you isn't really yours.

The one rule that keeps this honest

Clockula owns storage, but nothing above the data layer knows Room exists. The app talks to AlarmRepository / TimerRepository / WorldClockRepository / StopwatchRepository; only the DAOs and one data source per aggregate touch Room entities. Domain models are plain Kotlin. Never let a Room entity or a @Query string leak above the data layer — the same discipline that let Agendula swap its whole storage posture without touching the UI.


1. Locked decisions

# Decision Why
1 Four surfaces in v1: alarms, timers, stopwatch, world clock. The complete stock-clock feature set. Anything less reads as a toy next to the app it wants to replace.
2 No extras in v1. No widget, no QS tile, no screensaver, no bedtime. v1 is already large. All four land in the roadmap as post-v1 milestones. Better a tight, finished v1 than five half-surfaces.
3 Full AlarmClock intent contract + next-alarm publishing via setAlarmClock. §6. This is the thesis.
4 Room + DataStore + JSON export. Repeat rules, per-alarm overrides and lap history want a real schema. Prefs stay DataStore. Backup is an open documented format.
5 Maximum alarm reliability, including a self-check diagnostics screen. §4. An alarm that doesn't ring is a dead app. This is the whole value proposition, not a nice-to-have.
6 Seed #6B7A5C (muted sage). §8. Completes the family rotation.
7 Four tabs + a live pill. §9. Stock-familiar navigation; the pill fixes the stock clock's worst flaw.
8 Codeberg-canonical from the first commit. §10. No Gitea-canonical phase to migrate out of later.
9 floret-kit consumed from day one, and Clockula pays the core-prefs + core-di extraction up front. §3. The kit's own roadmap names a third app as the trigger. We are it.

2. What transfers from the siblings

Agendula's layering is the template. Lift verbatim or near-verbatim:

Area From Change for Clockula
Gradle setup, version catalog, settings.gradle.kts, repro rules Agendula Drop :provider; add Room + KSP
CI / release / translations / renovate pipeline Agendula spine + Calendula's play job §10
F-Droid metadata, fastlane tree, issue/PR templates Agendula Reword
core-crash wiring, core-locale + locales_config.xml Agendula Reword
Theme structure (ui/theme/Color.kt + Theme.kt over FloretExpressiveTheme) Agendula Reseed to #6B7A5C
Settings composition from GroupedSurface / GroupedRow / OptionPicker Agendula Clock-specific domain
Export-via-SAF module shape (data/export, DocumentFile) Agendula JSON instead of iCalendar
Test stack: JUnit 5 + Truth + Turbine Agendula

Deliberately not transferred: Agendula's reminder scheduler. The kit roadmap is explicit that schedulers stay app-local, and Clockula's needs (exact alarms, full-screen intents, a ringing foreground service, DST-correct recurrence) are a different animal from a due-date nudge.


3. floret-kit

Consumed as a git submodule + Gradle composite build, as the siblings do — includeBuild("floret-kit"), implementation("de.jeanlucmakiola.floret:<module>"), with a gitignored clockula/floret-kit/local.properties locally and ANDROID_HOME in CI.

Used from day one: identity (theme factory + motion + predictive back), components (GroupedSurface/GroupedRow, CollapsingScaffold, OptionPicker, OptionCard, DialogControls, AboutCard, LanguagePickerRow, pastelize), core-locale, core-crash, core-time.

Extracted by us (M1): core-prefs (ThemeMode, dynamicColor, the typed DataStore wrapper, toEnum()) and core-di (@IoDispatcher + provider). These are pure relocation with a known shape, and the kit roadmap defers them only "until a third app makes the duplication hurt". Writing them a third time is that moment. Clockula consumes the extracted modules; migrating Calendula and Agendula onto them is a separate follow-up, tracked in the kit's roadmap, not in this loop.

Deliberately deferred: core-notification. Clockula's notification needs (full-screen intents, ringing services, media-session-grade audio focus) are far heavier than either sibling's. Designing a shared API from one speculative consumer would produce a leaky abstraction. Revisit after v1, when the real surface is known.


4. The alarm engine — the hard part

Everything else in this app is a readout. This is the engineering.

Scheduling

  • An alarm stores a local time-of-day + a repeat-day set, never an absolute instant. The next fire time is resolved against the device's current zone every time it is scheduled.
  • Re-resolve on: fire, snooze, edit, enable/disable, BOOT_COMPLETED, TIME_SET, TIMEZONE_CHANGED, and MY_PACKAGE_REPLACED. DST transitions are handled by this re-resolution, not by arithmetic on stored millis. A 02:30 alarm on a spring-forward night has a defined, tested behaviour.
  • Scheduled with AlarmManager.setAlarmClock() — not setExactAndAllowWhileIdle. setAlarmClock is the only variant the system treats as a user-visible alarm: it is doze-exempt, and it populates getNextAlarmClock(), which is what draws the status-bar alarm icon and the lockscreen line. Permission is USE_EXACT_ALARM (install-granted for apps whose core function is an alarm clock) with a SCHEDULE_EXACT_ALARM fallback path.
  • Only the single next alarm is registered with AlarmManager at a time; firing re-resolves and registers the following one. Registering all of them invites drift and quota trouble.

Ringing

AlarmManagerBroadcastReceiver → foreground service (which owns audio + vibration and survives the receiver's 10-second window) → full-screen-intent notification whose content intent is the ring activity.

  • Full-screen intent on Android 14+ needs USE_FULL_SCREEN_INTENT; check canUseFullScreenIntent() and deep-link to the grant screen when denied. A denied FSI must degrade to a high-priority heads-up notification that still rings — never to silence.
  • Gradual volume ramp, per-alarm ringtone (system RingtoneManager URIs, so the user's existing sounds work), vibration pattern, AudioAttributes with USAGE_ALARM so DND's alarm exemption applies.
  • Snooze with a configurable interval and a snooze limit; dismiss; an optional dismiss challenge (maths / hold) that must never be able to strand the user.
  • The ring activity shows over the lockscreen (setShowWhenLocked, setTurnScreenOn) and holds a wake lock for the ring duration only.
  • A ringing alarm survives process death and reboot mid-ring.

Diagnosability (the self-check screen)

A "Why might my alarm not ring?" screen that reports actual device state, not generic advice: exact-alarm permission, notification permission, full-screen-intent permission, battery-optimisation exemption, DND policy access, whether the OEM has Clockula under an aggressive app-killer, and the next scheduled fire time as the system sees it (getNextAlarmClock()). Where possible each row deep-links to the exact settings page. This screen is a v1 feature, not a support afterthought.


5. Data model

Room, schema exported and version-controlled, migrations tested from v1.

  • alarms — id, hour, minute, label, enabled, repeat-day mask, ringtone URI, vibrate, snooze minutes, snooze limit, volume-ramp seconds, dismiss challenge, skipNextOccurrence, timestamps.
  • timers — id, label, duration, state (idle/running/paused/expired), the elapsed-realtime anchor it resolves against, ringtone, sort order. Multiple concurrent timers are first-class.
  • world_clocks — id, IANA zone ID, optional custom label, sort order.
  • stopwatch_laps — id, index, split, cumulative. The stopwatch's running state lives in DataStore (a single record), its laps in Room so they survive process death.

Running timers and the stopwatch anchor on SystemClock.elapsedRealtime(), never wall-clock — a user changing the time must not warp a running timer. Alarms are the opposite: wall-clock by definition. This distinction is the single easiest thing to get wrong in a clock app; it is tested both ways.

World clocks are IANA zone IDs, with display names and city labels resolved through ICU (android.icu) so they localise with the app language. No bundled city database to rot.


6. System interop — the AlarmClock contract

Clockula handles, in full:

Intent Behaviour
ACTION_SET_ALARM EXTRA_HOUR, EXTRA_MINUTES, EXTRA_MESSAGE, EXTRA_DAYS, EXTRA_RINGTONE, EXTRA_VIBRATE, EXTRA_SKIP_UI. Honours SKIP_UI only when the extras fully specify the alarm.
ACTION_SET_TIMER EXTRA_LENGTH, EXTRA_MESSAGE, EXTRA_SKIP_UI.
ACTION_SHOW_ALARMS / ACTION_SHOW_TIMERS Deep-link to the tab.
ACTION_DISMISS_ALARM / ACTION_SNOOZE_ALARM Act on the firing or next alarm, with the documented search-mode extras.

Plus getNextAlarmClock() participation via setAlarmClock, so the system status bar and lockscreen show Clockula's next alarm like any stock clock.

Every extra is untrusted input from another app. Hours, minutes, day sets, lengths and URIs are validated and clamped at the intent boundary before they reach a repository, and a malformed intent opens the editor pre-filled rather than writing anything. SKIP_UI never means "skip validation".


7. Backup format

clockula-backup-v1.json, written to a user-chosen folder via SAF (DocumentFile), mirroring Agendula's export module shape. Top-level { "format": "clockula-backup", "version": 1, "exportedAt": …, "alarms": […], "timers": […], "worldClocks": […], "settings": {…} }. The schema is documented in the repo, versioned, and import is tolerant of unknown fields and strict about known ones. Ringtone URIs are exported as-is with a documented fallback when the target device can't resolve them.


8. Identity

Seed #6B7A5C — a muted sage/olive.

The family's seeds are one colour rotated: Calendula #5C6B7A (slate blue) → Agendula #7A5C6B (warm mauve) → Clockula #6B7A5C (sage). Same three bytes, third position. The cycle closes at three, which is a constraint worth keeping rather than a limit to work around.

Structure follows the siblings: FloretExpressiveTheme from the kit's identity module, dynamic colour on by default with the seed-derived scheme as the fallback, hand-tuned light/dark ColorSchemes in ui/theme/Color.kt.

Design principles inherited from the family (non-negotiable):

  • Expressive ≠ big or bold. Refinement comes from shape, colour, space and motion — never from enlarging text or upping weight.
  • Content lives on surfaces, not as bare text on the background.
  • MaterialShapes / androidx.graphics.shapes morphs are for the genuinely clock-specific surfaces — the analog face, the ring screen, timer progress — not sprinkled everywhere.

A clock app is mostly one enormous number. That readout is where the design lives, and it is the thing to get right before anything else.


9. Navigation

Four tabs — Alarm, Timer, Stopwatch, World clock — in an M3 navigation bar, with a live pill floating above it whenever a timer or the stopwatch is running: current value plus pause and stop, reachable from any tab.

This is the one deliberate departure from the stock model, and it exists because the stock clock's worst flaw is that a running timer is invisible unless you are standing on its tab. The pill is not decoration; it is the fix.

Adaptive: navigation rail on wide layouts, per M3 adaptive guidance.


10. Repository & pipeline

Codeberg is canonical from the first commit — git, issues, PRs and releases. The self-hosted Gitea instance holds the secrets and runs the release job, and push-mirrors from Codeberg. There is no Gitea-canonical phase to migrate out of, which is the mistake Agendula had to unwind.

The pipeline is copied wholesale from the siblings:

  • .forgejo/workflows/ci.yaml — contributor-facing, no secrets. Copy Agendula's, adjusting SKIP_RE.
  • .forgejo/workflows/translations.yaml — identical in both siblings; copy as-is.
  • .gitea/workflows/release.yamlAgendula's spine (Codeberg-canonical: pushes the tag to Codeberg itself, pre-1.0 prerelease flag, and the Codeberg publish step is not continue-on-error, after five Calendula-era releases reported green while never publishing) plus Calendula's play job grafted on unchanged — last, isolated, continue-on-error, skipping cleanly until PLAY_SERVICE_ACCOUNT_JSON exists. It therefore stays dormant through the pre-1.0 run, which is the correct behaviour anyway.
  • .gitea/workflows/renovate.yml — Agendula's, including the github.repository_owner == 'makiolaj' guard.
  • Issue templates, PR template, F-Droid metadata, fastlane tree.

Reproducibility rules carry over verbatim and are load-bearing: no foojay toolchain resolver anywhere (not even in a comment), vcsInfo { include = false } on release, submodules: recursive in checkout, and the repro guard scanning the floret-kit submodule's Gradle scripts.


11. ClockMaster — how we use it, and the licence line

PranshulGG/ClockMaster is an archived, unmaintained Apache-2.0 Material Expressive clock app. It is a useful reference for the parts of an alarm app that are tedious to rediscover.

Apache-2.0 code cannot be relicensed to MIT. So:

  • Default posture: read it, don't copy it. Understanding how it schedules and how its services are wired is free; retyping our own implementation from that understanding keeps Clockula cleanly MIT.
  • Where we genuinely lift code — realistically only intricate scheduling or service-lifecycle edge cases — that file keeps its Apache-2.0 header and attribution, is listed in docs/PROVENANCE.md, and the Apache text ships in licenses/. This is exactly the pattern Agendula already uses for the vendored dmfs provider, so it is a known, working arrangement rather than a new risk.
  • Every such decision is made consciously and recorded, never by drifting into a copy-paste.

Worth studying: helpers/alarmHelper.kt (scheduling), receiver/alarmReceiver.kt, services/alarmService.kt + TimerAlarmService.kt (ring/foreground lifecycle), helpers/alarmSoundPicker.kt (RingtoneManager plumbing), the timezone DAO/repository, and the Room entity shapes as a sanity check on §5.

Deliberately not taken:

  • Its always-on foreground service (AlarmAlwaysForegroundService). Keeping a permanent service alive to make alarms reliable is a battery-cost workaround; setAlarmClock is the supported mechanism and does not need it.
  • Its Pomodoro feature — out of scope.
  • Its settings/tile component vocabulary (ui/components/tiles/*, SettingTile) — floret-kit's GroupedRow / OptionCard / OptionPicker are our equivalents and keep the family coherent.
  • MaterialKolor + a colour-picker dependency — the kit's identity module owns theming.
  • Its no-Hilt, manual-ViewModel structure and minSdk 24.

For interaction detail — snooze behaviour, the ring screen, timer presentation, what the stopwatch does with laps — Google Clock is the reference, not ClockMaster.


12. Testing posture

JUnit 5 + Truth + Turbine, matching Agendula. Non-negotiable unit coverage:

  • Next-fire resolution across DST spring-forward and fall-back, every repeat-day configuration, skipNextOccurrence, and snooze interaction with the next occurrence.
  • Elapsed-realtime vs wall-clock behaviour for timers and the stopwatch under a simulated user clock change.
  • Intent-extra validation at the AlarmClock boundary, including hostile input.
  • Backup round-trip: export → import → identical state, plus forward-compatible handling of unknown fields.
  • Room migrations, from v1 onward.