diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt index 5e4f12c..58c0e2e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -10,14 +10,17 @@ import de.jeanlucmakiola.clockula.domain.time.ClockChanges import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import java.util.TimeZone import javax.inject.Inject import javax.inject.Singleton /** - * `TIME_SET`, `TIMEZONE_CHANGED` and `TIME_TICK`, through a receiver that is - * registered only while somebody collects. `TIME_TICK` cannot be declared in - * the manifest at all, and the other two already have their manifest receiver - * (`SystemEventReceiver`) for the alarm engine; this one is for a readout. + * `TIME_SET`, `TIMEZONE_CHANGED` and `SCREEN_ON`, through a receiver that is + * registered only while somebody collects. The first two already have their + * manifest receiver (`SystemEventReceiver`) for the alarm engine; this one is + * for a readout. `SCREEN_ON` cannot be declared in the manifest at all, and is + * here because a `delay` on the main looper does not count deep sleep: a + * readout left subscribed through a sleep would otherwise wake stale. */ @Singleton class AndroidClockChanges @Inject constructor( @@ -27,13 +30,18 @@ class AndroidClockChanges @Inject constructor( override fun changes(): Flow = callbackFlow { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + // The process's cached default zone is reset by a separate + // one-way call that can land after this broadcast; resetting it + // here too (as ActivityThread does) means the rebuild this + // triggers reads the new zone, not the old one. + if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) TimeZone.setDefault(null) trySend(Unit) } } val filter = IntentFilter().apply { addAction(Intent.ACTION_TIME_CHANGED) addAction(Intent.ACTION_TIMEZONE_CHANGED) - addAction(Intent.ACTION_TIME_TICK) + addAction(Intent.ACTION_SCREEN_ON) } // Protected system broadcasts still arrive at a non-exported receiver, // and nothing else on the device gets to poke the tab. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index 8167665..f1d7e41 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -52,9 +52,10 @@ interface Ticker { /** * The system saying the wall clock may not read what a readout last drew: the - * time was set, the zone changed, or a minute passed while the device was - * awake. A readout on a long cadence merges this in so it does not wait out - * its own period to catch up. Emits only while collected. + * time was set, the zone changed, or the screen came back on after a sleep + * that a `delay` does not count. A readout on a long cadence restarts its tick + * on this, so it does not wait out its own period to catch up. Emits only + * while collected. */ interface ClockChanges { fun changes(): Flow diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt index 16e6611..7cbd918 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt @@ -8,16 +8,16 @@ import kotlin.time.toKotlinDuration internal object WorldClockDefaults { /** - * Aligned to the wall clock, so the readouts change on the boundary itself - * rather than up to a cadence late: the minute when seconds are off, the - * second only when the user asked for them (D18, #23). - */ - /** - * The unit every time on the tab is truncated to. The tick is derived from - * it, so the readouts and the cadence cannot drift apart. + * The unit every time on the tab is truncated to: the minute, or the second + * only when the user asked for them (D18, #23). */ fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES + /** + * The tab's cadence, aligned to the wall clock through + * `Ticker.alignedTicks` so the readouts change on the boundary itself. + * Derived from [unitFor], so the readouts and the cadence cannot drift apart. + */ fun tickFor(showSeconds: Boolean): Duration = unitFor(showSeconds).duration.toKotlinDuration() val HeroFaceSize: Dp = 200.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt index a4a73b4..a3b2de5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt @@ -139,17 +139,18 @@ internal fun WorldClockRow( ) } if (time != null) { + val timeStyle = if (showSeconds) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.headlineMedium + } Text( text = time, // Tabular, because M7 made the headline role tabular // app-wide: a column of times must not jitter as it ticks. // A size down with seconds, which add three or more glyphs // the city name would otherwise pay for. - style = if (showSeconds) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.headlineMedium - }, + style = timeStyle, maxLines = 1, softWrap = false, // Capped, and shrunk to fit the cap, so at a large font @@ -157,11 +158,7 @@ internal fun WorldClockRow( // city name no width at all. autoSize = TextAutoSize.StepBased( minFontSize = TimeMinFontSize, - maxFontSize = if (showSeconds) { - MaterialTheme.typography.headlineSmall.fontSize - } else { - MaterialTheme.typography.headlineMedium.fontSize - }, + maxFontSize = timeStyle.fontSize, ), // The row's description already says the time, to the // minute; the ticking text itself stays out of the tree. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index d246238..7cfe2e7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -228,7 +230,9 @@ private fun HomeFace( verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Spoken to the minute: with seconds on, a merged node whose text - // changed every second would be re-announced every second. + // changed every second would be re-announced every second. Set as + // the text, not a description, so the merge still reads "Home", + // the city and the zone name after it. val spokenTime = formatSpokenTime(home.time) Text( text = formatTime(home.time), @@ -237,7 +241,7 @@ private fun HomeFace( textAlign = TextAlign.Center, modifier = Modifier .padding(bottom = 8.dp) - .clearAndSetSemantics { contentDescription = spokenTime }, + .clearAndSetSemantics { text = AnnotatedString(spokenTime) }, ) // The face has no other label saying whose time it is, and a city // here is otherwise indistinguishable from a city in the list below. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt index bac1301..c0533b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt @@ -46,8 +46,8 @@ internal class WorldClockSource @Inject constructor( * The tab's state. Both the instant and the device zone are re-read on * **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and * `TIMEZONE_CHANGED` show at once rather than at the next minute, the next - * wait is measured against the clock as it now reads, and `TIME_TICK` - * catches the minute up after a deep sleep stretched a `delay`. It sits + * wait is measured against the clock as it now reads, and `SCREEN_ON` + * catches up after a deep sleep stretched a `delay`. It sits * outside the `flatMapLatest`, so its receiver is registered once per * subscription rather than once per list edit, and `onStart` lets the * first state through without waiting for a broadcast. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt index a9cd9f4..b0f9dd3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -53,7 +53,7 @@ class FakeClockChanges : ClockChanges { override fun changes(): Flow = stream - /** One `TIME_SET`/`TIMEZONE_CHANGED`/`TIME_TICK` to every collector. */ + /** One `TIME_SET`/`TIMEZONE_CHANGED`/`SCREEN_ON` to every collector. */ suspend fun fire() { stream.emit(Unit) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7693c73..c7de204 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -595,7 +595,8 @@ Four 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_dismiss_challenge`, `home_zone_id`, `world_clock_show_seconds` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow`; `world_clock_show_seconds` (#23) as its own `SettingsPrefs.worldClockSeconds`, so flipping a display option re-emits nothing alarms and timers watch | +| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | +| 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` | @@ -2444,8 +2445,9 @@ 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), `WorldClockDefaults.MinuteTick` -or `SecondTick`, so a tick lands on the wall-clock boundary itself rather than +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 @@ -2458,12 +2460,16 @@ 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, plus `TIME_TICK`, make +while the tab collects) restarts the tick: those two, plus `SCREEN_ON`, 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. `TIME_TICK` also -covers the one gap an aligned `delay` has, 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 signal is combined +measures its next wait against the clock as it now reads. On +`TIMEZONE_CHANGED` the receiver also resets the process's cached default +zone itself, since the system's own reset is a separate call that can land +after the broadcast. `SCREEN_ON` covers the one gap an aligned `delay` has, a +deep sleep inside the five-second `WhileSubscribed` grace, which stretches +the wait because the main looper's clock stops while the device sleeps. +(`TIME_TICK` would also do, but it fires on the same boundary as the minute +tick and would build every minute twice.) 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