Merge branch 'feat/23-world-clock-seconds' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
This commit is contained in:
makiolaj committed 2026-10-26 20:54:31 +01:00
commit fd7f5e8dcd
40 files changed
+860 -110

No files matched your search

+63 -15
View File
@@ -594,9 +594,11 @@ 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. **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
device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. Since #23 the
tick is a minute long unless seconds are on, so `TIME_SET` and
`TIMEZONE_CHANGED` also restart it through `ClockChanges`, a receiver the tab
registers while it collects (§16, "The cadence"); `SystemEventReceiver` still
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/`.
@@ -605,12 +607,13 @@ one anywhere under `domain/` or `ui/`.
## 6. Preferences
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
Four slices:
Six slices:
| Slice | Keys | Owner |
|---|---|---|
| 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_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, 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 |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -1619,6 +1622,16 @@ 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.
`alignedTicks(unit)` is the same seam for a readout of whole wall-clock units:
after the immediate emission it waits `WallBoundaries.untilNext(now, unit)`,
re-measured against `WallClock` after every wake, so it fires on the second or
minute boundary itself and a `delay` that ran long does not accumulate. A
clock set mid-wait is only caught up once that wait ends, so a caller on a
long cadence restarts the flow on `ClockChanges`, the system's own word that
the clock or zone moved. The world clock is its one caller (§16, "The
cadence"), and does exactly that, so a long
cadence never means a long wait for those.
### The dismiss challenge, and why it cannot strand anyone
`ChallengeGate` is a pure state machine whose single boolean `open` means
@@ -2646,8 +2659,8 @@ 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, all in scheme
tokens. The morph is built once and the path re-derived into a reused
marks with the four quarters longer, the hour and minute hands (and a second
hand when seconds are on, below), a centre cap, all in scheme tokens. 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`. M8 left the digital
time out from under the face on the grounds that an analog face that also
@@ -2663,15 +2676,50 @@ 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, which is 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).
**Display time with seconds** (Settings > Clock, `world_clock_show_seconds`,
off by default, #23) decides how often the tab wakes. Off, the face has an hour
and a minute hand, the readout under it and every row show hours and minutes,
and the tab ticks **once a minute**. On, the readout and the rows add seconds
in the same 12/24-hour and locale form (`HH:mm:ss`, or the locale's medium
time), the face gains its second hand, and the tab ticks **once a second**.
The second hand belongs to the switch rather than being always there: a hand
that steps every second is exactly what makes the tab wake every second, and
the point of the switch is that nobody pays for that without asking.
Both cadences are `Ticker.alignedTicks` (§12), at
`WorldClockDefaults.tickFor(showSeconds)`, which is derived from the same
`unitFor` the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
up to a period late; a free-running one-second `delay` could leave the second
hand a full second behind the device clock. This replaces M8's 200 ms
free-running tick, which got within a fifth of a second only by waking five
times as often as anything changed. `HomeFaceState.time` and
`WorldClockRowState.time` are truncated to the same unit the tab ticks in, and
the flow stays `distinctUntilChanged`, which now only absorbs the odd early
wake. The setting is read with the stored list and the home zone, so flipping
it switches the cadence at once through the same `flatMapLatest`.
A minute is too long to wait for `TIME_SET` or `TIMEZONE_CHANGED`, which can
arrive with the tab on screen (a network zone change on landing, split
screen). So `ClockChanges` (`AndroidClockChanges`, a receiver registered only
while the tab collects) restarts the tick: those two make the state re-read
the clock and the zone at once, and the restarted ticker measures its next
wait against the clock as it now reads. The signal is combined
with the stored list, the home zone and the setting *outside* the
`flatMapLatest`, so the receiver is registered once per subscription, not
once per list edit. Each broadcast costs one extra `build`, which
`distinctUntilChanged` drops when nothing moved. The screen also sends a
refresh on every resume, merged into the same signal. A per-app language
change recreates the activity but not the ViewModel and is announced by no
broadcast, so the city names would otherwise wait for the next minute to
change language. And an aligned `delay` has one gap of its own, a deep sleep
inside the five-second `WhileSubscribed` grace, which stretches the wait
because the main looper's clock stops while the device sleeps; the tab
cannot be seen again without a resume, which re-reads the clock.
TalkBack is given the time to the minute even with seconds on: the row's
description and the home readout's semantics use the minute formatter, and
the ticking text is kept out of the tree, because a focused node whose text
changed every second would be re-announced every second.
### Twenty-four cities, and a reorder a screen reader can reach
+5
View File
@@ -159,6 +159,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_alarm_silence_after_minutes": 10,
"default_timer_silence_after_minutes": 10,
"volume_buttons_while_ringing": "CONTROL_VOLUME",
"world_clock_show_seconds": false,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
"bedtime": {
"enabled": false,
@@ -199,6 +200,10 @@ hour). A file without them, from before the setting existed, imports as `10`,
the value it was made with. A value of 60 or more reads as 0, "Never" (which
rings for that long anyway); a negative one reads as the default, 10.
Files from before `world_clock_show_seconds` existed import with it off, its
default. It travels with the settings but is not a clock default: nothing an
alarm or timer inherits depends on it.
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.
+3 -1
View File
@@ -453,7 +453,9 @@ deliberate showpiece.
January and eight in July; labels are data, not strings, so Kathmandu is
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
cadence is 200 ms with the state truncated to the second (face) and the
minute (rows) and `distinctUntilChanged` over it; the list is capped at
minute (rows) and `distinctUntilChanged` over it (since #23, a tick aligned
to the wall clock: once a minute, or once a second with "Display time with
seconds" on); the list is capped at
24, with the cap spelled out on the screen once it is reached, because
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
reduce-motion snaps the morph but never stops the second hand,