docs: plain-language pass over every doc

Removes AI-writing patterns across the docs, store listing and
templates: dash-heavy rhythm, not-X-but-Y contrasts, one-line kickers
and decorative bold. Facts, code, links and headings are unchanged.

Also fixes leftovers copied from the sibling apps: the crash report
template promised "no calendar content", and CONTRIBUTING described
Agendula's task-provider layout, test seams and commit style.
This commit is contained in:
makiolaj committed 2026-10-06 19:14:11 +02:00
1 parent 881f6b1286
commit e870918a03
11 files changed
+697 -697

No files matched your search

+7 -8
View File
@@ -6,8 +6,8 @@ contact_links:
- name: Translate Clockula
url: https://weblate.dev.jeanlucmakiola.de/engage/clockula/
about: >-
Translations are managed on Weblate, not here — it owns every values-*
file, so a hand-edited translation gets overwritten on the next sync.
Translations are managed on Weblate, which owns every values-* file, so
a translation edited by hand here gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide
@@ -20,13 +20,12 @@ contact_links:
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/README.md
about: >-
Open the app's own self-check first (Settings → Why might my alarm not
ring?). It reports what your device is actually doing — battery
optimisation, notification and full-screen-intent permissions, the next
alarm as the system sees it — and that report is what makes such an issue
actionable.
ring?). It reports what your device is doing (battery optimisation,
notification and full-screen-intent permissions, the next alarm as the
system sees it). Include that report so the issue can be acted on.
- name: Scope and roadmap
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/docs/ROADMAP.md
about: >-
What's built, what's planned, and what's deliberately out of scope —
check here before requesting a feature.
What's built, what's planned, and what's deliberately out of scope.
Check here before requesting a feature.
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: Crash report
about: Report a crash. Clockula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
about: Report a crash. Clockula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
@@ -11,10 +11,10 @@ labels:
<!--
Thanks for reporting a crash in Clockula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
If the app prefilled this for you, the crash report is already below. Add what
you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
stack trace, with no personal data and none of your alarms, timers or clocks.
-->
### What happened
+1 -1
View File
@@ -9,7 +9,7 @@ labels:
### What would you like Clockula to do?
### Why — what problem does it solve?
### What problem would it solve?
### Anything else
+3 -3
View File
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/clockula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong:
• The one architectural rule — Room entities, DAOs and `@Query` strings never
• The one architectural rule: Room entities, DAOs and `@Query` strings never
leak above the data layer. Everything else talks to a repository.
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
cuts a release, so it belongs only in a release PR.
@@ -16,7 +16,7 @@ Two things it's easy to get wrong:
### Why
<!-- Closes #123 — link the issue this implements or fixes. -->
<!-- Link the issue this implements or fixes, e.g. Closes #123. -->
### How it was tested
@@ -24,7 +24,7 @@ Two things it's easy to get wrong:
<!--
Which of these ran green, and anything you exercised by hand. On-device notes
are especially useful for UI changes, and essential for anything touching the
alarm engine — an alarm that doesn't ring is the one bug that matters most, and
alarm engine. An alarm that doesn't ring is the one bug that matters most, and
it only ever reproduces on a real device overnight.
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
+27 -24
View File
@@ -1,8 +1,8 @@
# Contributing to Clockula
Thanks for your interest in Clockula — a Material 3 Expressive clock app:
Thanks for your interest in Clockula, a Material 3 Expressive clock app for
alarms, timers, stopwatch and world clock. Unlike its siblings it owns its
storage, because there is no open provider behind a clock. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
storage, because there is no open provider behind a clock. Before you start, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
practical how.
@@ -15,14 +15,14 @@ layer.** Clockula owns its storage, which makes this rule more important here
than in the siblings, not less: it is the only thing keeping the schema a
private implementation detail rather than the app's de-facto domain model. If a
change would expose a Room type to a ViewModel or the UI, it's in the wrong
layer — see [`docs/PLAN.md`](docs/PLAN.md) §0.
layer; see [`docs/PLAN.md`](docs/PLAN.md) §0.
## Prerequisites
- JDK 17
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
- A real device for anything touching the alarm engine. An emulator will not
tell you whether an alarm survives doze, a reboot, or an OEM battery-saver —
tell you whether an alarm survives doze, a reboot, or an OEM battery-saver,
and that is the only question that matters about an alarm.
## Build, test, lint
@@ -43,11 +43,11 @@ before opening a PR. Keep CI green.
owned by a self-hosted Weblate that writes to this repository directly, and a
hand-edit is overwritten on the next sync.
→ **[Translate Clockula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)**
**[Translate Clockula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and
fine — missing keys are informational. Stale and orphaned keys are not, so run
fine; missing keys are informational. Stale and orphaned keys are not, so run
```sh
python3 scripts/check_translations.py
@@ -57,64 +57,67 @@ before pushing. It reports those more clearly than lint's `MissingTranslation`
does, and it's what the `Translations` check runs on every PR.
A new language also needs one `<locale>` line in
`app/src/main/res/xml/locales_config.xml` — that file is the single source of
`app/src/main/res/xml/locales_config.xml`. That file is the single source of
truth for both the in-app picker and the Android 13+ per-app language setting.
## Where to put code
| Layer | Lives in | Rule of thumb |
|---|---|---|
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
| Pure logic (models, scheduling and ring policies, formatting, the self-check rules) | `domain/` | No Android imports; must be JVM-unit-testable. |
| Storage | `data/` (`db/`, `alarms/`, `timers/`, `stopwatch/`, `worldclocks/`, `prefs/`, `backup/`) | The only place that knows Room or DataStore exists. Everything above it goes through a repository interface. |
| Engines and their Android seams | `alarm/`, `timer/`, `stopwatch/`, `ring/`, `interop/`, `widget/` | The engine is plain Kotlin; AlarmManager, services and receivers sit behind seams in its `android/`, `receiver/` and `service/` subpackages. |
| Screens | `ui/<area>/` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §3 lists every package.
## Code style & conventions
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
whitespace, all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
Match the surrounding code.
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
colour-scheme tokens (never hardcoded colours), and canonical M3 components
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
new screen or component.
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
the only Android-coupled data implementation so the rest stays JVM-testable.
- Prefer the domain layer for anything testable; keep Android APIs behind the
seams in each engine's `android/` subpackage so the rest stays JVM-testable.
## Tests
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
come with JVM unit tests under `app/src/test/`. The data source is the
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
- Add an instrumented test only when a path genuinely needs a real
`ContentResolver`.
- New domain logic (scheduling, ring policies, formatting, mappers) **must**
come with JVM unit tests under `app/src/test/`. Repositories and engine seams
are the JVM-testable boundary: fake them rather than reaching for
instrumentation.
- Add an instrumented test only when a path genuinely needs a real device
(Room migrations, AlarmManager, a foreground service).
## Commits & PRs
- Write focused commits with clear messages (the existing history uses short,
scoped subjects like `UI: ...` / `M1 ...`).
conventional subjects like `feat(widgets): ...` / `fix(ringtones): ...`).
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
user-visible change — its sections feed the release notes and F-Droid "What's
user-visible change. Its sections feed the release notes and F-Droid "What's
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
- If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
- Don't bump `versionName` / `versionCode` in a regular PR. The committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope
v1 is four surfaces — alarms, timers, stopwatch, world clock — done properly,
v1 is four surfaces (alarms, timers, stopwatch, world clock) done properly,
and nothing else. Widgets, a Quick Settings tile, screensaver mode and a bedtime
schedule are all wanted, and all deliberately deferred until v1 is finished; see
[`docs/ROADMAP.md`](docs/ROADMAP.md).
Two things are out of scope by design. **A sync stack of our own** — your alarms
Two things are out of scope by design. **A sync stack of our own**: your alarms
export to a documented JSON file, and syncing them is a job for something that
already does syncing well. **Anything that makes an alarm less reliable in
exchange for a feature** — if a change trades away the certainty that an alarm
exchange for a feature**: if a change trades away the certainty that an alarm
rings, it loses, however nice the feature is.
## License
+393 -395
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -1,6 +1,6 @@
# Clockula backup format
`clockula-backup-v1.json` — a documented, versioned JSON export of Clockula's
`clockula-backup-v1.json` is a documented, versioned JSON export of Clockula's
own data, written to (and read from) a user-chosen folder via SAF
(`docs/PLAN.md` §7). This file is the schema's source of truth; the codec
(`data/backup/BackupDto.kt`, `BackupCodec.kt`) implements exactly what is
@@ -20,18 +20,18 @@ written here.
}
```
- `format` — must be exactly `"clockula-backup"`.
- `version` — must be exactly `1`. Any other value rejects the whole file:
- `format`: must be exactly `"clockula-backup"`.
- `version`: must be exactly `1`. Any other value rejects the whole file:
Clockula does not guess at a schema it has not implemented.
- `exportedAt` — epoch milliseconds, informational only (not re-imported).
- `exportedAt`: epoch milliseconds, informational only (not re-imported).
## Parsing rules
- **Tolerant of unknown fields, at any level.** An older or newer client's
extra field is ignored, never an error.
- **Strict about known fields.** A malformed value in a field this schema
defines — `format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis` — rejects the **whole file** with a
defines (`format`, `version`, an alarm's `hour`/`minute`, a world clock's
`zone_id`, a timer's `duration_millis`) rejects the **whole file** with a
specific reason. Nothing is imported half-way.
- Settings values (snooze minutes, volume ramp, …) are
**not** range-checked at parse time; they pass through the same clamps
@@ -41,19 +41,19 @@ written here.
- An enum field with a name this version does not recognise (a `dismiss_
challenge` or `theme_mode` from a future version, say) degrades to that
field's default, the same forgiving-read posture `AlarmMapper` already uses
for a corrupt Room row — it does not fail the import.
for a corrupt Room row. It does not fail the import.
## Import semantics
Import **replaces all state** — alarms, timers and world clocks are deleted
Import **replaces all state**: alarms, timers and world clocks are deleted
and re-created from the file, and every setting the file carries is written
over the current value. This is not a merge, and the backup screen says so
before the user confirms. A ringing alarm is dismissed and any running timer
is deleted *before* the replace, so the import never leaves an orphaned ring
session; alarm/timer/stopwatch scheduling is re-resolved immediately after.
Every imported alarm and timer gets a **fresh id and fresh timestamps** —
the backup carries no id and no `created_at`/`updated_at`, since those are
Every imported alarm and timer gets a **fresh id and fresh timestamps**.
The backup carries no id and no `created_at`/`updated_at`, since those are
local-database facts, not portable ones.
## Alarms
@@ -77,7 +77,7 @@ One object per row of `alarms`, config only:
| `delete_after_use` | Boolean | A transient, contract-created alarm. |
**Not exported:** the `alarm_states` ring-state table (snooze/ringing/handled-
occurrence bookkeeping) — it is volatile, per-device state, not configuration,
occurrence bookkeeping). It is volatile, per-device state, not configuration,
and `docs/ARCHITECTURE.md` §4/§6 are explicit that it must never appear here.
## Timers
@@ -93,7 +93,7 @@ One object per row of `timers`, config only:
| `delete_after_use` | Boolean | |
**Not exported:** `state`, `remaining`, the elapsed-realtime anchors, or
`ends_at_wall_clock` — a timer's running state is meaningless on another
`ends_at_wall_clock`. A timer's running state is meaningless on another
device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
`IDLE`, with `remaining` equal to `duration`.
@@ -128,12 +128,12 @@ files may also carry `default_timer_duration_millis`, from a default timer
length setting that has since been removed; it is ignored on import like any
other unknown field. Every
numeric/duration value is clamped on *apply*, through the existing pref
setters — never on parse.
setters, never on parse.
## Ringtone URIs
Exported exactly as stored (a `content://` URI, typically). On import the
URI is written back unchanged; if the *importing* device cannot resolve it
at ring/preview time, the existing ringtone-resolution fallback already used
elsewhere in the app applies (falls through to the device default) — this is
elsewhere in the app applies (falls through to the device default). This is
not special-cased in the backup path itself.
+34 -34
View File
@@ -1,6 +1,6 @@
# Clockula — implementation plan
> The **design decisions and their rationale** — the "why". Locked unless
> 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).
@@ -32,7 +32,7 @@ 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.
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
@@ -44,7 +44,7 @@ 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
`@Query` string leak above the data layer**. That is the same discipline that let
Agendula swap its whole storage posture without touching the UI.
---
@@ -89,7 +89,7 @@ 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 —
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.
@@ -128,7 +128,7 @@ Everything else in this app is a readout. This is the engineering.
`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`.
- 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
@@ -147,7 +147,7 @@ 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.
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.
@@ -162,7 +162,7 @@ notification whose content intent is the ring activity.
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, **whether DND currently blocks alarms**
(read via `NotificationManager.getCurrentInterruptionFilter()` — Clockula never
(read via `NotificationManager.getCurrentInterruptionFilter()`; Clockula never
requests `ACCESS_NOTIFICATION_POLICY` or changes DND itself, it only reports what the
user already has), whether the OEM has Clockula under an aggressive app-killer, and
the next scheduled fire time as the system sees it (`getNextAlarmClock()`), compared
@@ -175,19 +175,19 @@ the exact settings page. This screen is a v1 feature, not a support afterthought
Room, schema exported and version-controlled, migrations tested from v1.
- **`alarms`** — id, hour, minute, label, enabled, repeat-day mask, ringtone URI,
- **`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
- **`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
- **`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
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.
@@ -226,12 +226,12 @@ than writing anything. `SKIP_UI` never means "skip validation".
"timers": […], "worldClocks": […], "settings": {…} }`. The schema is **documented
in the repo** (`docs/BACKUP.md`), versioned, and import is tolerant of unknown
fields (ignored, at any level) and strict about known ones (a malformed known
field rejects the whole file — nothing is imported half-way). Ringtone URIs are
field rejects the whole file, so nothing is imported half-way). Ringtone URIs are
exported as-is with a documented fallback when the target device can't resolve
them.
Import is **whole-state replacement**, not a merge: the user is shown exactly
that before confirming. Alarm and timer records carry only configuration — ring
that before confirming. Alarm and timer records carry only configuration. Ring
state (`alarm_states`, a timer's running anchors) is never exported and never
touched by an import; an imported timer always lands `IDLE`. Record field names
mirror the Room column vocabulary (§5), so the schema, the exported Room schema
@@ -241,7 +241,7 @@ and the backup all read the same words.
## 8. Identity
Seed **`#6B7A5C`** — a muted sage/olive.
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,
@@ -254,10 +254,10 @@ 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.
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 —
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
@@ -267,24 +267,24 @@ 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
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.
standing on its tab.
Adaptive: navigation rail on wide layouts, per M3 adaptive guidance.
### Home-screen widgets (M10)
Three Jetpack Glance app widgets, each a read-only mirror of state that already
exists — no widget owns data: **next alarm** (day, time, label), **timer**
exists (no widget owns data): **next alarm** (day, time, label), **timer**
(the live pill's subject timer, same selection logic, with pause/resume/reset),
and **clock** (local time, growing to world clocks at larger sizes). Tapping a
widget opens the matching tab through the same `MainActivity` intents the
`AlarmClock` contract (§6) already uses — a widget is just another caller of
`AlarmClock` contract (§6) already uses. A widget is just another caller of
that door, not a second entry point. Refresh is event-driven (`updatePeriodMillis
= 0`): widgets update when the state they mirror changes, not on a timer.
@@ -292,24 +292,24 @@ that door, not a second entry point. Refresh is event-driven (`updatePeriodMilli
## 10. Repository & pipeline
**Codeberg is canonical from the first commit** — git, issues, PRs and releases.
**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,
- `.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
- `.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
unchanged. It runs last, isolated and `continue-on-error`, and skips 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
- `.gitea/workflows/renovate.yml`: Agendula's, including the
`github.repository_owner == 'makiolaj'` guard.
- Issue templates, PR template, F-Droid metadata, fastlane tree.
@@ -331,8 +331,8 @@ useful reference for the parts of an alarm app that are tedious to rediscover.
- **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
- **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.
@@ -348,16 +348,16 @@ DAO/repository, and the Room entity shapes as a sanity check on §5.
- 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 **Pomodoro** feature, which is out of scope.
- Its **settings/tile component vocabulary** (`ui/components/tiles/*`,
`SettingTile`) — floret-kit's `GroupedRow` / `OptionCard` / `OptionPicker` are
`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
- **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
For interaction detail (snooze behaviour, the ring screen, timer presentation,
what the stopwatch does with laps), **Google Clock is the reference**, not
ClockMaster.
---
+4 -4
View File
@@ -1,12 +1,12 @@
# Clockula — documentation
Clockula is a Material 3 Expressive **clock** app for Android — alarms, timers,
Clockula is 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). See the top-level
[`../README.md`](../README.md) for the pitch.
Unlike its siblings, Clockula owns its storage — there is no open provider behind
Unlike its siblings, Clockula owns its storage, because there is no open provider behind
a clock. What that costs and how the open-standards commitment is honoured anyway
is the first section of [`PLAN.md`](PLAN.md).
@@ -15,8 +15,8 @@ is the first section of [`PLAN.md`](PLAN.md).
| Doc | What it covers |
|---|---|
| [`PLAN.md`](PLAN.md) | **The spec.** Thesis, locked decisions, the alarm engine, data model, system interop, identity, pipeline, and how ClockMaster is used. The "why". |
| [`ROADMAP.md`](ROADMAP.md) | **Status and the work queue** — milestones M0–M11 and post-v1. What the build loop works through. |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Clockula is built **today** — layers, the data seam, the alarm engine, DI, build/tooling, manifest. Written from M2 onward. |
| [`ROADMAP.md`](ROADMAP.md) | **Status and the work queue**: milestones M0–M11 and post-v1. What the build loop works through. |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Clockula is built **today**: layers, the data seam, the alarm engine, DI, build/tooling, manifest. Written from M2 onward. |
| [`RELEASING.md`](RELEASING.md) | How to cut a release. Written at M0 alongside the pipeline. |
| [`PROVENANCE.md`](PROVENANCE.md) | Any third-party code carried in-tree and under what licence. Created only if we actually lift something (`PLAN.md` §11). |
+207 -207
View File
@@ -1,6 +1,6 @@
# Clockula — roadmap
The **status** view, and the work queue. Design rationale for every decision here
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.
@@ -13,16 +13,16 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
✅ **M10 done.** Clockula now has a settings hub (theme, dynamic colour,
language, the alarm/timer/clock defaults every new alarm or timer inherits,
help), a documented, versioned JSON backup (`docs/BACKUP.md`) that exports and
restores alarms/timers/world clocks and settings through SAF — configuration
only, never ring state, never a timer's running anchors — as a whole-state
restores alarms/timers/world clocks and settings through SAF (configuration
only, never ring state, never a timer's running anchors) as a whole-state
replace the screen is explicit about before the user confirms, a "why might my
alarm not ring?" self-check that reads real device state (exact-alarm and
notification permissions, full-screen intent, battery optimisation, DND, alarm
volume, a known aggressive-OEM allowlist, and the system's own next alarm
compared against what Clockula computed) and deep-links to the exact settings
page for each, and three Glance home-screen widgets — next alarm, timer,
clock — that mirror existing state and redraw event-driven, on every write,
with no ticker of their own. **M11** (release readiness) is next.
page for each, and three Glance home-screen widgets (next alarm, timer,
clock) that mirror existing state and redraw event-driven, on every write,
with no ticker of their own. M11 (release readiness) is next.
---
@@ -30,12 +30,12 @@ with no ticker of their own. **M11** (release readiness) is next.
One milestone per iteration, auto-continuing. Each iteration:
1. Pick the **first milestone not marked ✅**.
2. Implement it **completely** — every checklist item under it.
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).
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.
@@ -45,7 +45,7 @@ Rules that hold across every iteration:
(`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
- 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.
@@ -75,7 +75,7 @@ The project exists, builds, is themed, and ships itself.
`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
unwritten by design. M2 writes it, once there is an architecture to
describe.
### ✅ M1 — floret-kit: `core-prefs` + `core-di`
@@ -83,16 +83,16 @@ 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.
- 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.
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
@@ -114,19 +114,19 @@ The whole storage stack, headless. No UI.
✅ 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.
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
gap: 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
@@ -140,13 +140,13 @@ plumbing.
- 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.
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 —
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
@@ -155,43 +155,43 @@ plumbing.
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
suppress a re-fire but can never silence the future, because suppression also
requires the candidate to be at-or-before now. 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.
The "never to silence" chain is a testable invariant: 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
- 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),
✅ 480 JVM tests, 154 of them new, and still no Robolectric. Keeping it that
way meant moving everything that decides something 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
`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
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
@@ -200,7 +200,7 @@ plumbing.
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
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
@@ -213,18 +213,18 @@ 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`.
✅ 624 JVM tests, 144 of them new, and **still no Robolectric** — kept for a
✅ 624 JVM tests, 144 of them new, and still no Robolectric, kept for a
whole *form*, which is where a screen usually reaches for one. Everything a
tap decides is a pure object (`AlarmListRows`, `NextFireFormat`,
`RepeatDaysSummary`, `AlarmDefaults`, `AlarmOverrides`, `RingtonePicker`,
`AudioSourcePolicy`, `AlarmRoutes`) or a ViewModel method tested over the
**real** `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
real `AlarmEngine`; three new `ArchitectureRulesTest` rules make that a
build fact rather than a habit.
The decisions worth knowing: the next-fire line comes from
`AlarmEngine.upcoming`, which grew a `refresh: Flow<Unit>` cadence parameter
so the countdown advances without a database write and the screen re-derives
none of the engine's time arithmetic; the list is ordered by **time of day**,
none of the engine's time arithmetic; the list is ordered by time of day,
not by next fire, so rows never swap under a thumb at 07:00; every per-alarm
override is an `OptionPicker` whose first row is "App default (On)", because
the stored value is ternary and a `Switch` cannot say "I have not chosen";
@@ -235,79 +235,79 @@ line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
read-modify-write, so a one-shot alarm that disabled itself underneath an open
editor is not re-enabled by the next unrelated tap.
The correctness find: disabling or deleting a **ringing** alarm used to clear
`ringingSince` without stopping the ring service — whose auto-silence backstop
then disarms itself on its own null guard — so the alarm would have sounded
The correctness find: disabling or deleting a ringing alarm used to clear
`ringingSince` without stopping the ring service (whose auto-silence backstop
then disarms itself on its own null guard), so the alarm would have sounded
until the 15-minute wake-lock timeout. Both new ViewModels now dismiss through
the engine first, conditionally, so a merely snoozed alarm keeps its snooze.
floret-kit went to **0.6.0** for one change: `CollapsingScaffold` takes a
floret-kit went to 0.6.0 for one change: `CollapsingScaffold` takes a
`floatingActionButton` slot, forwarded to the underlying `Scaffold`, rather
than each app re-deriving the inset maths in a `Box`.
Knowingly open: no hand reordering of alarms (the time order answers it, and
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
chip after a delete (traded for a confirmation that names the alarm), no
repeat presets, and no per-alarm auto-silence — still M10 at the earliest.
repeat presets, and no per-alarm auto-silence (still M10 at the earliest).
Editing the app-wide defaults every override row offers to follow is M10's
settings screen. The seven new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached.
have never run on hardware, as no device was attached.
### ✅ 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.
✅ 843 JVM tests, 219 of them new, and **still no Robolectric** — kept for a
✅ 843 JVM tests, 219 of them new, and still no Robolectric, kept for a
second screen *and* for a foreground service, which is the other place a
project usually gives in. Every decision is a pure object (`TimerReadings`,
`TimerExpiry`, `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`,
`TimerDurationEntry`, `TimerListRows`, `TimerRoutes`) or a method on the
engine or one of the two ViewModels, tested over the **real**
`TimerRepositoryImpl` and the **real** `TimerEngine`. `TimerService` holds no
policy and no state beyond one flag — even its two `delay` amounts come from
engine or one of the two ViewModels, tested over the real
`TimerRepositoryImpl` and the real `TimerEngine`. `TimerService` holds no
policy and no state beyond one flag; even its two `delay` amounts come from
the engine, so the timings are asserted in a JVM test. Three new
`ArchitectureRulesTest` rules pin it, one of which makes a *second*
`MediaPlayer` a build failure. The four new instrumentation tests bring the
compiled total to 32.
The decisions worth knowing: the expiry slot is **one** registration on the
`ELAPSED_REALTIME_WAKEUP` base — so a `TIME_SET` cannot warp a running timer,
The decisions worth knowing: the expiry slot is one registration on the
`ELAPSED_REALTIME_WAKEUP` base (so a `TIME_SET` cannot warp a running timer,
and a timer never populates `getNextAlarmClock()`, which belongs to the user's
next *alarm* — and its fire is an **idempotent sweep carrying no id**, so a
next *alarm*), and its fire is an idempotent sweep carrying no id, so a
late delivery expires everything due, an early one writes nothing, and the
anchors *are* the watermark; the service adds a second, independent trigger
for the same sweep, because neither has to be reliable alone; one
`systemExempted` foreground service covers the countdown and the ring, alive
on the live pill's own "active, not running" predicate, and the countdown
holds **no wake lock** (the alarm slot is what wakes the device); an alarm
ringing **wins the audio** and the timer's ring is *deferred, not lost*,
because the arbitration is a pure function of current state rather than an
event — so the reverse case is symmetric and free; two timers expiring
holds no wake lock (the alarm slot is what wakes the device); an alarm
ringing wins the audio and the timer's ring is deferred, because the
arbitration is a pure function of current state rather than an event, so the
reverse case is symmetric and free; two timers expiring
together share one ring session and one ten-minute window, and each is stopped
on its own, because silently resetting both destroys the information "which
one finished"; auto-silence stops the noise and leaves the row EXPIRED, which
is the load-bearing difference from an alarm; presets are app-wide in
DataStore with out-of-range values **dropped, never clamped**; and the
DataStore with out-of-range values dropped, never clamped; and the
notification's countdown is the platform chronometer, so the service posts
once per state change rather than once per second for forty-five minutes.
Two things the checklist did not name were changed anyway, and are recorded in
`ARCHITECTURE.md`: **"+1 min" on an expired timer now resumes it** with
`ARCHITECTURE.md`: "+1 min" on an expired timer now resumes it with
exactly the extra, in one transaction (M2 left it paused, written before any
UI existed and pinned by no test — and resuming is the only way to avoid
emitting an intermediate PAUSED frame to the pill and the row); and **the live
pill's timer actions now go through `TimerEngine`**, because after M6 there is
UI existed and pinned by no test; resuming is the only way to avoid
emitting an intermediate PAUSED frame to the pill and the row); and the live
pill's timer actions now go through `TimerEngine`, because after M6 there is
an AlarmManager registration and a service that have to move with the state.
The pill's contract is otherwise unchanged: M6 *extracted* its precedence into
`TimerReadings` so the pill, the notification and the ring share one ordering,
and `LivePillSelectorTest` passing **unmodified** is the proof.
and `LivePillSelectorTest` passing unmodified is the proof.
No schema change (the database stays at v2; the ring session's one volatile
fact is a DataStore record, which is `PLAN.md` §5's own record-versus-rows
split and keeps it out of M10's backup), **no new permission** (a timer never
takes over the screen — no full-screen intent, no ring activity), and **no
floret-kit change**: the kit earned `CollapsingScaffold`'s
split and keeps it out of M10's backup), no new permission (a timer never
takes over the screen: no full-screen intent, no ring activity), and no
floret-kit change: the kit earned `CollapsingScaffold`'s
`floatingActionButton` slot at 0.6.0 for exactly this shape of tab, and M6
consumes it.
@@ -318,85 +318,85 @@ Elapsed-realtime anchored.
"stop all" when several timers are expired, no undo after a delete (traded for
a confirmation, as with alarms), no ramp on a timer's ring, no presets
management screen and no editing of `default_timer_duration_millis` or
`default_timer_ringtone_uri` — M10's settings screen. `TimerSnapshot.anchorIsStale`
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the
in-process hook `ShellNavigation.tabForAction` exists, wired to one action;
M9 owns the rest. The four new instrumentation tests compile in the gate but
have **never run on hardware**, as no device was attached — the same caveat
M5's seven carry.
have never run on hardware, as no device was attached; M5's seven carry the
same caveat.
Two cases in the new suite are left **red** and are a design conversation
rather than a behaviour gap; both are defects in the test, not in the code:
Two cases in the new suite are left red. Both are defects in the test, not in
the code, so they are a design conversation rather than a behaviour gap:
`TimerIntentsTest` §5.17 #3 asserts `containsNoDuplicates` over a list that
contains the *same* `requestCodeFor(REQUEST_PAUSE, 1L)` call twice by
construction, which no deterministic function can satisfy; and `TimerPrefsTest`
§5.10 #11 opens a second DataStore over a live file in the same scope, which
DataStore refuses by design — as `SettingsPrefsTest` itself documents.
DataStore refuses by design, as `SettingsPrefsTest` itself documents.
### ✅ 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);
The post-reboot repair is not here: M3 took it (`ARCHITECTURE.md` §5), and
M7 owns only how the paused result is presented.
✅ 968 JVM tests, 125 of them new, and **still no Robolectric** — kept now for
✅ 968 JVM tests, 125 of them new, and still no Robolectric, kept now for
a third screen and a third service. Everything the tab decides is a pure
object (`StopwatchFormat`, `StopwatchReadings`, `LapStats`, `StopwatchLaps`,
`StopwatchNotificationPolicy`, `StopwatchRows`) or a method on the engine or
the ViewModel, tested over the **real** `StopwatchRepositoryImpl` across the
the ViewModel, tested over the real `StopwatchRepositoryImpl` across the
real `appendLap` transaction and a real DataStore under a `@TempDir`.
`StopwatchService` holds no policy and **no state at all** — not even the
timers' one `alreadyAlerted` flag, because nothing here ever alerts — and it
`StopwatchService` holds no policy and no state at all (not even the
timers' one `alreadyAlerted` flag, because nothing here ever alerts), and it
has no `delay`. Two new `ArchitectureRulesTest` rules and two extended; the
four new instrumentation tests bring the compiled total to 36.
The decisions worth knowing: the foreground service is **`specialUse`**, not
`systemExempted`, because a stopwatch is not alarm functionality — it
The decisions worth knowing: the foreground service is `specialUse`, not
`systemExempted`, because a stopwatch is not alarm functionality. It
schedules nothing, wakes nothing and rings nothing, and claiming the type
would have the app tell the platform something untrue; it holds **no wake
lock, ever**, since the elapsed value is derived from a persisted monotonic
anchor at read time; the notification is the **platform chronometer counting
up**, with its base `now - elapsed` **derived at post time and never stored**,
would have the app tell the platform something untrue. It holds no wake
lock, ever, since the elapsed value is derived from a persisted monotonic
anchor at read time; the notification is the platform chronometer counting
up, with its base `now - elapsed` derived at post time and never stored,
which is how the run record keeps its "no wall-clock field whatsoever" rule;
its channel is `IMPORTANCE_LOW` and silent, because nothing here ever
finishes, so there is no alert-once rule and no session window anywhere in the
path; the readout is hundredths and it is **two strings**, so the
fastest-changing glyphs are not the loudest ones; best and worst need **three**
path; the readout is hundredths and it is two strings, so the
fastest-changing glyphs are not the loudest ones; best and worst need three
laps, ties go to the earliest, and all-equal means neither; laps are capped at
**999**, guarded in the engine over one `COUNT(*)` so appending never
materialises the rows; and the tab ticks at ~60 Hz **only while running**, so
999, guarded in the engine over one `COUNT(*)` so appending never
materialises the rows; and the tab ticks at ~60 Hz only while running, so
a paused or idle stopwatch costs nothing.
Three things the checklist did not name were changed anyway, and are recorded
in `ARCHITECTURE.md`: **the live pill's stopwatch actions now go through
`StopwatchEngine`** (M6 recorded this as knowingly open), with the pill's own
contract unchanged and `LivePillSelectorTest` passing **unmodified** as the
proof; **`LivePillSelector` was re-pointed at a new pure `StopwatchReadings`**,
in `ARCHITECTURE.md`: the live pill's stopwatch actions now go through
`StopwatchEngine` (M6 recorded this as knowingly open), with the pill's own
contract unchanged and `LivePillSelectorTest` passing unmodified as the
proof; `LivePillSelector` was re-pointed at a new pure `StopwatchReadings`,
the same extraction M6 made for timers, so the pill, the shade and the tab
cannot answer "what does this read right now" three different ways; and
**`ui/theme/Type.kt` stopped being `Typography()`** — tabular figures on the
`ui/theme/Type.kt` stopped being `Typography()`: tabular figures on the
display, headline and title roles, app-wide, with no call-site change
anywhere, plus `ClockulaReadoutDefaults` naming the three roles a readout uses.
The correctness find: a run that met a reboot mid-lap had `accumulated = 0`
while its laps still recorded forty seconds, so the honest reading is
**floored at the last lap's total** as well as at zero — a readout sitting
floored at the last lap's total as well as at zero. A readout sitting
below a lap the same run printed is not one anyone can believe, and the laps
are real measurements that are never deleted to tidy the arithmetic. Recorded
in `ARCHITECTURE.md` §5. The review found the other half of it: the floor has
to be **banked, not only displayed** — resuming writes
to be banked as well as displayed. Resuming writes
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
bank the same floored reading — or the record ticks from below its own last
lap, the shade's free-running chronometer counts while the tab sits frozen,
and the next lap records a cumulative below the previous one. The same review
pass moved the engine's verbs onto **the reading's** mode rather than the
stored row's, so a stale record's Resume button resumes instead of hitting a
silent no-op. `pauseAfterReboot` itself is still untouched.
bank the same floored reading; otherwise the record ticks from below its own
last lap, the shade's free-running chronometer counts while the tab sits
frozen, and the next lap records a cumulative below the previous one. The
same review pass moved the engine's verbs onto the reading's mode rather than
the stored row's, so a stale record's Resume button resumes instead of hitting
a silent no-op. `pauseAfterReboot` itself is still untouched.
No schema change (the database stays at v2; `LapDao` gains one `COUNT(*)` and
nothing else), **one** new permission — `FOREGROUND_SERVICE_SPECIAL_USE`, the
one that names what the service actually does — and **no floret-kit change**:
nothing else), one new permission (`FOREGROUND_SERVICE_SPECIAL_USE`, the
one that names what the service actually does), and no floret-kit change:
the kit stays at 0.6.0, untouched.
Knowingly open: no lap export, sharing, deletion or editing, and no "previous
@@ -409,86 +409,86 @@ M7 owns only how the paused result is presented.
is still not surfaced, the same answer M6 gave for timers. Of `AlarmClock`'s
intent contract only the in-process hook `ShellNavigation.tabForAction` grew a
second action; M9 owns the rest. The four new instrumentation tests compile in
the gate but have **never run on hardware**, as no device was attached — the
same caveat M5's seven and M6's four carry.
the gate but have never run on hardware, as no device was attached; M5's
seven and M6's four carry the same caveat.
### ✅ 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
built from `MaterialShapes` / `androidx.graphics.shapes` is the app's one
deliberate showpiece.
✅ 1 139 JVM tests, 171 of them new, and **still no Robolectric** — kept now
for a fourth screen, and this was the milestone most tempted to break it,
✅ 1 139 JVM tests, 171 of them new, and still no Robolectric, kept now
for a fourth screen. This was the milestone most tempted to break it,
because ICU and `Canvas` both look like they need a device. They do not:
everything ICU answers is behind `ZoneNames` (faked, with per-method call
counters and a `failEverything` flag), everything the face draws is
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction — two
pure objects asserted at every minute of the day without a pixel — and
`DrawScope` calls over `AnalogFace`'s angles and `DayNight`'s fraction (two
pure objects asserted at every minute of the day without a pixel), and
everything the list and the picker decide is `WorldClockRows`,
`ZonePickerRows`, `ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and
`WorldClocks`. Every write is a `WorldClockViewModel` method over the **real**
`WorldClocks`. Every write is a `WorldClockViewModel` method over the real
`WorldClockRepositoryImpl` across `FakeWorldClockDao` (so `addIfAbsent` and
`reorder`'s real transactions run) and a real DataStore under a `@TempDir`.
Three new `ArchitectureRulesTest` rules; the four new instrumentation tests
bring the compiled total to 40.
The decisions worth knowing: **there is no engine and must not be one** — a
The decisions worth knowing: there is no engine and must not be one, because a
world clock owns no AlarmManager slot, no service and no ring session, so a
fourth engine would be three empty seams and a `Mutex` guarding nothing; the
tab reads the **wall clock**, and re-reads both the instant and the device
zone on **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
free (no new receiver branch); the zone **ids** stay pure (through a new
`ZoneProvider.available()`) while only the **names** cross the ICU seam, which
tab reads the wall clock, and re-reads both the instant and the device
zone on every tick, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
free (no new receiver branch); the zone ids stay pure (through a new
`ZoneProvider.available()`) while only the names cross the ICU seam, which
is one file, `runCatching`-guarded end to end and pinned there by a build
rule; the catalog keeps only region-based ids that are **their own canonical
id** — so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
Asia/Kolkata and no table of ours can rot — unless the canonical id is not on
rule; the catalog keeps only region-based ids that are their own canonical
id (so the tzdata's own canonicalisation drops Asia/Calcutta in favour of
Asia/Kolkata and no table of ours can rot), unless the canonical id is not on
the device at all, since ICU and tzdata are separately updatable APEX modules
that can disagree, plus `UTC` by name; search is
**word-prefix over folded text**, so "erl" deliberately does not find Berlin;
the offset and day are computed **at the instant** from both zones' rules, the
word-prefix over folded text, so "erl" deliberately does not find Berlin;
the offset and day are computed at the instant from both zones' rules, the
same discipline `AlarmOccurrences` uses, so Berlin↔Sydney is ten hours in
January and eight in July; labels are **data, not strings**, so Kathmandu is
January and eight in July; labels are data, not strings, so Kathmandu is
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
cadence is **200 ms** with the state truncated to the second (face) and the
cadence is 200 ms with the state truncated to the second (face) and the
minute (rows) and `distinctUntilChanged` over it; the list is capped at
**24**, with the cap spelled out on the screen once it is reached, because
24, with the cap spelled out on the screen once it is reached, because
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
reduce-motion snaps the morph but **never stops the second hand**,
reduce-motion snaps the morph but never stops the second hand,
because a clock whose second hand stops is a broken clock.
Three things outside the checklist were changed anyway, and are recorded in
`ARCHITECTURE.md`: **`ZoneProvider` gained `available()`**, because
`ARCHITECTURE.md`: `ZoneProvider` gained `available()`, because
`ZoneId.getAvailableZoneIds()` at a call site is the same ambient read
`PLAN.md` §5 bans for `now()`; **`ui/common/AlarmTimeFormatter.kt` gained
`rememberLocalTimeFormatter`** with the existing function delegating to it, so
`PLAN.md` §5 bans for `now()`; `ui/common/AlarmTimeFormatter.kt` gained
`rememberLocalTimeFormatter` with the existing function delegating to it, so
M5's "one 12/24-hour formatter" survives a fourth caller instead of growing a
second answer; and **`ShellDefaults.EmptyTabScreen` was deleted**, its own
KDoc having said it existed only until each of M5–M8 replaced its own tab —
second answer; and `ShellDefaults.EmptyTabScreen` was deleted, its own
KDoc having said it existed only until each of M5–M8 replaced its own tab;
the world clock was its last caller.
No schema change at all (the database stays at v2; `WorldClockEntity`, its
DAO, its mapper and its repository are not edited), **no new permission and no
manifest entry**, **one** new dependency coordinate —
`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
resolved transitively, because M8 imports that package directly — and **no
floret-kit change**: the kit stays at 0.6.0, untouched, with
DAO, its mapper and its repository are not edited), no new permission and no
manifest entry, one new dependency coordinate
(`androidx.graphics:graphics-shapes:1.0.1`, pinned to the version that already
resolved transitively, because M8 imports that package directly), and no
floret-kit change: the kit stays at 0.6.0, untouched, with
`ReorderableColumn`, `GroupedSurface`, `FullScreenPicker`, `InlineTextField`
and `CollapsingScaffold`'s FAB slot already covering the screen.
Knowingly open: no rename UI for `world_clocks.label` — the column is honoured
on read and M10's backup import will carry labels; no automatic "home" row
Knowingly open: no rename UI for `world_clocks.label` (the column is honoured
on read and M10's backup import will carry labels); no automatic "home" row
while travelling (a separate feature with its own preference, and the hero
face already follows the device); no per-city detail screen, map,
sunrise/sunset or day-length bar — `DayNight` is a stated convention for a
shape morph, not an ephemeris; no live times or long zone names in the picker;
sunrise/sunset or day-length bar (`DayNight` is a stated convention for a
shape morph, not an ephemeris); no live times or long zone names in the picker;
no seeded data, so a fresh install really has an empty list; no reordering by
anything but `sort_order`; and no `MaterialShapes` on the ring screen or timer
progress, which `PLAN.md` §8 also sanctions — the build rule makes widening
progress, which `PLAN.md` §8 also sanctions; the build rule makes widening
that allowlist a deliberate edit. The four new instrumentation tests compile
in the gate but have **never run on hardware**, as no device was attached —
the same caveat M5's seven, M6's four and M7's four carry.
in the gate but have never run on hardware, as no device was attached;
M5's seven, M6's four and M7's four carry the same caveat.
### ✅ M9 — System interop
The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
@@ -496,103 +496,103 @@ The full `android.provider.AlarmClock` contract (`PLAN.md` §6): `SET_ALARM`,
next-alarm publishing. Hostile-input validation at the intent boundary.
- **Tests:** extra validation, including malformed and out-of-range input.
✅ 1 420 JVM tests, 281 of them new, and **still no Robolectric** — kept now
✅ 1 420 JVM tests, 281 of them new, and still no Robolectric, kept now
for a *boundary*, which is the last place a project usually gives in, because
"it is an `Intent`" sounds like it needs a device. It does not: the door is
split so that everything which decides is pure (`AlarmClockRequests`,
`AlarmMatching`, `IntentExtras`, `InteropText`, `InteropDeepLinks`) and the
Android half is two policy-free files — a `Bundle` reader and an `Intent`
builder. `AlarmClockHandler` orchestrates with **no `android.*` import**, as
all three engines do, and is tested over both **real** engines through a new
`InteropHarness` — deliberately *not* a composition of the existing harnesses,
since each builds a DataStore over the same file name and DataStore refuses a
second instance over a live one. Four new `ArchitectureRulesTest` rules and a
new `ManifestRulesTest`, because the manifest is where this milestone could be
silently wrong and none of it fails a compiler. The five new instrumentation
tests bring the compiled total to 45.
Android half is two policy-free files: a `Bundle` reader and an `Intent`
builder. `AlarmClockHandler` orchestrates with no `android.*` import, as
all three engines do, and is tested over both real engines through a new
`InteropHarness`. It is deliberately not a composition of the existing
harnesses, since each builds a DataStore over the same file name and DataStore
refuses a second instance over a live one. Four new `ArchitectureRulesTest`
rules and a new `ManifestRulesTest`, because the manifest is where this
milestone could be silently wrong and none of it fails a compiler. The five
new instrumentation tests bring the compiled total to 45.
`ACTION_DISMISS_TIMER` — the seventh action, named neither in this checklist
nor in `PLAN.md` §6's table — was built anyway: "the full contract" is the
`ACTION_DISMISS_TIMER`, the seventh action, named neither in this checklist
nor in `PLAN.md` §6's table, was built anyway: "the full contract" is the
milestone's own first sentence, and shipping six of seven and calling it full
would be the kind of quiet gap this loop exists to avoid.
The decisions worth knowing: **one range rule settles every extra** — a value
that decides when something will ring in the *future* is **dropped** out of
range, a value that modifies what the user is doing *now* is **clamped**, so
The decisions worth knowing: one range rule settles every extra. A value
that decides when something will ring in the *future* is dropped out of
range, and a value that modifies what the user is doing *now* is clamped, so
hour 25 never becomes an enabled 23:00 alarm while a 1 000-minute snooze
becomes the 60 minutes `ClockPrefs` would have allowed; **a `Bundle` is
modelled honestly** as `Map<String, Any?>` with four *total* accessors,
becomes the 60 minutes `ClockPrefs` would have allowed; a `Bundle` is
modelled honestly as `Map<String, Any?>` with four *total* accessors,
because hostile input is an `Int` where a `String` was documented far more
often than it is an out-of-range number, and the platform's typed getters
cannot tell "absent" from "wrong type"; **`SKIP_UI` never means skip
validation** — an incomplete `SET_ALARM` creates the row from the extras that
cannot tell "absent" from "wrong type"; `SKIP_UI` never means skip
validation, so an incomplete `SET_ALARM` creates the row from the extras that
*were* valid, at the next whole hour, and lands the user in its editor, since
M5 locked that the editor edits a persisted row and nothing is ever written
and hidden; **`ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest"**, and
and hidden; `ALARM_SEARCH_MODE_TIME` matches exactly, never "nearest", and
an ambiguous 0..12 hour with no `IS_PM` carries *both* readings as candidates
so the question only reaches the user when the data cannot settle it;
**`ALARM_SEARCH_MODE_ALL` never asks** — taken literally the contract's "show
the results" would make the mode useless — while every other search with two
`ALARM_SEARCH_MODE_ALL` never asks (taken literally, the contract's "show
the results" would make the mode useless), while every other search with two
or more matches opens the milestone's one new surface, an M3 basic dialog with
a `ListItem` list where tapping a row *is* the answer.
**A correctness find, fixed here:** the `AlarmClockInfo` **`showIntent`** — the
A correctness find, fixed here: the `AlarmClockInfo` `showIntent` (the
thing the system opens when the user taps the status-bar alarm icon or the
lockscreen's next-alarm line — pointed at the *ring screen* with
lockscreen's next-alarm line) pointed at the *ring screen* with
`CLEAR_TASK`, so tapping "my next alarm" opened a ring screen for an alarm
that was not ringing, which resolved to `Finished` and closed itself. It now
opens `MainActivity` on the Alarms tab, and a build rule fails the build if
`AndroidAlarmScheduler` ever names `AlarmRingActivity` again. That intent is
literally what "next-alarm publishing" means to a user.
what "next-alarm publishing" means to a user.
**The schema went to v3**: `delete_after_use` on `alarms` and on `timers`, two
The schema went to v3: `delete_after_use` on `alarms` and on `timers`, two
`ALTER TABLE`s in `MIGRATION_2_3`, `3.json` exported and committed, `1.json`
and `2.json` untouched. It is not optional polish — the contract says in two
places that a `SKIP_UI` alarm or timer should be removed once dismissed, and
without it a daily "wake me at 6:30" turns the Alarms tab into a graveyard.
and `2.json` untouched. The contract says in two places that a `SKIP_UI`
alarm or timer should be removed once dismissed, and without the column a
daily "wake me at 6:30" turns the Alarms tab into a graveyard.
The flag is set only on the `SKIP_UI` create path and honoured inside the two
engines, under their locks.
Four things outside the checklist were changed anyway, and are recorded in
`ARCHITECTURE.md`: **`AlarmEngine` gained `dismissUpcoming(id)` and a
`minutesOverride` on `snooze`**, because both move the ring slot, the
`ARCHITECTURE.md`: `AlarmEngine` gained `dismissUpcoming(id)` and a
`minutesOverride` on `snooze`, because both move the ring slot, the
auto-silence backstop and the next-alarm registration, and a handler calling
three repositories in a row would race a fire broadcast; **`TimerEngine` gained
`dismissAllExpired()` and `dismissExpired(id)`** — the first is the "stop all"
three repositories in a row would race a fire broadcast; `TimerEngine` gained
`dismissAllExpired()` and `dismissExpired(id)` (the first is the "stop all"
the UI still deliberately does not offer, because that rule was about a thumb
on a ring and a programmatic request has stated exactly what it is discarding;
**`domain/text/TextFolding` was extracted** and `ZoneSearch` delegates to it,
with `ZoneSearchTest` passing unmodified as the proof; and **the shell's
`openTab` became `openRequest: ShellRequest?`**, since three of the contract's
on a ring and a programmatic request has stated exactly what it is discarding);
`domain/text/TextFolding` was extracted and `ZoneSearch` delegates to it,
with `ZoneSearchTest` passing unmodified as the proof; and the shell's
`openTab` became `openRequest: ShellRequest?`, since three of the contract's
outcomes are not "select a tab".
**No new `<uses-permission>`**: the one new permission string is
`android:permission` *on the door*, a requirement on the caller. **No new
dependency coordinate** and **no floret-kit change** — the kit stays at 0.6.0,
No new `<uses-permission>`: the one new permission string is
`android:permission` *on the door*, a requirement on the caller. No new
dependency coordinate and no floret-kit change; the kit stays at 0.6.0,
untouched, because the one new surface is material3's own `AlertDialog`.
Knowingly open: no voice-interaction follow-on flows
(`VoiceInteractor.CompleteVoiceRequest`/`PickOptionRequest`), so
`android.intent.category.VOICE` is deliberately not declared and Clockula
accepts a `clockula://` deeplink while publishing none; no "most closely
matched" time search; no toast on a `SKIP_UI` write — the status-bar icon is
the platform's own receipt; no `delete_after_use` surface in either editor, so
matched" time search; no toast on a `SKIP_UI` write (the status-bar icon is
the platform's own receipt); no `delete_after_use` surface in either editor, so
an assistant-set alarm the user edits stays transient, as it does in Google
Clock; no `EXTRA_MESSAGE` carried into the timer setup panel when no length
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less — it
was given; and `AlarmScheduler.systemNextAlarm()` is still caller-less, since it
belongs to M10's self-check screen. The five new instrumentation tests and the
two new migration cases compile in the gate but have **never run on
hardware**, as no device was attached — the same caveat M5's seven, M6's four,
M7's four and M8's four carry.
two new migration cases compile in the gate but have never run on
hardware, as no device was attached; M5's seven, M6's four, M7's four and
M8's four carry the same caveat.
### ✅ M10 — Settings, backup, the self-check, and widgets
- 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
`docs/BACKUP.md`.
- The **"why might my alarm not ring?"** self-check screen (`PLAN.md` §4) —
- The "why might my alarm not ring?" self-check screen (`PLAN.md` §4):
real device state, deep links to the exact settings pages.
- Three Glance home-screen widgets — next alarm, timer, clock (`PLAN.md` §9).
- Three Glance home-screen widgets: next alarm, timer, clock (`PLAN.md` §9).
- **Tests:** backup round-trip and unknown-field tolerance; self-check rule
logic per permission/state combination; widget model selection/formatting.
@@ -608,13 +608,13 @@ reliability soak across a few real nights. Then 1.0.0.
Deferred deliberately, in rough priority order:
- **Dock / screensaver mode** — a `DreamService` full-screen clock. Cheapest big
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.
- **Quick Settings tile** — timer control and next alarm from the shade.
- **Bedtime / sleep schedule** — wind-down and wake, with DND handoff. A feature
- **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
- **`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
- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`**: tracked in
the kit's roadmap, not here.
- **Wear OS companion** — unscoped.
- **Wear OS companion**: unscoped.
@@ -4,7 +4,7 @@ It is the third app in the Floret family, after Calendula (calendar) and Agendul
<b>Alarms that actually ring</b>
An alarm app is only worth having if you can trust it. Clockula schedules alarms the way Android intends — as real alarm clocks, exempt from battery saving, visible to the system lock screen and status bar. When something on your device would stop an alarm from ringing, Clockula tells you which setting it is and takes you there, instead of failing silently at 7am.
Clockula schedules alarms the way Android intends: as real alarm clocks, exempt from battery saving and visible on the lock screen and status bar. When something on your device would stop an alarm from ringing, Clockula tells you which setting it is and takes you there, instead of failing silently at 7am.
<b>Everything you expect</b>
@@ -17,6 +17,6 @@ A running timer or stopwatch stays reachable from every tab, so you never lose t
<b>Open, and yours</b>
Clockula speaks Android's standard alarm intents, so your assistant and your automation apps can set alarms and timers in it like any other clock app. Your alarms, timers and clocks export to a documented JSON file whenever you want them, because data you cannot take with you is not really yours.
Clockula speaks Android's standard alarm intents, so your assistant and your automation apps can set alarms and timers in it like any other clock app. Your alarms, timers and clocks export to a documented JSON file whenever you want them.
No account. No tracking. No ads. Free software under the MIT licence.
No account, no tracking, no ads. Free software under the MIT licence.