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.
|
fallbacks ends in vibration, never in silence.
|
||||||
- The post-reboot repair: a running timer no longer counts down from an anchor
|
- 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 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.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/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/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)` |
|
| `data/timers/` | `TimerEntity`, `TimerDao`, `TimerMapper`, `TimerRepository(+Impl)` |
|
||||||
| `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`, `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs` |
|
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `UiPrefs` |
|
||||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider` |
|
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
|
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule` |
|
||||||
| `alarm/` | `AlarmEngine` and the four seams it talks to — `AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier` — plus `AlarmIntents` |
|
| `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/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 |
|
| `alarm/di/` | `AlarmModule` — `@Binds` for the four seams |
|
||||||
| `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/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
|
## 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:
|
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,
|
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/`.
|
||||||
|
|
||||||
|
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
|
- **Fakes over `MutableStateFlow`.** The five fake DAOs are backed by a
|
||||||
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
|
`MutableStateFlow<List<Entity>>`, so a write re-emits on the observing flow
|
||||||
exactly as Room's would. The three abstract DAOs are *extended*, not
|
exactly as Room's would. The three abstract DAOs are *extended*, not
|
||||||
@@ -605,10 +623,9 @@ locale metadata holder service.
|
|||||||
|
|
||||||
| Not here | Milestone |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
@@ -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
|
`RingPresentationPolicy` decides how the ring is *presented*, and its
|
||||||
`soundsAnyway` is true in all eight capability combinations — asserted
|
`soundsAnyway` is true in all eight capability combinations — asserted
|
||||||
exhaustively, because that is the invariant the app lives on.
|
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