docs: the world clock, its seam and its caches
`ARCHITECTURE.md` gains §16 for the tab — where the zone list comes from, why ICU sits behind a seam, what `ZoneDirectory` caches and on what key, and why every comparison is read at the instant rather than against a stored offset. The module, package and rule tables count the new arrivals.
This commit is contained in:
@@ -154,3 +154,17 @@ Tag sections feed the release notes — see [`docs/RELEASING.md`](docs/RELEASING
|
||||
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.
|
||||
- A world clock tab, with an analog face for your own time zone whose dial
|
||||
changes shape with the hour — so a glance says whether it is the middle of the
|
||||
night there, without a line of text saying so.
|
||||
- Cities added from the device's own time-zone database, searched by name in
|
||||
your language — accents and all, so typing "sao" finds São Paulo — with the
|
||||
country and the offset from GMT shown before you pick.
|
||||
- Each city shows its time, how far ahead or behind your own it is, and whether
|
||||
it is a different day; all of it re-read from the real time-zone rules, so a
|
||||
daylight-saving change in either place is right on the day it happens.
|
||||
- Cities reordered by dragging them, or by a "move up" / "move down" action a
|
||||
screen reader can reach — a list you can only rearrange by dragging is a list
|
||||
some people cannot rearrange at all.
|
||||
- A home time zone you can set by hand or leave following the device, and which
|
||||
every city's offset and day difference are measured against.
|
||||
|
||||
+312
-13
@@ -1,6 +1,6 @@
|
||||
# Clockula — architecture
|
||||
|
||||
How Clockula is built **today**, after M7. Where something does not exist yet,
|
||||
How Clockula is built **today**, after M8. Where something does not exist yet,
|
||||
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
|
||||
"what, right now".
|
||||
@@ -48,12 +48,19 @@ through.
|
||||
│ entities │ typed prefs
|
||||
┌──────────────┴─────────────┐ ┌─────────┴───────────────┐
|
||||
│ DAOs + mappers (Room) │ │ PrefStore (DataStore) │
|
||||
│ ClockulaDatabase v1 │ │ clockula_prefs │
|
||||
│ ClockulaDatabase v2 │ │ clockula_prefs │
|
||||
└──────────────┬─────────────┘ └─────────┬───────────────┘
|
||||
│ │
|
||||
SQLite preferences_pb
|
||||
```
|
||||
|
||||
One seam sits **beside** the repositories rather than under them:
|
||||
`data/zones/`'s `ZoneNames` and `ZoneDirectory` (M8). They read the *platform* —
|
||||
the device's tzdata through `ZoneProvider.available()` and ICU's localised names
|
||||
through `android.icu` — not storage, so they have no entity, no DAO and no
|
||||
migration. They live in `data/` for the same reason `data/ringtones/` does: the
|
||||
thing behind them is an Android API the rest of the app must not name.
|
||||
|
||||
The seam is the repository interface list. Above it there is no `AlarmEntity`,
|
||||
no `@Query`, no `androidx.room` import and no `ClockulaDatabase` reference —
|
||||
`ArchitectureRulesTest` greps the whole main source set for exactly those names
|
||||
@@ -83,18 +90,20 @@ kit module — the kit's roadmap keeps schedulers app-local.
|
||||
| `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/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider`, `BootId`, `Ticker` |
|
||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()` — the device's tzdata), `BootId`, `Ticker` |
|
||||
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
|
||||
| `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 — `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/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/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` |
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)` — unchanged since M2; M8 is the milestone that finally calls all of it |
|
||||
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory` — the locale-keyed catalog cache and the DST-keyed display-name memo |
|
||||
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||
| `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` |
|
||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8 — one `@Binds`, `ZoneNames → IcuZoneNames`) |
|
||||
| `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` |
|
||||
@@ -113,12 +122,12 @@ kit module — the kit's roadmap keeps schedulers app-local.
|
||||
| `stopwatch/di/` | `StopwatchModule` — `@Binds` for the one seam |
|
||||
| `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 — and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
|
||||
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is **gone**: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
|
||||
| `ui/common/` | `rememberLocalTimeFormatter` — the app's **one** 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17) — 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/` | 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/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
|
||||
---
|
||||
@@ -287,6 +296,18 @@ city table (`PLAN.md` §5). A zone the device's tzdata later drops is **kept**,
|
||||
not blanked; `WorldClock.isKnownZone` reports it and the UI can show it as
|
||||
broken. Losing the user's row silently would be worse.
|
||||
|
||||
**M8 changes no schema here.** M2 built exactly the surface the world clock
|
||||
needs, so the milestone that finally uses this table adds no column, no version
|
||||
bump and no migration, and `WorldClockEntity`, `WorldClockDao`,
|
||||
`WorldClockMapper` and `WorldClockRepository(+Impl)` are not edited at all. What
|
||||
it does is give all three of `label`, `sort_order` and the unique `zone_id`
|
||||
their **first caller**: a row shows the user's `label` when it has one and the
|
||||
ICU city otherwise, `sort_order` carries the hand reorder (`reorder`'s first
|
||||
caller, from both a drag and the accessibility actions), and `zone_id`'s
|
||||
uniqueness is what lets the picker leave an already-added zone tappable. The
|
||||
read path M2's comment promised is real too: a zone the tzdata dropped is kept
|
||||
and drawn with no time, no offset and no day (§16).
|
||||
|
||||
### `stopwatch_laps`
|
||||
|
||||
| Column | Type | Notes |
|
||||
@@ -529,6 +550,16 @@ 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.
|
||||
|
||||
**M8 adds a wall-clock surface.** The world clock is a calendar fact about
|
||||
somewhere else, so it reads `WallClock.now()` and never `ElapsedRealtimeClock` —
|
||||
the same column of the table above that alarms sit in. What is worth writing
|
||||
down is the *cadence*: **both the instant and the device zone are re-read on
|
||||
every tick**, from the injected `WallClock` and `ZoneProvider`. That is what
|
||||
makes `TIME_SET` and `TIMEZONE_CHANGED` free for this tab — it self-heals
|
||||
within one tick, and `SystemEventReceiver` needs no new branch. The zone never
|
||||
comes from `ZoneId.systemDefault()` at a call site; a build rule (§8) fails on
|
||||
one anywhere under `domain/` or `ui/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Preferences
|
||||
@@ -560,6 +591,15 @@ 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`.
|
||||
|
||||
`home_zone_id` got its **first reader and its first writer in M8**, two
|
||||
milestones after the key was added. The reader is `HomeZone.resolve(stored,
|
||||
deviceZone)`, which is the whole home-zone rule in one pure function — the
|
||||
stored zone when the device still knows it, else the device's own — and the
|
||||
writer is the World clock tab's top-bar action, which also clears it so home
|
||||
follows the device again. Nothing is seeded: a fresh install has no stored home
|
||||
zone and no world clock row, and the hero face simply shows the device's zone
|
||||
(§16).
|
||||
|
||||
`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**
|
||||
@@ -590,7 +630,7 @@ before the first frame, and the alternative is an app only "clear data" can fix.
|
||||
|
||||
## 7. Dependency injection
|
||||
|
||||
Hilt, `SingletonComponent` throughout. Eight app modules plus the kit's:
|
||||
Hilt, `SingletonComponent` throughout. Nine app modules plus the kit's:
|
||||
|
||||
| Module | Provides |
|
||||
|---|---|
|
||||
@@ -600,6 +640,7 @@ Hilt, `SingletonComponent` throughout. Eight 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` |
|
||||
| `ZoneModule` | `@Binds` for the one ICU seam: `ZoneNames → IcuZoneNames` (M8). `ZoneDirectory` needs no binding — it is a `@Singleton` `@Inject` class |
|
||||
| `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") |
|
||||
|
||||
@@ -618,17 +659,24 @@ queries onto its own executor and runs Flow queries off the main thread, so a
|
||||
`withContext(io)` wrapper around a DAO call would be cargo cult. The DataStore
|
||||
half already runs on `@IoDispatcher`, wired once in `DataModule`.
|
||||
|
||||
`SystemRingtoneCatalog` is the one documented exception: it takes
|
||||
`SystemRingtoneCatalog` is the first documented exception: it takes
|
||||
`@IoDispatcher`, because a raw `ContentResolver` query — and
|
||||
`RingtoneManager`'s cursor, and `MediaPlayer.prepare` in
|
||||
`SystemRingtonePreviewer` — dispatches nothing of its own and would otherwise
|
||||
block whichever thread asked.
|
||||
|
||||
`ZoneDirectory` (M8) is the **second**, for the same reason: building the zone
|
||||
catalog is ~450 ids times three ICU calls, ICU dispatches nothing of its own,
|
||||
and the build happens the first time a picker opens. It is also cached on the
|
||||
locale tag — AppCompat's per-app language can change under a running activity —
|
||||
and guarded by one `Mutex`, so two collectors opening the picker at once build
|
||||
once.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
JVM-first. 843 unit tests run in the gate; 32 instrumentation tests compile in
|
||||
JVM-first. 1 139 unit tests run in the gate; 40 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/`, `domain/ring/` or `domain/timer/`.
|
||||
@@ -755,6 +803,40 @@ 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.
|
||||
|
||||
**M8 kept it for a fourth screen**, and it was the milestone most tempted to
|
||||
break it, because ICU and `Canvas` both look like they need a device. They do
|
||||
not. Everything ICU answers is behind `ZoneNames`, faked in a unit test.
|
||||
Everything the face *draws* is `DrawScope` calls over angles from `AnalogFace`
|
||||
and a fraction from `DayNight` — two pure objects with no collaborators, so the
|
||||
geometry is asserted at every minute of the day without a pixel. Everything the
|
||||
list and the picker *decide* is pure too: `WorldClockRows`, `ZonePickerRows`,
|
||||
`ZoneSearch`, `ZoneComparisons`, `ZoneCatalog` and `WorldClocks`. And everything
|
||||
the tab *writes* is a `WorldClockViewModel` method, tested over the **real**
|
||||
`WorldClockRepositoryImpl` across `FakeWorldClockDao` and the real
|
||||
`SettingsPrefs` over a real DataStore under a `@TempDir`.
|
||||
|
||||
One new fake and one new harness. `FakeZoneNames` is settable per method, counts
|
||||
calls per method — which is what makes "the catalog is built once" and "the
|
||||
display name is asked for once per DST phase" assertable — and carries a
|
||||
`failEverything` flag that makes every call throw, which is what makes "the
|
||||
directory never propagates an ICU failure" assertable. `WorldClockHarness` is
|
||||
`StopwatchEngineHarness`'s arrangement for a tab with no engine.
|
||||
|
||||
Three more `ArchitectureRulesTest` rules: `android.icu` is named only under
|
||||
`data/zones/`, so the ICU seam stays one file; `MaterialShapes` and
|
||||
`androidx.graphics.shapes` are named only under `ui/worldclock/`, which makes
|
||||
`PLAN.md` §8's "not sprinkled everywhere" mechanical (the ring screen and timer
|
||||
progress are sanctioned too, so widening the allowlist is a deliberate edit
|
||||
rather than a drift); and no file under `domain/` or `ui/` uses
|
||||
`ZoneId.systemDefault()` or `TimeZone.getDefault()` in code — the zone is a
|
||||
parameter, from `ZoneProvider`. All three held retroactively for M0–M7's files.
|
||||
|
||||
Four more instrumentation tests: the hero face really composing and its
|
||||
semantics really reaching TalkBack, the picker's real `Dialog` and real IME
|
||||
really adding a city, and the **Remove** and **Move up** accessibility actions
|
||||
really working the way TalkBack would drive them. **They have not been run** —
|
||||
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`.
|
||||
|
||||
@@ -786,6 +868,15 @@ Time types: the domain speaks `kotlin.time.Instant`, `kotlin.time.Duration`,
|
||||
for M3's DST work; the DST arithmetic itself is `java.time`'s, wrapped in one
|
||||
function (§11).
|
||||
|
||||
M8 adds **one** dependency coordinate: `androidx.graphics:graphics-shapes`,
|
||||
pinned at `1.0.1`, the version that already resolved transitively through
|
||||
`androidx.compose.material3`. `MaterialShapes` lives in material3, but `Morph`
|
||||
and `RoundedPolygon` are `androidx.graphics.shapes`, and the world clock's face
|
||||
imports that package directly — a direct import on an undeclared transitive
|
||||
dependency is how a Renovate bump breaks a build nobody declared. Pinning to
|
||||
what already resolved means the dependency graph does not move in this
|
||||
milestone.
|
||||
|
||||
### Manifest
|
||||
|
||||
Clockula's permission set is the alarm engine's, and nothing more. Each one is
|
||||
@@ -832,6 +923,10 @@ delivered regardless — so nothing else can fake a fire.
|
||||
`directBootAware`, and the database and DataStore live in credential-encrypted
|
||||
storage that is unreadable before the first unlock.
|
||||
|
||||
**M8 adds no permission and no manifest entry at all.** ICU, `java.time` and
|
||||
Compose drawing need none: the world clock schedules nothing, wakes nothing,
|
||||
rings nothing and posts no notification.
|
||||
|
||||
Components: `MainActivity`, the non-exported `CrashReportActivity` and
|
||||
`AlarmRingActivity`, two foreground services (`AlarmRingService` and M6's
|
||||
`TimerService`), five receivers — `AlarmFireReceiver`, `AlarmActionReceiver`,
|
||||
@@ -845,10 +940,8 @@ AppCompat's 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, 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -865,6 +958,12 @@ AppCompat's locale metadata holder service.
|
||||
| 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 |
|
||||
| A rename UI for `world_clocks.label`. The column is honoured on **read** — a row shows the label when it has one — but M8 adds no way to write it; M10's backup import will carry labels | M10 |
|
||||
| An automatic "home" row while travelling. Google Clock's automatic home clock is a separate feature with its own preference and its own edge cases; M8's answer to "where am I" is the hero face, which already follows the device when no home zone is stored | not planned for v1 |
|
||||
| A per-city detail screen, a map, sunrise/sunset times or a day-length bar. `DayNight` is a stated convention for a shape morph, not an ephemeris (§16) | not planned for v1 |
|
||||
| Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 |
|
||||
| Reordering world clocks by anything but `sort_order` — no grouping by continent, no sort-by-offset, no pinning. The list is the order the user put it in | not planned for v1 |
|
||||
| `MaterialShapes` on the ring screen or timer progress. `PLAN.md` §8 sanctions both; neither is rebuilt in M8, and the build rule (§8) makes widening the allowlist a deliberate edit | not planned for v1 |
|
||||
| Screenshots and store listing polish | M11 |
|
||||
|
||||
Also absent by design: any seeded default data — no starter alarm and no home
|
||||
@@ -1829,3 +1928,203 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## 16. The world clock
|
||||
|
||||
The section to read before changing anything zone-shaped.
|
||||
|
||||
### There is no engine, and there must not be one
|
||||
|
||||
`AlarmEngine`, `TimerEngine` and `StopwatchEngine` exist because each owns a
|
||||
platform registration that has to move with stored state — an AlarmManager slot,
|
||||
a foreground service, a ring session. **A world clock owns none of them.** It
|
||||
schedules nothing, wakes nothing, rings nothing and posts no notification, so a
|
||||
fourth engine would be three empty seams and a `Mutex` guarding nothing.
|
||||
|
||||
M8's shape is therefore the one §13/§14 describe for a *screen*: a pure state
|
||||
builder (`WorldClockRows`), a `Source` that combines the repository, the prefs,
|
||||
the directory, the clocks and the ticker into it, and a ViewModel that calls the
|
||||
repository and `SettingsPrefs` directly. Every verb the tab has — `add`,
|
||||
`remove`, `reorder`, `setHomeZone` — is a plain write with nothing to actuate
|
||||
afterwards.
|
||||
|
||||
### The ICU seam, and which half of tzdata is pure
|
||||
|
||||
The device's tzdata gives two different things, and they belong on two different
|
||||
sides of the boundary.
|
||||
|
||||
**The ids** are `java.time.ZoneId.getAvailableZoneIds()` — plain JVM, and
|
||||
therefore assertable in a unit test with no fake at all. They arrive through the
|
||||
existing `ZoneProvider` seam as `available(): Set<String>`, because a static
|
||||
`getAvailableZoneIds()` at a call site is the same ambient read `PLAN.md` §5
|
||||
bans for `now()`, and a test needs to hand over a small set.
|
||||
|
||||
**The names** — the exemplar city, the country and the long zone name — are
|
||||
`android.icu`, which does not exist on the JVM test classpath. They go behind
|
||||
**`ZoneNames`** in `data/zones/`, in exactly the shape `RingtoneCatalog` already
|
||||
has. Every method is **total and never throws**: ICU on an unusual OEM build
|
||||
must degrade a caption, not crash a tab, so `IcuZoneNames` wraps every body in
|
||||
`runCatching` and reads a blank answer back as no answer. A build rule pins it:
|
||||
`android.icu` appears only under `data/zones/`.
|
||||
|
||||
`ZoneDirectory` is the `@Singleton` that joins the two. It runs on
|
||||
`@IoDispatcher` (§7), caches the catalog on the **locale tag** under one
|
||||
`Mutex`, and builds **lazily** — the tab resolves one entry per stored clock
|
||||
through `entryFor`, and only the picker ever calls `entries()`. Those entries
|
||||
are memoised here too, on the same locale tag, so a per-app language change
|
||||
re-resolves the cities; a cache above this seam would keep the rows in the old
|
||||
language while the ICU zone name switched. The long zone name is memoised on
|
||||
`(locale, zoneId, DST phase)`, so a per-tick caller costs a map lookup and a tab
|
||||
left open across a transition picks up "Summer Time" on the next tick instead of
|
||||
going stale until the process restarts. Both memos are asked in **batch**
|
||||
(`entriesFor`, `displayNamesOf`), so one tick costs two dispatches rather than
|
||||
two per row.
|
||||
|
||||
### The catalog: canonical, region-based ids, plus UTC
|
||||
|
||||
`ZoneCatalog.pickableIds` keeps an id iff its first segment is one of the ten
|
||||
IANA regions **and** it is its own canonical id. The first rule drops
|
||||
`Etc/GMT+5`, `US/Pacific`, `SystemV/AST4` and `EST5EDT` — not city ids, and a
|
||||
user picking "GMT+5" is picking a fixed offset that will be wrong in six months.
|
||||
The second drops the `backward` aliases, so the picker offers **Asia/Kolkata**
|
||||
and not also Asia/Calcutta, and the answer comes from the tzdata's own
|
||||
canonicalisation through ICU rather than from a table of ours that would rot —
|
||||
`PLAN.md` §0's second commitment, applied. An alias is only dropped when its
|
||||
canonical id is itself available: ICU (`com.android.i18n`) and tzdata
|
||||
(`com.android.tzdata`) are separately updatable APEX modules, and a device whose
|
||||
tzdata still says `Europe/Kiev` while ICU already says `Europe/Kyiv` must not be
|
||||
left with no pickable zone for Ukraine at all.
|
||||
|
||||
**`UTC` is the one special case**, kept by name: it passes neither rule, and a
|
||||
clock app that cannot show UTC is missing the one zone every traveller and every
|
||||
log file shares. It is kept only when the device actually knows it; nothing is
|
||||
invented.
|
||||
|
||||
Search is **word-prefix over folded text**, not substring: NFD, combining marks
|
||||
stripped, lowercased through `Locale.ROOT`, split on anything that is not a
|
||||
letter or a digit. So "São Paulo" folds to "sao paulo", "york" and "new yor" and
|
||||
"yor new" all find New York, "germany" finds Berlin through the country, and
|
||||
**"erl" does not find Berlin** — a three-letter query must not turn into forty
|
||||
unrelated hits. Filtering preserves the catalog's own order rather than ranking,
|
||||
because the catalog is already sorted by city and an alphabet everybody can
|
||||
predict beats a relevance score nobody can.
|
||||
|
||||
### The offset and the day are read at the instant
|
||||
|
||||
`ZoneComparisons.compare(home, other, at)` reads **both** zones' offsets at `at`
|
||||
and subtracts. Nothing is stored, nothing is cached, and no arithmetic is done
|
||||
on a "standard offset":
|
||||
|
||||
```
|
||||
offsetMinutes = other.offset(at) − home.offset(at), in minutes
|
||||
dayDifference = DAYS.between(home local date, other local date)
|
||||
```
|
||||
|
||||
That is the discipline `AlarmOccurrences` uses for alarms (§11): let `java.time`
|
||||
answer the transition. Berlin↔Sydney is **ten** hours apart in January and
|
||||
**eight** in July, and a subtraction of standard offsets would get both wrong.
|
||||
The day comes from the two local *dates*, not from the offset — Berlin and
|
||||
Honolulu are eleven hours apart and still on the same calendar day.
|
||||
|
||||
The labels are **data, not strings**, following `NextFireFormat`'s precedent:
|
||||
`OffsetLabel.Same | Ahead(h, m) | Behind(h, m)` and
|
||||
`DayLabel.YESTERDAY | TODAY | TOMORROW`. The wording lives in `strings.xml`, and
|
||||
`h`/`m` are non-negative magnitudes with the sign carried by the variant, so
|
||||
Kathmandu is `Ahead(4, 45)` and not a signed 285 the translator has to
|
||||
interpret. `dayLabelFor` clamps to ±1 to stay total; the real spread between the
|
||||
extreme zones is 26 hours, so ±1 is the only outcome tzdata can reach.
|
||||
|
||||
### Home
|
||||
|
||||
`HomeZone.resolve(stored, deviceZone)` is the whole rule: the stored zone when
|
||||
the device still knows it, else the device's own. It is one pure function, so
|
||||
the fallback is tested rather than implied, and `ClockPrefs.homeZoneId` already
|
||||
normalises through `Zones` in both directions (§6), so a zone the tzdata later
|
||||
dropped reads back as absent before `resolve` ever sees it.
|
||||
|
||||
Consequences, all deliberate. **Nothing is seeded** — a fresh install shows the
|
||||
device's zone on the face and an empty list, and the face is not a stored row.
|
||||
**Home is set from this tab**, through the top-bar action, because a preference
|
||||
the user cannot reach is not handling; M10 may surface it a second time. **The
|
||||
home zone may also be added as a row**, which is not prevented: `add` is
|
||||
idempotent on `zone_id`, and that row simply reads "Same time as home".
|
||||
|
||||
### The face, and why its morph is information
|
||||
|
||||
`PLAN.md` §8 sanctions a `MaterialShapes` morph on the analog face and warns it
|
||||
must not be "sprinkled everywhere". M7 refused one on the stopwatch with the
|
||||
sentence this has to answer: *a stopwatch has no progress, and a decorative arc
|
||||
would be a lie drawn to two decimal places.*
|
||||
|
||||
So the morph here **carries information**. The dial is
|
||||
`Morph(MaterialShapes.Circle, MaterialShapes.Sunny)` and its progress is
|
||||
`DayNight.fractionAt(localTime)` — a plain circle in the middle of the night
|
||||
there, a sun at midday. "Is it the middle of the night where they are" is
|
||||
precisely the question a world clock exists to answer, and answering it with
|
||||
shape rather than a third line of text is what §8's "refinement comes from
|
||||
shape, colour, space and motion" means.
|
||||
|
||||
`DayNight` is a stated **convention, not an ephemeris**: 0 below 05:00, a linear
|
||||
ramp to 1 at 07:00, 1 until 17:00, a linear ramp back to 0 at 19:00. It needs no
|
||||
latitude, it has obvious tests, and the app never claims it is sunrise.
|
||||
|
||||
The rest is ordinary `Canvas` work over `AnalogFace`'s pure angles — twelve hour
|
||||
marks with the four quarters longer, three hands, a centre cap — in scheme
|
||||
tokens only. The morph is built once and the path re-derived into a reused
|
||||
`Path` per frame through `toPath(Morph, Float, Path)`, which is not
|
||||
`@Composable` and is therefore callable from a `DrawScope`. The face is the home
|
||||
readout and the digital time is **not** repeated under it: an analog face that
|
||||
also prints its own time is a face nobody looks at. It carries a content
|
||||
description naming the city and the time, and is deliberately **not** a live
|
||||
region — at 1 Hz it would recite the clock for as long as the tab was open,
|
||||
which is the mistake M4 already refused for the live pill (§12).
|
||||
|
||||
### The cadence
|
||||
|
||||
`WorldClockDefaults.Tick = 200 ms`. `RealTicker` is a plain `delay` loop with no
|
||||
alignment to the wall second, so a one-second cadence would leave the second
|
||||
hand up to a full second behind the device clock — visible, and not something a
|
||||
clock app ships. The cost is bounded by making the state **stable**:
|
||||
`HomeFaceState.time` is truncated to the second, `WorldClockRowState.time` to
|
||||
the minute, and the flow is `distinctUntilChanged`. The upstream emits five
|
||||
times a second, a new state is produced about once a second, and the rows' own
|
||||
values change once a minute. The period is a `Ticker` parameter, never a `delay`
|
||||
at a call site (§12).
|
||||
|
||||
### Twenty-four cities, and a reorder a screen reader can reach
|
||||
|
||||
The kit's `ReorderableColumn` is not lazy and pins rows to a 64dp pitch, so the
|
||||
list needs a bound: **`WorldClocks.MAX = 24`**, one per hour of the day, a list
|
||||
a person can take in at a glance. Past it the FAB carries "Add up to 24 cities"
|
||||
as its description, the same sentence is printed below the list where a sighted
|
||||
user can read it — M3's `ExtendedFloatingActionButton` has no `enabled`
|
||||
parameter to dim it with, and a refusal only a screen reader can hear is a dead
|
||||
button to everybody else — and the ViewModel refuses the add: the same shape as
|
||||
`StopwatchLaps.MAX = 999` (§15): a constant with a reason, asserted as a
|
||||
constant so a change is deliberate.
|
||||
|
||||
A drag-only reorder is inaccessible. TalkBack cannot press-and-drag and neither
|
||||
can a keyboard, and M4 settled the principle when the hold challenge grew an
|
||||
accessibility `onClick`: *a challenge TalkBack cannot answer is exactly the
|
||||
stranding the requirement forbids.* So every row carries three custom
|
||||
accessibility actions — **Move up**, **Move down**, **Remove** — alongside the
|
||||
handle and the long-press. The row also merges its descendants under one
|
||||
**content description** — the city, the time and the summary as one sentence —
|
||||
rather than letting TalkBack read three child texts in child order; that is
|
||||
also the only place the "no longer on your device" sentence is spoken. The arithmetic behind the first two is pure
|
||||
(`WorldClocks.moveUp`/`moveDown`), a no-op at the end it is already at and for
|
||||
an id the list does not hold, and always a permutation of its input; a move that
|
||||
changes nothing writes nothing.
|
||||
|
||||
Remove is a long-press plus a confirmation that names the city, with
|
||||
deliberately **no undo chip** — the same trade M5 and M6 made: the cheap moment
|
||||
to ask is before. The pending id is `rememberSaveable`, so a rotation
|
||||
mid-confirmation does not lose the question.
|
||||
|
||||
A row whose zone the device's tzdata no longer knows shows **no time, no offset
|
||||
and no day** — `WorldClockRowState` carries all three as `null`, non-null
|
||||
exactly when `known` is true — and says "This time zone is no longer on your
|
||||
device" instead. The row is kept, never rewritten: §4 already decided that, and
|
||||
inventing a time for a zone nobody can resolve would be worse still.
|
||||
|
||||
Reference in New Issue
Block a user