From d4120d8e0659c489ad5dbdba1c4fa86396b484ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 12 Sep 2026 13:07:53 +0200 Subject: [PATCH] docs: the app shell, and what it earned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE gains §12 for the shell and loses the two rows M4 paid off; the permissions row now says what M4 actually asks for and leaves the rest to M10's self-check. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV --- CHANGELOG.md | 24 ++++++++ docs/ARCHITECTURE.md | 137 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 248df96..b4ccb90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,3 +62,27 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING fallbacks ends in vibration, never in silence. - The post-reboot repair: a running timer no longer counts down from an anchor the reboot killed, and the stopwatch no longer invents a segment it never ran. +- The app shell: four tabs — alarms, timers, stopwatch, world clock — under a + navigation bar that becomes a rail on a tablet, chosen by the window's size + rather than by hand. Back from a tab goes home to Alarms; back from Alarms + leaves the app, and the predictive-back gesture previews whichever of the two + is actually about to happen. +- The live pill: whatever is counting is visible from every tab, and pausable + and stoppable without going to find the row that owns it. It stays up while + paused — a control that vanishes under the thumb that pressed it is no control + — shows how many other timers are running behind it, and flips a timer that + reaches zero to "finished" the moment it does, rather than counting into + negative time. Stop resets; it never deletes a timer the user configured. +- The real ring screen, over the lock screen with the display kept on for + exactly as long as the window lives: the time, the label, snooze, and an + optional dismiss challenge — a two-term sum, or a two-second hold — that can + never strand anyone. Three wrong answers or sixty seconds of ringing, either + one, opens a way out; snooze is never gated; solving the challenge dismisses + on the spot instead of asking twice; and the hold completes from an + accessibility click, so a screen reader is not locked out of its own alarm. + Back cannot silence an alarm. +- The first Compose motion: fade-through between peer tabs, the kit's springs for + the pill coming and going and for the challenge block opening, all of which + 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 725f03d..512ecb3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,14 +80,16 @@ kit module — the kit's roadmap keeps schedulers app-local. |---|---| | `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.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` | +| `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 | | `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` | -| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider` | +| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` | +| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` | | `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` | | `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 | @@ -96,7 +98,9 @@ kit module — the kit's roadmap keeps schedulers app-local. | `alarm/di/` | `AlarmModule` — `@Binds` for the four seams | | `system/` | `RebootRepair` — the boot-id gate | | `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface | -| `ui/ring/` | `AlarmRingActivity` — M3 ships its window flags and a placeholder; M4 ships the screen | +| `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/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls | --- @@ -482,11 +486,25 @@ half already runs on `@IoDispatcher`, wired once in `DataModule`. ## 8. Testing -JVM-first. 324 unit tests run in the gate; twelve instrumentation tests compile +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: everything that would have needed a shadow is behind one of the injected seams, or is a pure function in `domain/alarm/`. +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 +Kotlin: `ShellNavigation` (what a tab tap does to the back stack, and where back +goes), `LivePillSelector` (which of several running things the pill is about), +`ClockFormat` (the readout) and `ChallengeGate` + `MathProblems` (the barrier in +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 +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. + - **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a `MutableStateFlow>`, so a write re-emits on the observing flow exactly as Room's would. The three abstract DAOs are *extended*, not @@ -605,10 +623,9 @@ locale metadata holder service. | Not here | Milestone | |---|---| -| The ring screen itself — its layout, its motion, its dismiss challenge, its ViewModel. M3 ships the window flags and a plain placeholder | M4 | -| Any other UI, ViewModel or navigation beyond the theme | M4+ | | Alarm list, edit surface, time picker, ringtone picker, repeat-day selector, per-alarm override UI | M5 | -| The runtime permission *requests* and the exact-alarm / full-screen-intent deep links. `AlarmCapabilities.snapshot()` exists; asking is M4's and explaining is M10's | M4 / 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 — 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 | | 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 | @@ -768,3 +785,107 @@ would edit the user's own alarm volume and leave it edited. `RingPresentationPolicy` decides how the ring is *presented*, and its `soundsAnyway` is true in all eight capability combinations — asserted exhaustively, because that is the invariant the app lives on. + +--- + +## 12. The app shell + +Four tabs, one host, and one surface that follows whatever is running. + +### Navigation + +`NavigationSuiteScaffold` from `material3-adaptive-navigation-suite`, taking its +default `navigationSuiteType`: the M3 Expressive **short navigation bar** on a +compact width and the **wide rail** on medium and expanded ones. Taking the +default rather than writing `if (compact) NavigationBar else NavigationRail` is +the point — the default also answers the tabletop posture and the compact-*height* +case a hand-rolled branch quietly gets wrong. + +The `NavHost` is the single source of truth for the selected tab. There is no +`var selectedTab by rememberSaveable`: the selection is derived from +`currentBackStackEntryAsState()`, and Navigation Compose already saves its back +stack through a `SavedStateHandle`, so the tab survives rotation and process +death with no mirror of ours to drift. + +The policy that back stack follows is a pure function, `ShellNavigation`: + +- a tab tap is `popUpTo(start) { saveState = true }` + `launchSingleTop` + + `restoreState`; re-selecting the tab you are already on is the same command + with `restoreState = false`, which pops that tab's own inner stack back to its + root — the standard behaviour, and the contract M5's editor will rely on; +- back from any of the other three tabs returns to **Alarms**; back from Alarms, + from a null route and from any route the shell does not recognise leaves the + app — a nested destination's back belongs to the `NavController`, not to us. + +Predictive back is the kit's `Modifier.predictiveBack`, gated on exactly that +policy: on Alarms the handler is *off*, so the gesture falls through to the +system and previews leaving the app, which is the truthful preview. Tab switches +use the kit's `fadeThrough()` — peer destinations have no spatial relationship, +and a slide would claim a hierarchy that is not there. + +### The live pill + +`PLAN.md` §9 asks for a running-state surface reachable from every tab. Its real +predicate is **active, not running**: pausing from the pill must not make the +pill vanish under the thumb that pressed it, or there is no way to resume or +stop from another tab — which is the flaw the pill exists to fix. So it shows for +`RUNNING`, `PAUSED` *and* `EXPIRED`, and its own Stop — which is `reset()`, +never `delete()` — is what removes it. + +Precedence is total; first match wins: + +| # | Subject | +|---|---| +| 1 | a timer that reads as **expired right now** (stored `EXPIRED`, or stored `RUNNING` whose snapshot has reached zero) | +| 2 | a timer that reads as **running**, the one with the smallest remaining | +| 3 | a timer that reads as **paused** | +| 4 | the **stopwatch**, when its state is not `IDLE` | +| 5 | otherwise no pill | + +Ties inside a timer bucket break on `sortOrder` then `id` — the same "lower id +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. + +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 +negative time, and never reads `0:00` while claiming to run. + +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. + +### The third time-shaped seam + +`Ticker` joins `WallClock`, `ElapsedRealtimeClock` and `ZoneProvider` in +`domain/time/`. A readout has to advance without a repository write, and the +discipline of §5 is that time is a *parameter*: so "re-read the clocks now" +became an injected flow rather than a `delay` at a call site. It emits once +immediately and then every period — one second for the pill, since the pill +formats to whole seconds — so a fresh subscriber is never blank. `RealTicker` is +nothing but `delay`, which makes it testable under `runTest`'s virtual clock. + +### The dismiss challenge, and why it cannot strand anyone + +`ChallengeGate` is a pure state machine whose single boolean `open` means +"dismissing is permitted now". `PLAN.md` §4's "must never be able to strand the +user" is made executable rather than promised: the escape hatch becomes +available on **either** three wrong answers **or** sixty seconds of ringing, +whichever comes first, for every challenge kind — asserted as a parameterised +invariant over `DismissChallenge`, not as three happy paths. + +Three more guarantees hold alongside it. Snooze is never gated: the gate protects +dismissal only. Auto-silence still ends the ring at ten minutes regardless of the +gate, because that is the engine's, and the screen only follows the session to +`Finished`. And a wrong answer carries no lockout and no penalty timer — it +replaces the problem and that is all. + +A completed hold *is* the dismissal, and a correct answer *is* the dismissal: no +"solve it, then press Dismiss again", because a half-awake person should not have +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.