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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user