docs: the app shell, and what it earned
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
@@ -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.
|
||||
|
||||
+129
-8
@@ -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<List<Entity>>`, 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.
|
||||
|
||||
Reference in New Issue
Block a user