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
355 lines
18 KiB
Markdown
355 lines
18 KiB
Markdown
# Clockula — implementation plan
|
|
|
|
> The **design decisions and their rationale** — the "why". Locked unless
|
|
> explicitly overturned here. For the moving status view see
|
|
> [`ROADMAP.md`](ROADMAP.md); for the shape of the code as it actually stands,
|
|
> [`ARCHITECTURE.md`](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](https://codeberg.org/jlmakiola/calendula) (calendar) and
|
|
[Agendula](https://codeberg.org/jlmakiola/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
|
|
|
|
`AlarmManager` → `BroadcastReceiver` → 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 `ColorScheme`s 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.yaml` — **Agendula'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](https://github.com/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.
|