docs: the stopwatch, and why its reading is floored and banked

`ARCHITECTURE.md` gains §15 for the stopwatch and records the one correctness
find this slice turned up: a lap taken inside a segment a reboot later
discarded would have dragged the readout, the lap totals and the shade
backwards. The reading is floored at the last lap's total — and the floor is
banked, not merely displayed, or the shade's free-running chronometer walks
away from a tab that is standing still.

The package, module, receiver, service and permission tables all count one
more; §10 loses the two rows M7 closed.
This commit is contained in:
2026-09-22 10:24:08 +02:00
parent 83995c39db
commit cb35cc9c95
2 changed files with 273 additions and 12 deletions
+16
View File
@@ -138,3 +138,19 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
- Progress on each timer card is drawn by the Expressive wavy indicator, and the - Progress on each timer card is drawn by the Expressive wavy indicator, and the
wave itself is the state — it flattens when you pause and when the timer is wave itself is the state — it flattens when you pause and when the timer is
done. done.
- The stopwatch tab, for real: start, pause and reset, with a readout down to
hundredths of a second that stays legible because the fraction is drawn
smaller than the seconds rather than shouting alongside them.
- Laps, each with its own split and its running total, newest at the top, and
the fastest and slowest of them marked — in colour and in words, so a screen
reader hears the distinction too. The lap you are in the middle of has its own
row, counting up.
- An ongoing notification with Lap and Pause in the shade, which costs no
battery to keep accurate because the system draws the ticking; Clockula posts
once when something actually changes.
- A stopwatch that keeps counting while the app is closed, and that comes back
after a restart paused — with every lap intact — at the time it had banked,
rather than inventing the time it lost. Changing the system clock, in either
direction, cannot move it at all.
- The app's big numbers now sit in fixed columns, so a readout no longer jitters
as its digits change — on every screen that shows one, not only the stopwatch.
+257 -12
View File
@@ -1,6 +1,6 @@
# Clockula — architecture # Clockula — architecture
How Clockula is built **today**, after M3. Where something does not exist yet, How Clockula is built **today**, after M7. Where something does not exist yet,
this document says so and names the milestone that builds it, rather than this document says so and names the milestone that builds it, rather than
describing a plan as though it were code. `PLAN.md` is the "why"; this is the describing a plan as though it were code. `PLAN.md` is the "why"; this is the
"what, right now". "what, right now".
@@ -81,15 +81,16 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `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.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 presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 | | `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/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
| `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/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/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/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 | | `domain/format/` | `ClockFormat` — `M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated — `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat` — the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
| `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` | | `data/db/` | `ClockulaDatabase` — `@Database` v2, `exportSchema = true` — and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories | | `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) | | `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)`, `TimerRingStateStore` (the ring session's one DataStore record) |
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` | | `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
| `data/stopwatch/` | `LapEntity`, `LapDao`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` | | `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `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/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/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
@@ -105,13 +106,19 @@ kit module — the kit's roadmap keeps schedulers app-local.
| `timer/receiver/` | `TimerExpiryReceiver` (the slot arriving), `TimerActionReceiver` (the notification's buttons) | | `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/service/` | `TimerService` — one foreground service for the countdown and the ring — and `TimerNotifications` |
| `timer/di/` | `TimerModule` — `@Binds` for the three seams | | `timer/di/` | `TimerModule` — `@Binds` for the three seams |
| `stopwatch/` | `StopwatchEngine` and the one seam it talks to — `StopwatchServiceHandle` — plus `StopwatchIntents` |
| `stopwatch/android/` | `ServiceStopwatchHandle`, the seam's Android implementation |
| `stopwatch/receiver/` | `StopwatchActionReceiver` (the notification's buttons; no extras, because there is one stopwatch) |
| `stopwatch/service/` | `StopwatchService` — the `specialUse` foreground service — and `StopwatchNotifications` |
| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam |
| `system/` | `RebootRepair` — the boot-id gate | | `system/` | `RebootRepair` — the boot-id gate |
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | | `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/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 — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need | | `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/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/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/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
| `ui/worldclock/` | one top-level tab — a title bar and an empty state until M8 fills it |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | | `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
--- ---
@@ -292,6 +299,12 @@ broken. Losing the user's row silently would be worse.
index and the split, and inserts — so two concurrent laps cannot collide on the index and the split, and inserts — so two concurrent laps cannot collide on the
unique index. unique index.
**M7 changes no schema here.** The table already had every column the tab
needs, so the stopwatch milestone adds no column, no version bump and no
migration; `LapDao` gains exactly one `@Query("SELECT COUNT(*) …")`, which the
engine's lap cap reads so appending a lap never materialises 999 rows. The
database stays at **version 2**.
### The repeat mask ### The repeat mask
Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion Bit `n` is ISO day `n + 1`: **Monday is bit 0, Sunday bit 6**, so the conversion
@@ -480,6 +493,42 @@ knowingly-fabricated elapsed reading on screen for two more milestones was not
defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its defensible. **M3 owns the stopwatch's post-reboot repair; M7 owns its
presentation.** presentation.**
**M7 closes the loop.** `RebootRepair`, `pauseAfterReboot` and `snapshotAt` are
untouched; what M7 adds is what the user then sees, and it is decided in one
pure place, `StopwatchReadings.of` (§15). The rule it carries is that **the mode
comes from the snapshot, not from the stored row**: a `RUNNING` record whose
anchor did not survive reads `PAUSED` at what it banked, so a stale run shows
Resume · Reset rather than a Pause button and a ticking chronometer, on all
three surfaces at once.
One honest consequence, recorded rather than papered over. A lap recorded inside
a segment the repair later discarded keeps its own cumulative, which can exceed
the run's banked `accumulated` — a stopwatch that ran forty seconds and lapped
twice, then met a reboot, has `accumulated = 0` and `lastLapCumulative = 40s`.
The reading is therefore **floored at the last lap's total** as well as at zero:
a readout sitting below a lap the same run printed is not one anyone can
believe, and the lap is a real measurement. The laps themselves are never
deleted to make the arithmetic tidy, and the in-progress row's split is clamped
at zero for the same reason. Reset clears all of it in one tap.
The floor is **banked, not only displayed**. Resuming such a run writes
`accumulated = max(accumulated, lastLapCumulative)`, and pausing and lapping
bank the same floored reading rather than the raw snapshot. Without that the
record would tick from below its own last lap: the tab would sit frozen at the
floored value for as long as the discarded segment lasted while the shade's
chronometer — derived from the floored reading once and then free-running —
counted on without it, and the next lap would record a cumulative *below* the
previous one and drag every readout backwards. `pauseAfterReboot` itself is
still untouched; the banking happens on the way back out, in
`StopwatchRepositoryImpl.start`, `pause` and `lap`.
For the same reason the engine's verbs guard on **the reading's** mode rather
than the stored row's, so they agree with the controls the user is being
offered: between a reboot and the repair, a record that still says `RUNNING`
reads `PAUSED` everywhere, and its Resume button resumes (banking first, as the
repair would have) instead of hitting a silent "already running" no-op, while
Lap is refused rather than recorded below the last one.
--- ---
## 6. Preferences ## 6. Preferences
@@ -491,7 +540,7 @@ Four slices:
|---|---|---| |---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names | | Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs` — the family's shared key names |
| 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>` | | 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` | | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` — the last of which M7 is the first caller of, for the in-progress lap's split | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore` — the persisted half of the reboot gate | | 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` | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs` — the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -541,7 +590,7 @@ before the first frame, and the alternative is an app only "clear data" can fix.
## 7. Dependency injection ## 7. Dependency injection
Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's: Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's:
| Module | Provides | | Module | Provides |
|---|---| |---|---|
@@ -551,6 +600,7 @@ Hilt, `SingletonComponent` throughout. Seven app modules plus the kit's:
| `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` | | `TimeModule` | `@Binds` for `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` and `BootIdProvider` |
| `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` | | `AlarmModule` | `@Binds` for the alarm engine's four seams: `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` |
| `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` | | `RingtoneModule` | `@Binds` for the two ringtone seams: `RingtoneCatalog`, `RingtonePreviewer` |
| `StopwatchModule` | `@Binds` for the stopwatch engine's one seam: `StopwatchServiceHandle` (the foreground service). It has no scheduler, because nothing the stopwatch does has a deadline |
| `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") | | `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 From floret-kit: `core-di`'s `CoroutinesModule` supplies `@IoDispatcher` and the
@@ -795,10 +845,9 @@ AppCompat's locale metadata holder service.
| Not here | Milestone | | 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 | | 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 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 | | Every tab's real content beyond Alarms, Timers and the Stopwatch — the zone list and the analog face. The world clock keeps M4's title bar and empty state | 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 | | 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 | | 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 |
| Stopwatch presentation, best/worst lap analysis (its post-reboot repair shipped in M3) | M7 |
| ICU city and zone display names, offsets, day differences | M8 | | 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 `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 | | The self-check screen ("why might my alarm not ring?"), settings screen, JSON backup / SAF export | M10 |
@@ -809,6 +858,13 @@ AppCompat's locale metadata holder service.
| 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 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 | | 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 | | 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 |
| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 |
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
| A user-configurable lap cap or readout precision. Both are constants with reasons — 999 laps, hundredths — and M10's settings screen is not asked to carry them | not planned for v1 |
| Reordering, grouping or filtering laps. The list is index order, newest first, and that is the whole of it | not planned for v1 |
| A countdown-to-start, split-lap alarms, or any sound or vibration from the stopwatch. It is silent, and a build rule says so (§8) | not planned for v1 |
| An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 |
| Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 |
| Screenshots and store listing polish | M11 | | Screenshots and store listing polish | M11 |
Also absent by design: any seeded default data — no starter alarm and no home Also absent by design: any seeded default data — no starter alarm and no home
@@ -1063,11 +1119,18 @@ from `TimerMode` in one total `when`.
registration and a foreground service that have to move with the state, so 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 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 deadline and the service posting a stale notification. Self-healing — the next
sweep would find nothing due — but wasteful and wrong. The stopwatch branches sweep would find nothing due — but wasteful and wrong.
keep calling `StopwatchRepository` until M7.
The mode and value come from `Timer.snapshotAt` / `StopwatchRun.snapshotAt`, not **M7 did the same for the stopwatch branches**, for the same reason: after M7
from the stored row — so §5's whole elapsed-realtime-versus-wall-clock story, there is a foreground service that has to move with the state, so pausing from
the pill would otherwise leave the shade showing a running stopwatch, and Stop
would leave the service up with nothing to show. The pill's own contract is
unchanged — it shows for RUNNING and PAUSED, never IDLE; Stop is `reset()` and
never `delete()`; and it gets **no Lap button**, because the pill has two
controls by design.
The mode and value come from `TimerReadings` / `StopwatchReadings`, both of them
over the stored row's *snapshot* rather than over the row — so §5's whole elapsed-realtime-versus-wall-clock story,
stale anchor and all, is honoured once. The consequence that matters: a running 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 timer that reaches zero flips the pill to `EXPIRED` immediately, without waiting
for the expiry sweep to write `markExpired`. The pill never counts into for the expiry sweep to write `markExpired`. The pill never counts into
@@ -1584,3 +1647,185 @@ 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` — 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 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. settled against a working stopwatch in M7, not guessed at in the scaffolding.
---
## 15. The stopwatch
Read this before changing anything stopwatch-shaped.
### The shape
`StopwatchEngine` is the third engine and a peer of the other two: plain Kotlin,
**no `android.*` import ever** (a build rule), reaching the platform through
**one** seam it does not implement, `StopwatchServiceHandle`. Every public entry
point is serialised on one non-reentrant `Mutex`, because they arrive from
threads that know nothing about each other — a notification-button broadcast,
the service's own collector, the tab's ViewModel, the shell's pill,
`ClockulaApp`'s launch pass.
It is deliberately much smaller than `TimerEngine`, because the stopwatch has
less to own: **no AlarmManager registration at all** (nothing has a deadline),
**no ring** (nothing expires), **no arbitration** (it makes no sound), and one
record rather than N rows. What it does have is four verbs — `start`, `pause`,
`lap`, `reset` — reachable from the shade with no ViewModel in sight, which is
why they live on the engine and not on a screen. `StopwatchRepository` stays the
storage face and gained exactly one method, `lapCount()`; nothing above the
engine calls its lifecycle verbs any more, including `ShellViewModel` (§12).
Like the other two it is not driven by a Flow: `resync()` is called explicitly.
### The service type, and why not `systemExempted`
`specialUse`, with a `stopwatch` subtype, and one new permission. The reasoning
is in §9 and is the decision in this milestone worth looking at hardest: the
easy answer would have the app tell the platform it is continuing alarm
functionality, which it is not.
The service is up while the run is `RUNNING` **or** `PAUSED`, and down when it
is `IDLE` — exactly the live pill's predicate and exactly
`StopwatchReadings.isActive`, so the shade, the pill and the tab cannot disagree
about whether there is a stopwatch. `PAUSED` is in it so a user who paused from
the shade still has a Resume there.
One consequence, accepted: after a reboot the repair leaves a running stopwatch
paused, so the service comes back up with a paused notification nobody asked
for. It is truthful — the stopwatch *is* paused and its laps are intact — and
its own Reset clears it in one tap. Resetting automatically would destroy laps
the user recorded, which is worse.
### The chronometer base is derived and never stored
The running notification is the **platform chronometer counting up**:
`setUsesChronometer(true)` + `setChronometerCountDown(false)` + `setWhen(base)`,
so the system renders the ticking and the service posts once per state change
rather than sixty times a second. The base is `wallClock.now() - elapsed`,
computed at post time and **never written anywhere** — which is how the
notification gets a wall-clock base without breaking §5's "`StopwatchRun`
carries no wall-clock field whatsoever", a rule a test asserts on the stored
bytes. The timer's equivalent base *is* a stored column and therefore needs
re-anchoring on a clock change; the stopwatch's needs nothing but a re-post,
which is all `onSystemTimeChanged()` does.
The shade's readout is therefore whole seconds. A *paused* body is frozen, so it
shows the full `StopwatchFormat.precise` value — it is stable, and the precision
is the point of having stopped. The channel is its own, `stopwatch`, at
`IMPORTANCE_LOW` with sound and vibration off: nothing here ever needs to
heads-up, because nothing here ever finishes. That is why there is no
alert-once rule, no `alreadyAlerted` flag and no session window anywhere in this
path. Notification id 1_003; actions are broadcasts into
`StopwatchActionReceiver` carrying **no extras at all**, because there is
exactly one stopwatch.
### The readout is hundredths, and it is two strings
`StopwatchFormat.readout` returns a `StopwatchReadout(major, hundredths)`, not
one string: the tab draws `major` at the hero scale and `hundredths` two ranks
down in `onSurfaceVariant`, baseline-aligned. A readout whose hundredths are the
same size as its seconds makes the fastest-changing glyphs the loudest ones and
drags the eye off the number that matters.
`major` **is** `ClockFormat.elapsed`, called rather than reimplemented, so the
stopwatch, the live pill and the timer row can never disagree about what a
duration reads, and the truncate-never-round rule is inherited rather than
restated. `hundredths` is `(millis % 1000) / 10`, zero-padded through
`Locale.ROOT` for the same glyph-column reason. Truncated, never rounded: a
stopwatch must never read ahead of reality. Hundredths and not tenths (too
coarse to time anything by hand) and not milliseconds (three digits that are
noise at 60 Hz).
### `StopwatchReadings`: one reading, three callers
`StopwatchReadings.of(run, elapsedRealtime)` is the single pure reading, and
`LivePillSelector`, `StopwatchNotificationPolicy` and `StopwatchRows` all call
it — the same extraction M6 made for `TimerReadings`, and for the same reason:
three copies of "what does this read right now" is how three surfaces start
disagreeing. `LivePillSelectorTest` passing **unmodified** is the proof the
extraction changed no behaviour. Its two rules are in §5: the mode comes from
the snapshot, and the value is floored at the last lap's total.
### Best and worst
Decided in `LapStats`, because the roadmap left them open:
- **Fewer than three recorded laps ⇒ no emphasis at all** (`LapStats.MIN_LAPS`,
asserted as a constant so a change is deliberate). With one lap there is
nothing to compare; with two, "best" and "worst" mark every row and say the
same thing twice.
- **Ties go to the lowest lap index** — "first to achieve it" is the defensible
reading and it is deterministic.
- **If the fastest and the slowest split are equal, neither exists.** Every lap
identical is not a run with a best and a worst in it.
- **The in-progress lap is never emphasised** and is never in `LapStats`' input:
it has not finished, so it cannot be the fastest. A lap can therefore never be
both, which falls out of the rules rather than needing a guard.
Best is `colorScheme.primary` and worst is `colorScheme.tertiary` — both scheme
tokens, and `error` is deliberately not used, because a slow lap is not a
failure. Colour is not the only channel: each row carries a content description
naming what it is, since a distinction a screen reader cannot hear is not a
distinction.
### The tab
A fixed readout and its controls **above** a lap list — scrolling a long list
must not put Lap out of reach mid-run — and the list runs **newest first**,
which is Google Clock's order (`PLAN.md` §11). A lap row is a three-column table
(index · split · total) and therefore a bespoke `Row` rather than a
`GroupedRow`: `ListItem` has one trailing slot and a table has two figures.
The first row is the **lap in progress**: index `laps.size + 1`, cumulative the
current reading, split `elapsed - lastLapCumulative` floored at zero — which is
what `stopwatch_last_lap_cumulative_millis` has existed for since M2 with no
caller. It appears **only once at least one lap has been recorded**: with none,
its split *and* its cumulative equal the hero figure, so the tab would print the
same number three times. It is shown while paused too, frozen, because the user
stopped mid-lap and that is true.
The vocabulary is **Start · Pause/Lap · Resume/Reset**. An idle stopwatch offers
one button: a Lap with nothing to lap and a Reset with nothing to reset are dead
controls. Reset is not offered while running — the user pauses first, which is
Google Clock again; the pill's Stop still resets from any state, which is M4's
contract and unchanged. The word "Stop" is deliberately absent from this tab: in
Clockula "Stop" already means "reset", and a Stop that paused would be the one
inconsistency in the app's verbs.
The cadence is `StopwatchDefaults.ReadoutTick` — 16 ms, about 60 Hz, the
display's own cadence — and the ticker is subscribed **only while the stored
state is RUNNING**. A paused or idle stopwatch therefore costs nothing, and the
flow is distinct-until-changed, which absorbs the one case where the record says
running and the reading does not move. The cadence is a `Ticker` parameter, never
a `delay` at a call site.
`StopwatchLaps.MAX = 999`. Beyond it the Lap button is disabled (carrying "Lap
limit reached" as its description) and `StopwatchEngine.lap()` returns null. An
unbounded table behind a button that can be held down is a storage leak the user
cannot see or clear except by resetting — and 999 keeps the index column three
digits wide, which is a layout fact as well as a limit. The guard is the
engine's, under its lock, over `lapCount()`.
Deliberately no `MaterialShapes` morph and no wavy progress: a stopwatch has no
progress — there is nothing to be a fraction of — and a decorative arc would be
a lie drawn to two decimal places.
### The typography, settled here for the whole app
`ui/theme/Type.kt` carried this as a written promise from M0, and M7 settles it
as two things.
`ClockulaTypography` applies **tabular figures** (`fontFeatureSettings = "tnum"`)
to the display, headline and title roles of the M3 default. That is the app-wide
half: every existing readout — `TimerRow`'s `headlineLarge`, `TimerSetupPanel`'s
`displayMedium`, the live pill's `titleMedium` — stops jittering as its digits
change, with **no call-site change anywhere**. Body and label roles keep
proportional figures, because prose is not a column of numbers.
`ClockulaReadoutDefaults` names the three roles a readout needs, in the M3
`*Defaults` shape (composable getters over `MaterialTheme.typography`, so a
theme change still flows): `Hero` (`displayLarge`, `FontWeight.Medium`),
`HeroFraction` (`headlineMedium`, about half the hero's size) and `Figure`
(`bodyLarge` with tabular figures — a figure inside a table row, the one place
body-scale text *is* a column of numbers). No new font, no size outside the M3
scale, and no weight above Medium: `PLAN.md` §8's "Expressive ≠ big or bold"
applies hardest to the one screen most tempted to break it. A build rule keeps
`fontFeatureSettings` under `ui/theme/` so it is configured once.