docs: the alarms screen, and the numbers put right

ARCHITECTURE gains §13 for the alarms screen. The test counts in §8 were
written before the review's fixes landed, and M4's instrumentation count was
one short; both now match what the gate actually runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 14:44:23 +02:00
co-authored by Claude Opus 5
parent 97ca617e6a
commit 176626b33a
2 changed files with 269 additions and 16 deletions
+25
View File
@@ -86,3 +86,28 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
already stand down when the system's "remove animations" setting is on.
- Notification permission is asked for once, on first launch, and the fact that
it was asked is recorded rather than guessed at.
- The alarms tab, for real: a list of your alarms that says when each one will
ring — "in 9h 12m" — and marks the next one to go off, with the repeat days
and a switch on every row. A switched-off alarm stays where it is, faded
rather than shuffled to the bottom, and the countdown keeps up on its own
without anything being saved.
- Create, edit, delete, enable and disable an alarm, applied as you make them
rather than behind a Save button: tapping + creates an alarm at the next whole
hour and opens it, every control writes at once, and back is just back.
- The Material time picker, with a keypad to type a time and the dial behind one
toggle, following the device's own 12- or 24-hour setting.
- A repeat-day selector that starts on your locale's first day of the week, not
on a hardcoded Monday, and a "skip next alarm" switch for a repeating one —
so you can sleep in on Thursday without switching the alarm off and
forgetting to switch it back.
- A per-alarm sound, vibration, gradual volume, snooze length, snooze limit and
dismiss challenge — each of which can say "follow the app default" rather than
silently copying today's default and drifting away from it later. Every row
shows what it currently resolves to.
- A ringtone picker with the device's own alarm sounds, a file you choose
yourself, and a Silent option that still vibrates — because an alarm that does
literally nothing is not an alarm. You can play a sound before taking it.
- A sound Clockula cannot read right now says so under the row, and your choice
is kept rather than quietly replaced with the default: an unmounted card comes
back. If a file you picked cannot be held onto permanently, that is disclosed
too instead of being hidden.
+244 -16
View File
@@ -78,19 +78,20 @@ kit module — the kit's roadmap keeps schedulers app-local.
| Package | Holds |
|---|---|
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt` — plain Kotlin, no Android |
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent sentinel), `RepeatSummary.kt`, `AlarmDefaults.kt` — plain Kotlin, no Android |
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the volume ramp, the ring policies |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
| `domain/live/` | `LivePillSelector` and its state — which running thing the live pill is about, as a pure function |
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated |
| `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — and `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data |
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
| `data/ringtones/` | the two ringtone seams — `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer` — plus their `System*` implementations |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule` |
| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` |
| `alarm/android/` | the seams' Android implementations: AlarmManager, the capability reads, the service handle, the snoozed notification |
| `alarm/receiver/` | `AlarmFireReceiver`, `AlarmActionReceiver`, `SystemEventReceiver` |
@@ -99,7 +100,9 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `system/` | `RebootRepair` — the boot-id gate |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask |
| `ui/alarms/`, `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M5–M8 fill them |
| `ui/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share |
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override and ringtone pickers |
| `ui/timers/`, `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M6–M8 fill them |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
---
@@ -128,7 +131,7 @@ JSON and M10's JSON backup all read the same vocabulary in a diff.
| `enabled` | INTEGER | indexed — `enabled()` filters on it |
| `repeat_days` | INTEGER | 7-bit mask, see below |
| `skip_next_occurrence` | INTEGER | |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults` |
| `ringtone_uri`, `vibrate`, `snooze_minutes`, `snooze_limit`, `volume_ramp_seconds`, `dismiss_challenge` | nullable | **overrides**; `NULL` = inherit `ClockDefaults`. For `ringtone_uri`: `NULL` = inherit, `clockula://silent` = deliberate silence (§13), anything else a content URI |
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
Per-alarm settings are nullable *overrides*, never concrete copies of the
@@ -138,6 +141,18 @@ silently failed to reach alarms the user never customised — the opposite of wh
collapses the two, and it uses `?:` throughout: a stored `false` for `vibrate`
is a choice, not an absent value.
`AlarmDao` is an **`abstract class`** rather than an interface, so it can carry
a `@Transaction open suspend fun updateWithin(id, transform)` — a read, a
transform and a write as one unit, mirroring `TimerDao.updateWithin`.
`AlarmRepository.edit(id, transform)` is its domain face: it stamps `updated_at`
from the wall clock and forces the id, so a transform can change a field but
never move a row. The editor writes through it exclusively, because the engine
can call `setEnabled(id, false)` underneath an open editor when a one-shot
alarm's cycle closes — and a whole-row write from a stale read would re-enable
an alarm that had just rung. **M5 changes no schema**: no column was added, no
version bumped, no migration written; a DAO becoming an abstract class changes
no SQL.
### `alarm_states`
Volatile ring state, one row per alarm, added at **v2** (M3).
@@ -466,6 +481,7 @@ Hilt, `SingletonComponent` throughout. Five app modules plus the kit's:
| `RepositoryModule` | `@Binds` for the five repository interfaces |
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
@@ -482,12 +498,18 @@ queries onto its own executor and runs Flow queries off the main thread, so a
`withContext(io)` wrapper around a DAO call would be cargo cult. The DataStore
half already runs on `@IoDispatcher`, wired once in `DataModule`.
`SystemRingtoneCatalog` is the one documented exception: it takes
`@IoDispatcher`, because a raw `ContentResolver` query — and
`RingtoneManager`'s cursor, and `MediaPlayer.prepare` in
`SystemRingtonePreviewer` — dispatches nothing of its own and would otherwise
block whichever thread asked.
---
## 8. Testing
JVM-first. 474 unit tests run in the gate; twenty instrumentation tests compile
in it and run only on a device. There is **no Robolectric** and no plan for it:
JVM-first. 624 unit tests run in the gate; 28 instrumentation tests compile in
it and run only on a device. There is **no Robolectric** and no plan for it:
everything that would have needed a shadow is behind one of the injected seams,
or is a pure function in `domain/alarm/`.
@@ -500,7 +522,7 @@ front of a dismissal, and the promise that it always opens). What is left in the
composables is `Text(...)` over those values. The two ViewModels that need a main
dispatcher get one from a twenty-line JUnit 5 `MainDispatcherExtension`.
What genuinely needs hardware is written as the eight new instrumentation tests
What genuinely needs hardware is written as the nine new instrumentation tests
and honestly labelled as such: a window really appearing over a lock screen with
`FLAG_KEEP_SCREEN_ON` set, a real system back press, a real activity recreation,
and a TalkBack-shaped accessibility click completing the hold challenge.
@@ -543,6 +565,36 @@ and a TalkBack-shaped accessibility click completing the hold challenge.
`system/`. It is the boundary of §2 made mechanical — and the engine's
testability is a build-gate fact rather than a habit.
**M5 kept the posture for a whole form**, which is where a screen usually
reaches for Robolectric. Everything a tap decides was pushed out of the
composables: `AlarmListRows` (what the list shows, in what order, which row is
"next"), `NextFireFormat` ("in 9h 12m"), `RepeatDaysSummary`/`RepeatDaysOrder`
("Weekdays", and which day column comes first), `AlarmDefaults` (a new alarm's
time, resolved through a spring-forward night), `AlarmOverrides` (inherited or
chosen), `RingtonePicker` (which rows exist and which is checked),
`AudioSourcePolicy` (the fallback order, silence included) and `AlarmRoutes`
(the route strings). Every write, every reschedule and every preview is a method
on one of the two ViewModels, which the tests build over the **real**
`AlarmEngine` across the fake DAOs and fake seams — the arrangement
`testing/AlarmEngineHarness.kt` now lifts out of `AlarmRingViewModelTest`. What
is left in the composables is `Text(...)`, `Switch(...)` and `stringResource`.
Three `ArchitectureRulesTest` rules pin that mechanically: no `*ViewModel.kt`,
`*UiState.kt` or `*Source.kt` imports `androidx.compose.`; the same files import
no `android.`; and no file under `ui/` names `AlarmResolver` or
`AlarmOccurrences` — the screen takes the engine's resolution and re-derives
none of its time arithmetic. All three held retroactively for M4's files, so
they pin existing discipline rather than an aspiration.
What was left to the device is the seven new instrumentation tests: the row's
switch really toggling and the row really dimming, the FAB creating an alarm and
landing in its editor, the M3 time picker writing a typed time, a day toggle
changing the repeat summary, a cancelled system document pick changing nothing,
back returning to a still-selected Alarms tab, and the delete confirmation
removing the row. **They have not been run**: no device is attached to the
machine this milestone was built on, so the gate compiles them and this
paragraph says so.
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
`useJUnitPlatform()` and `isReturnDefaultValues = true`.
@@ -623,9 +675,10 @@ locale metadata holder service.
| Not here | Milestone |
|---|---|
| Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 |
| The exact-alarm and full-screen-intent grant deep links, and any explanation of a denial. M4 asks for `POST_NOTIFICATIONS` once on first launch; the rest waits for the self-check screen, because a bare jump into system settings with no reason given is hostile | M10 |
| Every tab's real content — the alarm list and editor (M5), creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). M4 ships the four title bars and their empty states | M5–M8 |
| Every tab's real content beyond Alarms — creating and running a timer (M6), starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those three keep M4's title bars and empty states | M6–M8 |
| Reordering alarms by hand. The list is ordered by time of day (§13) and `alarms` has no `sort_order` column, so this would be a schema change for a preference the time order already answers | not planned for v1 |
| A per-alarm auto-silence duration. Every other per-alarm setting became an override picker in M5; this one is still global and still `AlarmRing.AUTO_SILENCE_AFTER` | M10 at the earliest |
| Timers ringing — M6 reuses this milestone's audio path; M3 wires no timer to it | M6 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
| ICU city and zone display names, offsets, day differences | M8 |
@@ -634,10 +687,8 @@ locale metadata holder service.
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest |
| Screenshots and store listing polish | M11 |
Also absent by design: any seeded default data (no starter alarm, no home world
clock on first run), and a "silent" ringtone sentinel — `ringtoneUri == null`
means *inherit*, and silent arrives with M5's picker, where the user can
actually choose it.
Also absent by design: any seeded default data — no starter alarm and no home
world clock on first run, so the empty states are real empty states.
Four more deliberate gaps the alarm engine leaves open:
@@ -670,10 +721,21 @@ callable from a plain JUnit test with hand-built inputs and no fakes at all.
four seams. It is **never driven by a Flow**: resolution writes state, so an
engine that rescheduled on each repository emission would re-trigger itself on
its own writes. `reschedule()` is called explicitly — from `ClockulaApp`, from
the receivers, and from M5's edit surface. `upcoming()` runs the same resolver in
the receivers, and from the alarm editor. `upcoming()` runs the same resolver in
preview mode and *discards* the state it returns; a read must not write, and a
test asserts the store recorded nothing across a full collection.
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the
two repository flows — so a countdown built on it would freeze until the next
database write. Making the cadence a parameter keeps `PLAN.md` §5's discipline
(time is a parameter, never an ambient read) without giving the engine a
`Ticker` and an Android-shaped dependency: the default, `flowOf(Unit)`, means
"resolve once per repository emission" and leaves every existing caller alone,
while the alarms screen passes `ticker.ticks(30.seconds)`. Re-resolving is still
read-only, and a test collects several refreshes and asserts `alarm_states`
recorded nothing.
Every entry point is a read-modify-write over `alarm_states`, and they arrive
from threads that know nothing about each other — a broadcast on
`Dispatchers.Default`, `ClockulaApp`'s launch-time re-sync, the ring screen — so
@@ -766,7 +828,11 @@ A snooze, by contrast, is an **absolute instant**: no transition can move it.
### Never to silence
The one non-negotiable, and it is a chain of fallbacks rather than a hope:
The one non-negotiable, and it is a chain of fallbacks rather than a hope. It
begins at `AudioSourcePolicy.sourcesFor(ringtoneUri)`, a pure function a JVM
test can enumerate, and the silent sentinel resolves there to an **empty source
list** — which is precisely what makes step 4 fire, so "silent" means "vibration
only" rather than "nothing at all":
1. the alarm's own ringtone URI, else
2. the device's default alarm URI, else
@@ -889,3 +955,165 @@ to discover a second step. The hold target also carries an accessibility
`onClick` that completes it outright — TalkBack cannot perform a press-and-hold,
and a challenge a screen reader cannot answer is precisely the stranding the
requirement forbids.
---
## 13. The alarms screen
The first tab with real content, and the milestone that gives the schema of §4
and the engine of §11 a face.
### Two destinations in one flat host
The list stays on the `ALARMS` tab route; the editor is a **sibling** route in
the same `NavHost`, `alarms/edit/{alarmId}`, with a `NavType.LongType`
argument. Three properties fall straight out of §12's existing policy rather
than being re-invented: `destinationOf("alarms/edit/7")` is null, so the Alarms
tab stays highlighted while the editor is open; `onBack` of that route is
`Exit`, so the shell's predictive-back handler stands aside and the editor pops
itself; and a tab tap's `popUpTo(start.route)` pops the editor, which is the
right behaviour and exactly the contract §12 wrote down for this milestone.
There is **no "new alarm" sentinel id**. The FAB creates the alarm — enabled,
non-repeating, at the next whole hour, every override null — and then navigates
to its id. One route shape, one load path, and no branch in the ViewModel for
"the thing I am editing does not exist yet".
### The row
A canonical two-line M3 `ListItem` through the kit's `GroupedRow`: the formatted
time with the label beside it in `onSurfaceVariant`, a summary of
`repeat · next fire · skipping next`, and a `Switch`. A **disabled** alarm is
`dimmed` — the kit's own answer for "present but switched off", which fades the
text to the M3 disabled emphasis and leaves the switch at full opacity. The
**next** alarm is marked with `colorScheme.primary` on its next-fire clause and
nothing else: `PLAN.md` §8's "Expressive ≠ big or bold", so the emphasis is a
colour rather than a third line or a larger type ramp.
The list is a `LazyColumn` inside `CollapsingScaffold(scrollable = false)`,
which is the case that parameter exists for, and the FAB sits in the kit's new
`floatingActionButton` slot (floret-kit 0.6.0). Both the FAB and the list's
bottom `contentPadding` clear `LocalLivePillInset`, because the pill is
bottom-centre and the FAB bottom-end.
### Ordered by time of day, not by next fire
`AlarmEngine.upcoming()` sorts by next fire, because its job is "which alarm is
earliest to register". That is the wrong order for a list: an 07:00 and a 22:00
alarm would **swap places at 07:00**, reordering rows under a thumb. So
`AlarmListRows.from` re-sorts by `time.minutesOfDay` then id — which is also
exactly what `AlarmDao.observeAll` emits, so the screen's order re-asserts the
storage order rather than inventing one. A disabled alarm keeps its place: it is
already dimmed, and sinking it would say the same thing twice while moving a row
the user did not move.
### The next-fire line
`NextFireFormat.labelFor(nextFire, now)` returns data, not a string —
`NotScheduled`, `Imminent`, `InMinutes`, `InHoursMinutes`, `InDaysHours` — and
the wording lives in `strings.xml`, following `RingUiState`'s precedent. Whole
units round **up**, the direction `ClockFormat.countdown` already rounds: "in 9h
12m" must mean *at least* nine hours and eleven-and-a-bit minutes, and an alarm
61 seconds out must not read "in 1m" when it is nearer two. `InDaysHours`
exists because a weekly alarm can be six days out.
The cadence is 30 seconds (`AlarmsDefaults.NextFireTick`): the readout's unit is
a minute, so half a unit bounds the displayed error, and the one-second cadence
the live pill needs would re-resolve every alarm sixty times for a readout that
can change once. "Ringing now" does not wait for a tick — it arrives as an
`alarm_states` write, which re-emits at once.
### Every override is a picker, and its first option is "App default"
A per-alarm setting is a nullable override (§4), so the stored value is
**ternary**: absent, or a choice that happens to be `false` or `0`. A `Switch`
cannot say "I have not chosen" — toggling one would silently turn "follows your
default" into "explicitly on", with no way back. So all five overrides (vibrate,
gradual volume, snooze length, snooze limit, dismiss challenge) use the kit's
`OptionPicker`, whose first row is "App default (On)" and names what the default
currently is. Every option offered is inside `ClockPrefs`' clamps, so nothing
chosen can be clamped away on the next read, and `snoozeLimit == 0` is labelled
"No snoozing", never "unlimited" — §11's "0 means infinite is a trap".
Each row's summary shows the effective value, prefixed while inherited, so the
inheritance is visible without opening anything. There is no tri-state hack and
no "reset this alarm" button to discover.
### The ringtone picker
Bespoke over `FullScreenPicker(scrollable = false)` rather than `OptionPicker`,
because a device's tone list can be hundreds of rows. `RingtonePicker.optionsFor`
is pure and yields four kinds of row, de-duplicated by URI:
1. **App default** — summarised with what it currently resolves to, or "Device
default alarm sound" when the app default is itself null;
2. **Silent** — `Ringtones.SILENT_URI`, `clockula://silent`. `null` already
means *inherit*, so silence needs a value of its own, and a private scheme is
one no ContentProvider URI can collide with. It resolves to an empty audio
source list, which forces vibration (§11) — the label is "Silent" with the
summary "Vibration only", so it is not a lie;
3. **the alarm's own sound when the device does not list it** — a SAF-picked
file, or a URI another app wrote. Without this row the user's own selection
would be invisible, and so uncheckable, in the picker that chose it;
4. the device's alarm tones, title-sorted.
"Choose from files…" is an **action** row, not an option, so the pure list stays
a list of selectable states. A picked document's read grant is made persistent
through `RingtoneCatalog.persistPickedDocument`; if that fails the URI is
**still stored** (the fallback chain protects the ring) and the row discloses
"Clockula may lose access to this file later". A stored sound the catalog cannot
open right now is likewise **reported, never rewritten** — "unreadable now" and
"gone forever" are indistinguishable, an unmounted card comes back, and
discarding the user's choice silently would be the worse failure.
Previewing goes through `RingtonePreviewer`, a two-method seam over the same
`USAGE_ALARM` attributes the ring service uses, so a preview sounds like the
alarm will. One previewer, so a second preview stops the first; selecting an
option, closing the picker and `onCleared()` all stop it. Deliberately no audio
focus and no ramp: a preview is not an alarm.
### The rule a future reader will otherwise simplify away
**A ringing alarm is dismissed through the engine before it is edited, disabled
or deleted.**
`AlarmResolver`'s disabled branch clears `ringingSince` and the engine persists
that — but nothing in that path stops the ring service. The service and the
auto-silence backstop are single *global* slots (§11), touched only by
`closeCycleLocked`, and `onAutoSilence` returns early once `ringingSince` is
null. So switching off or deleting a ringing alarm from this screen would clear
its state and leave it **sounding until the 15-minute wake-lock timeout, with
the backstop disarmed by its own guard**. Both ViewModels therefore call
```kotlin
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
```
before `setEnabled(id, false)` and before `delete(id)`. It reads storage under
the engine's own mutex, so it cannot race the fire it is asking about, and it is
deliberately **conditional**: dismissing unconditionally would also drop a
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
### Immediate apply
There is no Save button, no dirty state and no discard dialog: `PLAN.md` §11
names Google Clock as the interaction reference, and this is its model. The
editor edits a persisted row and every control writes at once. The label is
written on every keystroke through one conflating `MutableStateFlow` — which
writes the first value and the latest, in order, never interleaved — and
`onCleared()` re-launches the last value on the kit's `@ApplicationScope`, so
leaving the editor mid-word cannot lose the word. `reschedule()` is called only
after a change that can move a fire time (create, delete, enabled, time, repeat
days, skip-next), so fiddling with sound settings costs no AlarmManager traffic;
a snooze *length* cannot move a pending snooze, which is stored as an absolute
instant.
Skip-next-occurrence, built in M3 with no caller, is the editor's — and only for
a **repeating** alarm. The resolver answers a one-shot-with-skip by disabling the
alarm, and a switch labelled "skip" that silently switched the alarm off would
be a lie; the user who wants that has the enable switch.
Delete lives in the editor behind a confirmation that names the alarm, and there
is deliberately no undo chip: the delete happens on the editor, so an undo chip
would have to live on the list, and an accidentally deleted alarm is discovered
at 07:00 rather than now — the cheap moment to ask is before.