diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ccb90..54caf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 512ecb3..21cda50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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)` 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.