docs: the timers, and the counts checked against the gate
ARCHITECTURE gains §14 for timers and the ring package's new shape. The test counts were written before the review's fixes landed; they now match what the gate actually runs.
This commit is contained in:
+492
-25
@@ -79,30 +79,39 @@ kit module — the kit's roadmap keeps schedulers app-local.
|
||||
| Package | Holds |
|
||||
|---|---|
|
||||
| `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/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
|
||||
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
||||
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
||||
| `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 — 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/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
|
||||
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `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`, `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` |
|
||||
| `alarm/ring/` | the ringing foreground service, its audio player, its vibrator, its notifications |
|
||||
| `alarm/ring/` | the alarm's ringing foreground service and its notifications. Its audio player and vibrator moved to `ring/` in M6, shared with the timer's ring |
|
||||
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
|
||||
| `ring/` | `RingAudioPlayer` and `RingVibrator` — one `MediaPlayer` wrapper and one vibrator for both ring paths. A build rule fails on a second one |
|
||||
| `timer/` | `TimerEngine` and the three seams it talks to — `TimerScheduler`, `TimerServiceHandle`, `AlarmRingStatus` (+ `AlarmStateRingStatus`) — plus `TimerIntents` |
|
||||
| `timer/android/` | the seams' Android implementations: the one elapsed-realtime AlarmManager slot, the service handle |
|
||||
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) |
|
||||
| `timer/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` |
|
||||
| `timer/di/` | `TimerModule` — `@Binds` for the three seams |
|
||||
| `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/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/common/` | `rememberAlarmTimeFormatter` — the one 12/24-hour formatter the list, the editor and the ring screen share — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
|
||||
| `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 pickers (the ringtone picker moved to `ui/common/` in M6) |
|
||||
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
|
||||
| `ui/stopwatch/`, `ui/worldclock/` | one top-level tab each — a title bar and an empty state until M7–M8 fill them |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
|
||||
---
|
||||
@@ -238,6 +247,17 @@ occurrence, so skipping it is dismissing it in advance.
|
||||
| `sort_order` | INTEGER | indexed |
|
||||
| `created_at`, `updated_at` | INTEGER | wall-clock epoch millis |
|
||||
|
||||
**M6 changes this table by not one column.** No migration, no version bump: the
|
||||
database stays at v2. The only new persistent fact the Timers tab needs is
|
||||
*when the current ring session began sounding*, and that is a **single record**,
|
||||
which §5 of `docs/PLAN.md` sends to DataStore rather than to Room. It is not a
|
||||
`timers` column for the three reasons this section already gives: volatile ring
|
||||
state must not appear in M10's JSON backup of a timer, it must not survive the
|
||||
timer's deletion, and the timer mapper's tests should stay about timers. It is
|
||||
not a `timer_states` table either, because at most one timer ring sounds at a
|
||||
time — so it is `timer_ring_sounding_since_millis` behind `TimerRingStateStore`
|
||||
(§6).
|
||||
|
||||
### `world_clocks`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -389,14 +409,47 @@ approximately-right rather than to never noticing a reboot. The id is stored as
|
||||
one DataStore string, `last_boot_id`, and decoding is strict — anything
|
||||
malformed reads as "no known previous boot", i.e. "repair".
|
||||
|
||||
The mirror image of the reboot repair is the **clock change**. A reboot kills
|
||||
the monotonic anchors and leaves `endsAtWallClock` standing;
|
||||
`ACTION_TIME_CHANGED`/`ACTION_TIMEZONE_CHANGED` does the opposite — the
|
||||
monotonic anchors still hold and the wall-clock one has just become a lie. That
|
||||
value is not decoration: it is the countdown notification's chronometer base and
|
||||
the reboot fallback, so a stale one makes a running timer's notification read
|
||||
*finished* and a reboot after the change ring the timer at the wrong minute. So
|
||||
`TimerEngine.onSystemTimeOrZoneChanged` calls `reanchorWallClocks`, which
|
||||
re-derives `endsAtWallClock` for every RUNNING row from what its **monotonic**
|
||||
anchor says is left, writes nothing when the value has not moved, and leaves a
|
||||
row whose anchor is already stale to `repairAfterReboot`. Nothing is
|
||||
rescheduled: the expiry slot is `ELAPSED_REALTIME_WAKEUP`, so a clock change
|
||||
cannot move it.
|
||||
|
||||
Every timer write is a read-modify-write inside **one transaction**
|
||||
(`TimerDao.updateWithin`): the row is read, the domain rule applied and the
|
||||
result written back before another writer can interleave. The ringing service
|
||||
marking a timer expired and the user adding a minute to it therefore cannot
|
||||
swallow each other's edit. `addTime` on a running timer rebases on the clamped
|
||||
remaining and rewrites all three anchors from a single reading of both clocks,
|
||||
so "+1 min" always grants a whole minute even when the end anchor has already
|
||||
gone by.
|
||||
swallow each other's edit.
|
||||
|
||||
`addTime` — "+1 min" — is one rule over all four states, and the extra is
|
||||
measured **from the tap, never from an anchor that has gone by**:
|
||||
|
||||
| Before | After |
|
||||
|---|---|
|
||||
| `RUNNING` | still running; both end anchors rebased on `snapshot.remaining + extra`, from one reading of both clocks |
|
||||
| `PAUSED` | still paused, `remaining + extra`. The user paused deliberately |
|
||||
| `EXPIRED` | **`RUNNING` with exactly `extra` left**, all three anchors written, in the same one transaction |
|
||||
| `IDLE` | nothing. A stale notification button is a silent no-op |
|
||||
|
||||
`duration_millis` is never moved on any branch, so `reset` still returns the
|
||||
timer to the length the user configured — which is what makes a `timers` row its
|
||||
own preset.
|
||||
|
||||
M6 rewrote the `EXPIRED` branch, which M2 left "paused, not running: the user
|
||||
still has to press start". "+1 min" on a timer that has just rung is the single
|
||||
most common gesture in a timer app, and a second tap on Start is a papercut.
|
||||
Resuming in the same transaction is also the only way to avoid emitting an
|
||||
intermediate `PAUSED` frame to the live pill and to the row — and the reasoning
|
||||
is M2's own, generalised: *the user asked for `extra` more than zero, not
|
||||
`extra` more than an anchor that has gone by.*
|
||||
|
||||
A `RUNNING` row missing either elapsed anchor is treated as corrupt, not as
|
||||
stale-by-reboot: it reads back whatever it last banked, flagged stale. It never
|
||||
@@ -432,7 +485,7 @@ presentation.**
|
||||
## 6. Preferences
|
||||
|
||||
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
|
||||
Three slices:
|
||||
Four slices:
|
||||
|
||||
| Slice | Keys | Owner |
|
||||
|---|---|---|
|
||||
@@ -440,6 +493,7 @@ Three slices:
|
||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `default_timer_duration_millis`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
|
||||
The stopwatch's *run* is a single record, so it lives in DataStore rather than
|
||||
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
|
||||
@@ -457,6 +511,21 @@ value the user could never have chosen. Unknown enum names degrade to their
|
||||
default; a `home_zone_id` the device's tzdata no longer knows reads back as
|
||||
absent; blank strings read back as `null`.
|
||||
|
||||
`timer_presets` is the same discipline over a *list*: one comma-joined string of
|
||||
millis, sanitised both ways through `TimerPresets.sanitise` — non-numeric
|
||||
entries dropped, anything outside 1 s…24 h **dropped rather than clamped**
|
||||
(a clamp would silently turn one preset into another, possibly a duplicate),
|
||||
de-duplicated, sorted ascending, capped at twelve. An **absent** key means the
|
||||
built-in set (1, 2, 3, 5, 10, 15, 30 min, 1 h); an explicitly **empty** value
|
||||
means the user removed them all, and is honoured. Adds and removes go through
|
||||
`PrefStore.update`, so two concurrent edits cannot swallow each other.
|
||||
|
||||
`timer_ring_sounding_since_millis` is wall clock, deliberately: "how long has
|
||||
this been making noise" is a question a reboot must not reset, which is why
|
||||
`alarm_states.ringing_since` is wall clock too. §5's elapsed-realtime rule
|
||||
governs the *countdown*, not the ring's age. Null — the key absent — means no
|
||||
session, and the engine writes it only when the value changed.
|
||||
|
||||
`SettingsPrefs.defaults` and `StopwatchStateStore.run` are each built **once**
|
||||
as a property, not per access, and are distinct-until-changed. A collector keyed
|
||||
on the flow instance (as `collectAsStateWithLifecycle` is) would otherwise tear
|
||||
@@ -472,7 +541,7 @@ before the first frame, and the alternative is an app only "clear data" can fix.
|
||||
|
||||
## 7. Dependency injection
|
||||
|
||||
Hilt, `SingletonComponent` throughout. Five app modules plus the kit's:
|
||||
Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
|
||||
|
||||
| Module | Provides |
|
||||
|---|---|
|
||||
@@ -482,13 +551,14 @@ Hilt, `SingletonComponent` throughout. Five app modules plus the kit's:
|
||||
| `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` |
|
||||
| `TimerModule` | `@Binds` for the timer engine's three seams: `TimerScheduler` (the one elapsed-realtime slot), `TimerServiceHandle` (the foreground service), `AlarmRingStatus` (read-only: "is an alarm ringing") |
|
||||
|
||||
From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
|
||||
process-lifetime `@ApplicationScope` the receivers launch on, and `core-prefs`
|
||||
supplies `PrefStore`, `Pref` and the appearance keys.
|
||||
|
||||
`BroadcastReceiver.onReceive` is abstract, so a Kotlin subclass cannot call
|
||||
`super.onReceive(...)` — which is exactly where Hilt injects. The three
|
||||
`super.onReceive(...)` — which is exactly where Hilt injects. All five
|
||||
receivers therefore extend one concrete no-op base, `HiltBroadcastReceiver`; the
|
||||
Hilt plugin rewrites each receiver's superclass to its generated `Hilt_…` class
|
||||
and the `super` call lands on the injecting one.
|
||||
@@ -508,10 +578,10 @@ block whichever thread asked.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
JVM-first. 624 unit tests run in the gate; 28 instrumentation tests compile in
|
||||
JVM-first. 843 unit tests run in the gate; 32 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/`.
|
||||
or is a pure function in `domain/alarm/`, `domain/ring/` or `domain/timer/`.
|
||||
|
||||
That posture had to be *earned* again when the UI arrived, not merely kept. The
|
||||
shell's whole decision surface was pushed out of the composables and into pure
|
||||
@@ -595,6 +665,46 @@ 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.
|
||||
|
||||
**M6 kept it for a second screen — and for a foreground service**, which is the
|
||||
other place a project usually gives in. Every decision left the composables and
|
||||
the service: `TimerReadings` (the app's one timer precedence, extracted from
|
||||
M4's pill so the pill, the notification and the ring cannot disagree),
|
||||
`TimerExpiry` (what is due, and what the single slot should hold),
|
||||
`TimerRingPolicy` (the whole audio arbitration), `TimerNotificationPolicy`
|
||||
(the subject, the count, the two actions, the alert-once rule), `TimerPresets`,
|
||||
`TimerDurationEntry` (the keypad, as a state machine over a digit string) and
|
||||
`TimerListRows`. `TimerService` holds **no policy and no state** beyond one
|
||||
`alreadyAlerted` flag: it asks `TimerEngine.serviceState(...)` and actuates the
|
||||
answer, and even the two `delay` amounts come from the engine — so the timings
|
||||
are asserted in a JVM test.
|
||||
|
||||
Three new fakes complete the seam set in M3's shape: `FakeTimerScheduler` holds
|
||||
the expiry slot **as the value it currently holds**, `FakeTimerServiceHandle`
|
||||
records the up/down transitions **in order**, and `FakeAlarmRingStatus` is a
|
||||
settable boolean behind a `MutableStateFlow`, so an arbitration test hands the
|
||||
engine a `true` instead of building alarm state.
|
||||
`testing/TimerEngineHarness.kt` is `AlarmEngineHarness`'s arrangement for the
|
||||
other engine: the **real** `TimerEngine` over the real `TimerRepositoryImpl`,
|
||||
the fake DAO, the three fake seams, the real `RebootRepair` and a real DataStore
|
||||
under a `@TempDir` — with a counting `DataStore` wrapper, so "the session anchor
|
||||
is persisted **once** across three reads" is assertable without reaching into
|
||||
the store.
|
||||
|
||||
Three more `ArchitectureRulesTest` rules: `timer/TimerEngine.kt` joins the
|
||||
Android-free list; no file under `ui/` *uses* `TimerScheduler`, `TimerService`,
|
||||
`TimerRingPolicy` or `AlarmManager` (the mirror of the alarm rule — the screen
|
||||
schedules nothing and decides no ring); and `MediaPlayer` appears only under
|
||||
`ring/` and `data/ringtones/`, which makes "reuses M3's audio path" mechanical:
|
||||
a third audio path cannot be added without the build failing.
|
||||
|
||||
What was left to the device is four more instrumentation tests: the keypad and
|
||||
the bottom sheet really composing and really writing, the typed digits
|
||||
surviving a real activity recreation (the only thing that proves
|
||||
`rememberSaveable`), one row's Pause leaving a second running row alone, and a
|
||||
real back press from the timer editor landing on a still-selected Timers tab.
|
||||
**They have not been run**, for the same reason M5's have not: no device is
|
||||
attached to the machine this milestone was built on.
|
||||
|
||||
Stack: JUnit 5 + Truth + Turbine + `kotlinx-coroutines-test`, with
|
||||
`useJUnitPlatform()` and `isReturnDefaultValues = true`.
|
||||
|
||||
@@ -638,9 +748,16 @@ declared because a specific path would otherwise fail — silently, at 07:00.
|
||||
| `RECEIVE_BOOT_COMPLETED` | AlarmManager keeps nothing across a reboot |
|
||||
| `USE_FULL_SCREEN_INTENT` | the ring screen over the lock screen |
|
||||
| `POST_NOTIFICATIONS` | the ring and snoozed notifications |
|
||||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the ringing service |
|
||||
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | the two ringing services — the alarm's, and (since M6) the timers' |
|
||||
| `WAKE_LOCK` | keep the CPU up for the length of a ring |
|
||||
| `VIBRATE` | the alarm vibrates, and vibrates alone when no audio source opens |
|
||||
| `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens |
|
||||
|
||||
**M6 adds no permission at all.** A timer never takes over the screen — no
|
||||
full-screen intent, no ring activity, no `showWhenLocked` — because its user set
|
||||
it minutes ago and is in the room, so the heads-up notification, the audio, the
|
||||
pill's expired state and the tab's expired row are four ways to notice and none
|
||||
of them seizes the device. The foreground-service, wake-lock, vibrate and
|
||||
notification set above already covers the rest.
|
||||
|
||||
Neither of the two revocable ones can silence an alarm. A denied
|
||||
`POST_NOTIFICATIONS` costs the user the notification and nothing else — the
|
||||
@@ -649,7 +766,7 @@ degrades to a `PRIORITY_MAX`, `CATEGORY_ALARM` heads-up notification on the same
|
||||
HIGH-importance channel, which still rings. M3 declares them; M4 asks for them
|
||||
and M10's self-check screen explains them.
|
||||
|
||||
The ringing service's `android:foregroundServiceType` is **`systemExempted`**,
|
||||
Both ringing services' `android:foregroundServiceType` is **`systemExempted`**,
|
||||
which is the case Android's own `fgs-types-required` guidance names: an app
|
||||
holding `SCHEDULE_EXACT_ALARM` or `USE_EXACT_ALARM` and using a foreground
|
||||
service to continue alarms in the background. Not `mediaPlayback` — an alarm is
|
||||
@@ -657,7 +774,7 @@ not the user's media session, and it would owe the store a media justification
|
||||
and emphatically not `shortService`, which caps at about three minutes against a
|
||||
ten-minute ring window.
|
||||
|
||||
The three receivers are `android:exported="false"`: a protected system broadcast
|
||||
All five receivers are `android:exported="false"`: a protected system broadcast
|
||||
is delivered to an unexported receiver anyway (this is how `androidx.work`
|
||||
declares its own `RescheduleReceiver`), and a `PendingIntent` the app created is
|
||||
delivered regardless — so nothing else can fake a fire.
|
||||
@@ -666,8 +783,10 @@ delivered regardless — so nothing else can fake a fire.
|
||||
storage that is unreadable before the first unlock.
|
||||
|
||||
Components: `MainActivity`, the non-exported `CrashReportActivity` and
|
||||
`AlarmRingActivity`, the `AlarmRingService`, the three receivers, and AppCompat's
|
||||
locale metadata holder service.
|
||||
`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's
|
||||
`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`,
|
||||
`SystemEventReceiver`, `TimerExpiryReceiver`, `TimerActionReceiver` — and
|
||||
AppCompat's locale metadata holder service.
|
||||
|
||||
---
|
||||
|
||||
@@ -676,15 +795,20 @@ locale metadata holder service.
|
||||
| Not here | Milestone |
|
||||
|---|---|
|
||||
| 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 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 |
|
||||
| Every tab's real content beyond Alarms and Timers — starting and lapping the stopwatch (M7), the zone list and analog face (M8). Those two keep M4's title bars and empty states | M7–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 |
|
||||
| The `android.provider.AlarmClock` intent surface and its hostile-extra validation (`TimeOfDay.clamped` and `Zones.normalise` exist so M9 has something to call) | M9 |
|
||||
| The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
|
||||
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | M10 at the earliest |
|
||||
| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 |
|
||||
| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | M10 at the earliest |
|
||||
| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 |
|
||||
| A presets management screen, and editing `default_timer_duration_millis` or `default_timer_ringtone_uri`. M6 reads both and offers the two-gesture preset surface on the setup panel | M10 |
|
||||
| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 |
|
||||
| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 |
|
||||
| Screenshots and store listing polish | M11 |
|
||||
|
||||
Also absent by design: any seeded default data — no starter alarm and no home
|
||||
@@ -852,6 +976,18 @@ would edit the user's own alarm volume and leave it edited.
|
||||
`soundsAnyway` is true in all eight capability combinations — asserted
|
||||
exhaustively, because that is the invariant the app lives on.
|
||||
|
||||
**M6 shares this chain with the timer path.** The `MediaPlayer` wrapper, the
|
||||
`USAGE_ALARM` attributes, the transient focus request, the prepare-and-release
|
||||
loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`,
|
||||
`RingFallbackPolicy` and `VolumeRamp` all moved to `ring/` and `domain/ring/`
|
||||
and are used verbatim by both — so "silent" still means "vibration only" for a
|
||||
timer too. What the alarm **keeps to itself** is the full-screen intent, the
|
||||
ring activity, the dismiss challenge and the snooze; what the timer
|
||||
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
|
||||
someone awake, and a gentle start is just a quiet start) and its own
|
||||
auto-silence constant. §14 has the rest, including which of the two wins the
|
||||
one audio stream.
|
||||
|
||||
---
|
||||
|
||||
## 12. The app shell
|
||||
@@ -913,13 +1049,35 @@ wins" rule the alarm engine's precedence already uses, so the app has one rule.
|
||||
Timers outrank the stopwatch because a timer has a deadline: a missed timer costs
|
||||
something, a missed stopwatch tick costs nothing.
|
||||
|
||||
**M6 moved rows 1–3 into `TimerReadings` and rewrote `LivePillSelector` to call
|
||||
it.** The notification and the ring need the same answer — "which of several
|
||||
running timers is this about" — and three copies of one comparator is how three
|
||||
surfaces start disagreeing. `LivePillSelectorTest` passing **unmodified** is the
|
||||
proof the extraction changed no behaviour; `LivePillSelectorPrecedenceTest`
|
||||
cross-checks the two callers against each other. The pill keeps its own
|
||||
three-valued `LivePillMode`, because it has a stopwatch to describe too, mapped
|
||||
from `TimerMode` in one total `when`.
|
||||
|
||||
**M6 also re-pointed the pill's timer actions at `TimerEngine`.** M4 called
|
||||
`TimerRepository.pause/start/reset` directly; after M6 there is an AlarmManager
|
||||
registration and a foreground service that have to move with the state, so
|
||||
pausing from the pill would otherwise leave the expiry slot pointing at a dead
|
||||
deadline and the service posting a stale notification. Self-healing — the next
|
||||
sweep would find nothing due — but wasteful and wrong. The stopwatch branches
|
||||
keep calling `StopwatchRepository` until M7.
|
||||
|
||||
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not
|
||||
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story,
|
||||
stale anchor and all, is honoured once. The consequence that matters: a running
|
||||
timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
|
||||
for M6's expiry service to write `markExpired`. The pill never counts into
|
||||
for the expiry sweep to write `markExpired`. The pill never counts into
|
||||
negative time, and never reads `0:00` while claiming to run.
|
||||
|
||||
M4 noted that this two-writer case could not be demonstrated by hand. **It can
|
||||
now**, and it is correct: the pill and the sweep both read through
|
||||
`Timer.snapshotAt`, so the frame the pill shows before the write lands says
|
||||
exactly what the row will say after it.
|
||||
|
||||
The pill is **not** a live region: at 1 Hz TalkBack would recite the countdown
|
||||
for as long as it ran. The readout carries a full sentence as its content
|
||||
description instead, read when focused and never announced unprompted.
|
||||
@@ -1117,3 +1275,312 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## 14. Timers
|
||||
|
||||
The section to read before changing anything timer-shaped. `AlarmEngine` and
|
||||
`TimerEngine` are **peers**: neither drives the other, and the only thing they
|
||||
share is one read-only question (below).
|
||||
|
||||
### The shape
|
||||
|
||||
`TimerEngine` is the timers' analogue of §11's engine: plain Kotlin, no
|
||||
`android.*` import ever, reaching the platform through three seams it does not
|
||||
implement — `TimerScheduler` (one AlarmManager slot), `TimerServiceHandle` (the
|
||||
foreground service) and `AlarmRingStatus` (read-only: "is an alarm ringing").
|
||||
It owns every write the pure policies ask for, and every public entry point is
|
||||
serialised on one non-reentrant `Mutex`, because they arrive from threads that
|
||||
know nothing about each other: a broadcast on `Dispatchers.Default`, the
|
||||
service's collector, a ViewModel, `ClockulaApp`'s launch pass.
|
||||
|
||||
It is **not** driven by a Flow, for the same reason the alarm engine is not:
|
||||
resolution writes state, so an engine that resynced on every repository emission
|
||||
would re-trigger itself on its own writes. `resync()` is called explicitly.
|
||||
|
||||
The engine owns the **lifecycle** verbs — `start`, `pause`, `reset`, `addTime`,
|
||||
`delete`, plus `onExpiryDue`/`resync`/`onBootCompleted` — because each of them
|
||||
can move the slot, the service or the ring session, and every one of them is
|
||||
reachable from a notification button with no ViewModel in sight. `delete` is on
|
||||
the engine and not on a ViewModel-plus-`resync` path deliberately: deleting a
|
||||
*ringing* thing and leaving the global ring slot sounding is a bug class M5
|
||||
already met once, and the cheapest way not to have it again is to make the wrong
|
||||
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
|
||||
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
|
||||
|
||||
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way
|
||||
`AlarmRepository` does: a timer row carries three derived anchors whose
|
||||
consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can
|
||||
break them. `setDuration` is refused unless the timer is `IDLE`.
|
||||
|
||||
### Expiry: one slot, two triggers, an idempotent sweep
|
||||
|
||||
There is **one** AlarmManager registration for every timer, holding the earliest
|
||||
running deadline, and it carries **no timer id**. The fire is
|
||||
`TimerEngine.onExpiryDue()`: mark *every* timer whose snapshot has reached zero
|
||||
as `EXPIRED`, then re-register the next earliest. Three properties fall out, and
|
||||
each is a test:
|
||||
|
||||
- a fire delivered **late** still expires everything that came due while it was
|
||||
delayed;
|
||||
- a fire delivered **early**, or for a timer the user has since stopped, finds
|
||||
nothing due and writes nothing — so the trigger needs no grace window and no
|
||||
watermark of its own: the anchors *are* the watermark;
|
||||
- two timers expiring in the same second are **one pass**, not a race.
|
||||
|
||||
The base is **`ELAPSED_REALTIME_WAKEUP`**, mirroring the clock the domain
|
||||
anchors on: **alarms are `RTC_WAKEUP`, timers are `ELAPSED_REALTIME_WAKEUP`.**
|
||||
That is not tidiness, it is the requirement — a wall-clock registration moves
|
||||
when the user changes the system time, so a `TIME_SET` would warp a running
|
||||
timer's expiry, which §5 forbids. With this base a `TIME_SET` needs no
|
||||
re-registration at all, and a test asserts the slot's value and every stored row
|
||||
are unchanged across a three-hour clock jump in each direction.
|
||||
|
||||
`setExactAndAllowWhileIdle` is tried first and `setAndAllowWhileIdle` is the
|
||||
fallback. It does **not** go through `SchedulingPolicy`, whose
|
||||
`EXACT_ALARM_CLOCK` branch names `setAlarmClock` — and **a timer must never
|
||||
populate `getNextAlarmClock()`**, because that slot draws the status-bar alarm
|
||||
icon and the lockscreen line and belongs to the user's next *alarm*. Late is
|
||||
survivable; silent is not, so there is no third branch.
|
||||
|
||||
*Known limitation, recorded rather than glossed:* `*AllowWhileIdle` alarms are
|
||||
rate-limited per app while the device is idle. Clockula holds `USE_EXACT_ALARM`,
|
||||
which exempts it, but a device that refuses the grant could deliver a
|
||||
back-to-back sequence of short timers late. The idempotent sweep is one
|
||||
mitigation; the second is that **the service, while alive, `delay`s to the same
|
||||
deadline and calls the same `onExpiryDue()`**. Neither trigger has to be
|
||||
reliable alone.
|
||||
|
||||
Both `delay` amounts come **from the engine** (`TimerServiceState.expiresIn` and
|
||||
`silenceIn`), so the timings are asserted in a JVM test and the service only
|
||||
ever does `delay(d)`. Both are floored at `TimerRing.WAKE_FLOOR` (100 ms) so a
|
||||
rounding error cannot spin the loop.
|
||||
|
||||
### One foreground service, alive exactly while something is active
|
||||
|
||||
`TimerService` is a single `systemExempted` foreground service covering both
|
||||
phases — the countdown and the ring. Its lifetime predicate is the live pill's:
|
||||
**active, not running.** It is up while any timer is `RUNNING`, `PAUSED` or
|
||||
`EXPIRED` and down when every timer is `IDLE` or gone. `PAUSED` is in the
|
||||
predicate for §12's reason: dropping the notification on pause would leave a
|
||||
user who paused from the shade with no way to resume without opening the app.
|
||||
One "active" predicate in the whole app.
|
||||
|
||||
One service and not two, because a timer expiring while another runs must not
|
||||
need a second one, and because the ring phase needs nothing the countdown phase
|
||||
does not already have.
|
||||
|
||||
**The countdown holds no wake lock.** A forty-five-minute timer keeping the CPU
|
||||
awake is a battery bug; the AlarmManager slot is what wakes the device. The
|
||||
*ring* holds one, with a fifteen-minute timeout, exactly as `AlarmRingService`
|
||||
does.
|
||||
|
||||
The service is a **readout and a control surface, never the timekeeper** — the
|
||||
stored anchors and the AlarmManager slot are, and both survive its absence. So
|
||||
`TimerServiceHandle.sync` wraps both the start and the stop in `runCatching`,
|
||||
and the service holds no policy and no state of its own beyond one
|
||||
`alreadyAlerted` flag. Its trigger flow is
|
||||
`combine(timers, alarmIsRinging, defaults)` and deliberately **excludes** the
|
||||
ring session's anchor, so the engine's own anchor write cannot re-trigger the
|
||||
collector that caused it.
|
||||
|
||||
### The audio arbitration: an alarm wins, and the timer's ring is deferred, not lost
|
||||
|
||||
Two `USAGE_ALARM` streams at once is not a feature, and §11's ring service and
|
||||
auto-silence backstop are single global slots. So a timer that expires while an
|
||||
alarm is ringing **is still marked `EXPIRED`** — its data is truthful and the
|
||||
user must learn the pasta is done — and its notification says so, but it **does
|
||||
not open audio**. When the alarm's cycle closes, the timer starts sounding, and
|
||||
*nothing re-arms it*: the decision is a pure function of current state rather
|
||||
than an event.
|
||||
|
||||
```
|
||||
TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, now)
|
||||
```
|
||||
|
||||
`alarmIsRinging` arrives through one read-only seam, `AlarmRingStatus`, backed
|
||||
by `AlarmStateRepository.states()`. The timer engine therefore never reaches
|
||||
into `alarm_states` itself, never calls `AlarmEngine`, and never touches the
|
||||
alarm's ring slot or its auto-silence registration — asserted. A test hands the
|
||||
engine a boolean instead of building alarm state.
|
||||
|
||||
The reverse case is free and symmetric: an alarm firing over a sounding timer
|
||||
flips the flag, the flow re-emits, and the timer's audio stops. `AlarmEngine`
|
||||
writes `ringingSince` *before* it starts its own audio, so the flip leads the
|
||||
sound.
|
||||
|
||||
### One ring session for however many timers, and one window
|
||||
|
||||
- **Expiry is a set.** Every due timer is marked `EXPIRED`.
|
||||
- **The ring is one.** The subject is the first expired timer in §12's order,
|
||||
and its ringtone is the one that plays.
|
||||
- **Stop acknowledges one timer.** Stopping the subject resets *that* timer; if
|
||||
another expired timer remains, the ring continues for the next subject. Each
|
||||
timer is a separate thing the user set, and silently resetting all of them
|
||||
because one was acknowledged destroys the information "which ones finished".
|
||||
Two timers cost two taps, which is correct — there is no "stop all".
|
||||
- **A session is one window.** It opens when the audio *actually starts
|
||||
sounding* and closes when the expired set returns to empty. A second timer
|
||||
expiring mid-session inherits the session's remaining window rather than
|
||||
buying a fresh ten minutes — the conservative direction, and the same rule as
|
||||
§11's "a reboot does not buy the alarm another ten minutes". The anchor is the
|
||||
instant the audio *opened*, not the instant of expiry, so a long alarm ring in
|
||||
front of it does not eat the timer's whole window.
|
||||
- **A lapsed window does not silence the next timer.** A window that has run out
|
||||
belongs to the timers the user chose to leave `EXPIRED`; a timer reaching zero
|
||||
*after* it is a new event and opens a session of its own. The sweep is where
|
||||
that is decided, because the sweep is the only place that knows a timer has
|
||||
just come due: a non-empty due set drops a lapsed anchor, and the next pass
|
||||
opens a fresh window with its own heads-up. Inheriting stays the rule for a
|
||||
timer expiring *inside* a live window.
|
||||
|
||||
`TimerRing.AUTO_SILENCE_AFTER` is **ten minutes** — its own constant with the
|
||||
same value as the alarm's, free to diverge, because coupling the two would make
|
||||
one number answer two questions. The load-bearing difference from an alarm:
|
||||
**auto-silence stops the noise and leaves the row `EXPIRED`.** An auto-silenced
|
||||
alarm is a *missed* alarm and its cycle closes; an auto-silenced timer has still
|
||||
finished, and the user coming back must be able to see that and add a minute
|
||||
to it. Auto-silence is decided **before** suppression, so an alarm ringing over
|
||||
a window that has run out cannot hand it a fresh one.
|
||||
|
||||
`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not
|
||||
jolt a sleeping person awake; a timer is set by someone awake who wants to know
|
||||
*now*, and a gentle start is just a quiet start. `VolumeRamp.levelAt(_, 0s)`
|
||||
already returns `1f`, so "parameterised" means literally one different argument
|
||||
to the same player.
|
||||
|
||||
### The notification: one channel, one id, alert once
|
||||
|
||||
The subject is §12's; the rest are a count.
|
||||
|
||||
| Subject reads | Title | Body | Actions |
|
||||
|---|---|---|---|
|
||||
| `RUNNING` | the label, or "Timer" | the **platform chronometer** counting down | Pause · +1 min |
|
||||
| `PAUSED` | the label | "Paused · 4:32" | Resume · Reset |
|
||||
| `EXPIRED` | the label | "Finished" | Stop · +1 min |
|
||||
|
||||
plus "+2 more timers" when other active timers exist. Two actions at most, and
|
||||
they are the *same pair* the row offers for that mode — one vocabulary, two
|
||||
surfaces. What this gives up knowingly: per-timer controls in the shade for the
|
||||
timers that are not the subject. Those are one tap away in the app, and the
|
||||
alternative — a notification group with one entry per timer — multiplies ids,
|
||||
request codes and orphan-on-kill cases for a rare case.
|
||||
|
||||
`setUsesChronometer(true)` + `setChronometerCountDown(true)` + `setWhen(base)`
|
||||
means the **system** renders the ticking, so the service posts once per state
|
||||
change instead of once per second for forty-five minutes. The base is
|
||||
`timers.ends_at_wall_clock_millis`, which makes it the single wall-clock value
|
||||
in the whole timer path, with one honest consequence: a user changing the system
|
||||
clock while a timer runs leaves that stored base — and so the notification's
|
||||
readout — pointing at the wrong instant, while the timer itself does not move at
|
||||
all. So `SystemEventReceiver`'s `TIME_SET`/`TIMEZONE_CHANGED` branch calls
|
||||
`timerEngine.onSystemTimeOrZoneChanged()` — **not to reschedule anything**, but
|
||||
to re-anchor `ends_at_wall_clock_millis` from the monotonic anchor (§5) and
|
||||
re-post with a base that is true again. A running row with no wall-clock end (a
|
||||
corrupt row) falls back to a static readout rather than a chronometer counting
|
||||
to a wrong instant.
|
||||
|
||||
One channel (`timers`, `IMPORTANCE_HIGH`, sound and vibration off — the service
|
||||
owns both), one id, and **only the first post of a ring session may alert**. So a
|
||||
countdown update never heads-ups and never buzzes, an expiry heads-ups exactly
|
||||
once, and there is no channel gymnastics and no second notification. Every
|
||||
non-alerting post carries `setSilent(true)` as well as `setOnlyAlertOnce(true)`,
|
||||
because `setOnlyAlertOnce` speaks only about *updates* to a notification already
|
||||
on screen — on a high-importance channel the first post of a countdown, and the
|
||||
placeholder the service goes foreground with, would otherwise pop a banner. The
|
||||
rule is a pure function (`TimerNotificationState.alertOnce`); the service owns
|
||||
the flag that spends it and scopes it to the session's window, so it resets both
|
||||
when the decision goes `Silent` and when a new window opens.
|
||||
|
||||
Every action is a `PendingIntent.getBroadcast` into `TimerActionReceiver`, in
|
||||
the shape `AlarmActionReceiver` already has: `goAsync()`, `@ApplicationScope`,
|
||||
one call into the engine, `finish()`. Deliberately **not**
|
||||
`PendingIntent.getService` (a background foreground-service start can be
|
||||
refused; a broadcast cannot) and not an activity (a control should not have to
|
||||
open the app). Nothing is carried across but the timer id, which can be stale —
|
||||
so **every engine verb is guarded by the state it makes sense for**, a mismatch
|
||||
is a silent no-op, and the notification is re-posted from the truth afterwards.
|
||||
Request codes use the `base * 100_000 + (id % 100_000)` stride
|
||||
`RingNotifications` already uses, and a test asserts the **union** of alarm and
|
||||
timer codes is distinct, because that collision is how one `PendingIntent`
|
||||
silently eats another's extras.
|
||||
|
||||
Tapping the notification opens the **Timers tab**, through the smallest honest
|
||||
deep link: the content intent carries `TimerIntents.ACTION_SHOW_TIMERS`,
|
||||
`MainActivity` reads it in `onCreate` **and** `onNewIntent` into one
|
||||
`mutableStateOf`, and `ClockulaShell` consumes it once by replaying
|
||||
`ShellNavigation.onTabSelected` — the same command a tab tap produces.
|
||||
`startDestination` stays **Alarms**: it is the `popUpTo` anchor and the root of
|
||||
M4's asserted back policy, so changing it for a notification tap would rewrite
|
||||
that policy. The pure part is `ShellNavigation.tabForAction(action)`, which M9
|
||||
extends with the rest of the `AlarmClock` contract.
|
||||
|
||||
### Process death and reboot
|
||||
|
||||
- **Process death** costs nothing. The countdown lives in
|
||||
`ends_at_elapsed_realtime` and the AlarmManager registration survives the
|
||||
process. `ClockulaApp.onCreate` calls `timerEngine.onBootCompleted()` after
|
||||
the alarm engine's pass, which sweeps, re-registers and re-syncs the service.
|
||||
If the service itself was killed, the expiry broadcast starts it again.
|
||||
- **Reboot** is §5's `RebootRepair`, and M6 gave it a caller that *rings*:
|
||||
`TimerEngine.onBootCompleted()` calls `repairIfRebooted()` inside its own
|
||||
lock, then sweeps — so a timer whose end passed during the reboot is expired
|
||||
by the repair and **sounds when the device comes back**. The boot-id gate
|
||||
makes the second call of a boot a no-op, so neither engine depends on the
|
||||
other's ordering.
|
||||
- **`MY_PACKAGE_REPLACED`** clears AlarmManager, so it calls `resync()`.
|
||||
|
||||
### The tab
|
||||
|
||||
A `LazyColumn` in storage order — `sortOrder`, then `id`, which is exactly what
|
||||
the repository emits, so the screen re-asserts the order rather than inventing
|
||||
one. **An expired timer does not jump to the top**: a row that moves under a
|
||||
thumb is worse than a row the user has to look for, which is the answer M5 gave
|
||||
for alarms. The pill and the notification pick a subject; the list does not
|
||||
reorder for it.
|
||||
|
||||
Setting a timer is a keypad, a row of preset chips, the readout and one
|
||||
**Start** — and the same panel is hosted in a `ModalBottomSheet` over a
|
||||
non-empty list and *inline* on an empty one, where it **is** the empty state, so
|
||||
there is no "no timers yet" card to look at and then dismiss. Deliberately not
|
||||
M5's create-then-navigate: an alarm is live the moment it exists, so creating it
|
||||
first is honest, while a timer has a natural commit point and a row created by
|
||||
an accidental FAB tap would be litter. There is still no sentinel id, because
|
||||
the panel is not bound to a row at all.
|
||||
|
||||
The typed digits and the sheet's open flag live in the **composable** as
|
||||
`rememberSaveable`, not in a ViewModel, so they survive rotation *and* process
|
||||
death; the pure `TimerDurationEntry` is derived from the saved digit string, and
|
||||
`Start` is guarded by a ViewModel re-entrancy flag. `TimerDurationEntry` shifts
|
||||
digits in from the right and reads them as `h*3600 + m*60 + s` **without
|
||||
clamping minutes or seconds to 59**, so `0:00:99` is 99 seconds — Google Clock's
|
||||
forgiving behaviour, and a pure function with obvious tests. `canStart` is false
|
||||
outside 1 s…24 h, which is `ClockPrefs`' own clamp, so nothing the user can
|
||||
choose is clamped away on the next read.
|
||||
|
||||
Presets are **app-wide** quick-start durations in DataStore (§6), not a property
|
||||
of one row: a `timers` row already *is* its own preset, because it persists and
|
||||
`reset` returns it to its configured duration. The whole management surface is
|
||||
two gestures on the setup panel — a Save chip when the typed duration is not
|
||||
already one, and each chip's own remove affordance. A new timer's pre-filled
|
||||
duration is `ClockDefaults.timerDuration`, the `default_timer_duration_millis`
|
||||
preference M2 built and nothing had called until now.
|
||||
|
||||
A timer has exactly **one** per-setting override, its ringtone, because `timers`
|
||||
has exactly one nullable settings column. Vibrate and the (zero) ramp come from
|
||||
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits
|
||||
all of its behaviour for free: "App default" naming what it resolves to, Silent
|
||||
as an explicit sentinel that forces vibration, a SAF-picked file that is still
|
||||
stored when its grant cannot be made persistent, and an unreadable sound
|
||||
**reported, never rewritten**.
|
||||
|
||||
The expressive parts are `LinearWavyProgressIndicator` across each card — whose
|
||||
**amplitude is the state**: the default wave while running, flat while paused or
|
||||
finished — a kit `GroupedSurface` per card with `positionOf(index, count)`, a
|
||||
`ButtonGroup` of `FilledTonalButton`s with the press-widening `animateWidth`,
|
||||
and `tertiaryContainer` for an expired card, which is *exactly* the token the
|
||||
live pill already uses for an expired timer, so the two surfaces agree. The
|
||||
readout is `headlineLarge` and the setup panel's entry is `displayMedium` —
|
||||
plain scale roles, because `ui/theme/Type.kt` says the big-readout ramp gets
|
||||
settled against a working stopwatch in M7, not guessed at in the scaffolding.
|
||||
|
||||
Reference in New Issue
Block a user