From 65c86af313a6a4e3750518dfeb35a4574c254e53 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:07:19 +0200 Subject: [PATCH] fix(worldclock): keep seconds quiet for TalkBack and apart from the defaults The time each row and the home readout give TalkBack stays at minute precision, and the ticking text is kept out of the tree, so a focused row is not re-read every second. The row's time is capped in width and shrinks to fit, so a large font scale cannot crowd out the city name. The setting is its own SettingsPrefs flow rather than a ClockDefaults field, so flipping it re-emits nothing alarms and timers watch. The clock-change receiver sits outside the flatMapLatest and restarts the tick, registering once per subscription and re-measuring the wait against the clock as it now reads. --- .../clockula/backup/BackupEngine.kt | 1 + .../clockula/data/backup/BackupCodec.kt | 4 +-- .../clockula/data/prefs/ClockPrefs.kt | 6 ++++ .../clockula/data/prefs/SettingsPrefs.kt | 11 +++--- .../clockula/data/time/RealTicker.kt | 5 +-- .../clockula/domain/ClockDefaults.kt | 6 ---- .../clockula/domain/backup/BackupContent.kt | 2 ++ .../ui/settings/ClockSettingsScreen.kt | 4 +-- .../clockula/ui/settings/SettingsUiState.kt | 2 ++ .../clockula/ui/settings/SettingsViewModel.kt | 5 +-- .../ui/worldclock/WorldClockDefaults.kt | 4 --- .../clockula/ui/worldclock/WorldClockRow.kt | 35 +++++++++++++++++-- .../ui/worldclock/WorldClockScreen.kt | 27 +++++++++++--- .../ui/worldclock/WorldClockSource.kt | 24 ++++++++----- .../clockula/backup/BackupEngineTest.kt | 2 +- .../clockula/data/backup/BackupCodecTest.kt | 4 +-- .../clockula/data/prefs/ClockPrefsTest.kt | 17 +++++++-- .../ui/settings/SettingsViewModelTest.kt | 2 +- .../ui/worldclock/WorldClockViewModelTest.kt | 8 ++--- docs/ARCHITECTURE.md | 19 +++++++--- docs/BACKUP.md | 3 +- 21 files changed, 138 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index f0e1f1c..7b04895 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -103,5 +103,6 @@ class BackupEngine @Inject constructor( dynamicColor = settings.dynamicColor.first(), defaults = settings.currentDefaults(), timerPresets = settings.timerPresets.first(), + worldClockSeconds = settings.worldClockSeconds.first(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index 7c1b147..bd7f448 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -127,7 +127,7 @@ object BackupCodec { timerRingtoneUri = defaults.timerRingtoneUri, dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, - worldClockSeconds = defaults.worldClockSeconds, + worldClockSeconds = worldClockSeconds, timerPresets = timerPresets.map { it.inWholeMilliseconds }, ) @@ -234,9 +234,9 @@ object BackupCodec { timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), - worldClockSeconds = settingsDto.worldClockSeconds, ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, + worldClockSeconds = settingsDto.worldClockSeconds, ) return BackupResult.Success( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 278f650..af48d74 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -35,6 +35,12 @@ object ClockPrefs { val homeZoneId: Pref = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) + /** + * The world clock tab's readouts to the second, with a second hand on the + * face (#23). Off by default: it is what makes the tab wake every second + * rather than every minute. Not part of `ClockDefaults`, which alarms and + * timers inherit from, so flipping it re-emits nothing they watch. + */ val worldClockSeconds: Pref = booleanPref("world_clock_show_seconds", false) private fun Pref.clampedTo(range: IntRange): Pref = diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index d0eb078..c6076c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -65,13 +65,11 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.flow(ClockPrefs.alarmRingtoneUri), store.flow(ClockPrefs.timerRingtoneUri), store.flow(ClockPrefs.homeZoneId), - store.flow(ClockPrefs.worldClockSeconds), - ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId, worldClockSeconds -> + ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, homeZoneId = homeZoneId, - worldClockSeconds = worldClockSeconds, ) }.distinctUntilChanged() @@ -94,7 +92,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId - prefs[ClockPrefs.worldClockSeconds] = backup.defaults.worldClockSeconds + prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds prefs[TimerPrefs.presets] = backup.timerPresets } } @@ -116,6 +114,11 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) + // --- world clock display (#23) --- + + /** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */ + val worldClockSeconds: Flow = store.flow(ClockPrefs.worldClockSeconds) + suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled) // --- timer presets (M6) --- diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt index 3cd528a..4fee0e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt @@ -30,8 +30,9 @@ class RealTicker @Inject constructor(private val wallClock: WallClock) : Ticker /** * Cold and per-collector, as [ticks] is. The wait is re-measured against * the wall clock after every emission rather than added up, so a `delay` - * that ran a little long, or a wall clock that was set, is corrected on the - * very next boundary instead of accumulating. + * that ran a little long does not accumulate. A wall clock set mid-wait is + * only caught up after that wait ends; a caller that cannot wait that long + * restarts the flow on `ClockChanges`, as the world clock does. */ override fun alignedTicks(unit: Duration): Flow = flow { while (true) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 5e8939d..73499b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -13,12 +13,6 @@ data class ClockDefaults( val timerRingtoneUri: String? = null, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, - /** - * The world clock tab's readouts to the second, with a second hand on the - * face. Off by default: it is the one setting that makes the tab wake every - * second rather than every minute. - */ - val worldClockSeconds: Boolean = false, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..f4ac30e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -56,4 +56,6 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, + /** The world clock's "Display time with seconds" (#23). */ + val worldClockSeconds: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt index 0135223..6949415 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt @@ -38,11 +38,11 @@ internal fun ClockSettingsScreen( position = Position.Bottom, trailing = { Switch( - checked = state.defaults.worldClockSeconds, + checked = state.worldClockSeconds, onCheckedChange = viewModel::onWorldClockSecondsChanged, ) }, - onClick = { viewModel.onWorldClockSecondsChanged(!state.defaults.worldClockSeconds) }, + onClick = { viewModel.onWorldClockSecondsChanged(!state.worldClockSeconds) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 2b59f87..a85d060 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -14,6 +14,8 @@ internal sealed interface SettingsUiState { val appearance: Appearance, val defaults: ClockDefaults, val selfCheckProblemCount: Int? = null, + /** The world clock's "Display time with seconds" (#23); not a clock default. */ + val worldClockSeconds: Boolean = false, ) : SettingsUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 2a2666b..d0e1d9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -67,8 +67,9 @@ class SettingsViewModel @Inject internal constructor( settings.appearance, settings.defaults, selfCheckProblemCount, - ) { appearance, defaults, problemCount -> - SettingsUiState.Loaded(appearance, defaults, problemCount) + settings.worldClockSeconds, + ) { appearance, defaults, problemCount, worldClockSeconds -> + SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading) /** 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 028ba46..16e6611 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 @@ -12,10 +12,6 @@ internal object WorldClockDefaults { * rather than up to a cadence late: the minute when seconds are off, the * second only when the user asked for them (D18, #23). */ - val MinuteTick: Duration = ChronoUnit.MINUTES.duration.toKotlinDuration() - - val SecondTick: Duration = ChronoUnit.SECONDS.duration.toKotlinDuration() - /** * 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. 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 1c42776..a4a73b4 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 @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material3.Icon @@ -18,12 +20,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel import de.jeanlucmakiola.clockula.domain.worldclock.OffsetLabel @@ -52,6 +56,7 @@ internal fun WorldClockRow( row: WorldClockRowState, position: Position, formatTime: (LocalTime) -> String, + formatSpokenTime: (LocalTime) -> String, showSeconds: Boolean, dragHandle: Modifier, isDragging: Boolean, @@ -64,9 +69,12 @@ internal fun WorldClockRow( val time = row.time?.let(formatTime) // One sentence for TalkBack, in the order the row is read aloud in. A row // whose zone is gone has no time, and its summary already *is* the - // unknown-zone sentence, so naming it twice would be a stutter. - val description = if (time != null) { - stringResource(R.string.world_clock_row_description, row.name, time, summary) + // unknown-zone sentence, so naming it twice would be a stutter. Spoken to + // the minute even with seconds on: a description that changed every + // second would be re-announced every second while the row had focus. + val spokenTime = row.time?.let(formatSpokenTime) + val description = if (spokenTime != null) { + stringResource(R.string.world_clock_row_description, row.name, spokenTime, summary) } else { stringResource(R.string.world_clock_row_description_unknown, row.name, summary) } @@ -144,6 +152,22 @@ internal fun WorldClockRow( }, maxLines = 1, softWrap = false, + // Capped, and shrunk to fit the cap, so at a large font + // scale the time cannot take the whole row and leave the + // city name no width at all. + autoSize = TextAutoSize.StepBased( + minFontSize = TimeMinFontSize, + maxFontSize = if (showSeconds) { + MaterialTheme.typography.headlineSmall.fontSize + } else { + MaterialTheme.typography.headlineMedium.fontSize + }, + ), + // The row's description already says the time, to the + // minute; the ticking text itself stays out of the tree. + modifier = Modifier + .widthIn(max = TimeMaxWidth) + .clearAndSetSemantics {}, fontWeight = FontWeight.Bold, color = if (isDragging) { MaterialTheme.colorScheme.primary @@ -156,6 +180,11 @@ internal fun WorldClockRow( } } +/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ +private val TimeMaxWidth = 168.dp + +private val TimeMinFontSize = 14.sp + /** * The offset, and the day only when it differs: "Same time as home" stands * alone, and "Today" after it would be a sentence saying nothing twice. 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 00dd802..d246238 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 @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.text.style.TextAlign @@ -69,6 +70,8 @@ fun WorldClockScreen( val pillInset = LocalLivePillInset.current val loaded = state as? WorldClockUiState.Loaded val formatTime = rememberLocalTimeFormatter(withSeconds = loaded?.showSeconds == true) + // What TalkBack is given, always to the minute: see WorldClockRow. + val formatSpokenTime = rememberLocalTimeFormatter() // Saveable, not remembered: a rotation mid-confirmation must not lose the // question it was asking (D13). @@ -112,7 +115,12 @@ fun WorldClockScreen( }, ) { if (loaded != null) { - HomeFace(home = loaded.home, showSeconds = loaded.showSeconds, formatTime = formatTime) + HomeFace( + home = loaded.home, + showSeconds = loaded.showSeconds, + formatTime = formatTime, + formatSpokenTime = formatSpokenTime, + ) if (loaded.rows.isEmpty()) { EmptyWorldClocks() @@ -128,11 +136,12 @@ fun WorldClockScreen( modifier = Modifier.fillMaxWidth(), rowHeight = 80.dp, ) { id, position, dragHandle, isDragging -> - val row = rowsById[id] ?: return@ReorderableColumn + val row = rowsById.getValue(id) WorldClockRow( row = row, position = position, formatTime = formatTime, + formatSpokenTime = formatSpokenTime, showSeconds = loaded.showSeconds, dragHandle = dragHandle, isDragging = isDragging, @@ -190,7 +199,12 @@ fun WorldClockScreen( } @Composable -private fun HomeFace(home: HomeFaceState, showSeconds: Boolean, formatTime: (LocalTime) -> String) { +private fun HomeFace( + home: HomeFaceState, + showSeconds: Boolean, + formatTime: (LocalTime) -> String, + formatSpokenTime: (LocalTime) -> String, +) { Column( modifier = Modifier .fillMaxWidth() @@ -213,12 +227,17 @@ private fun HomeFace(home: HomeFaceState, showSeconds: Boolean, formatTime: (Loc horizontalAlignment = Alignment.CenterHorizontally, 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. + val spokenTime = formatSpokenTime(home.time) Text( text = formatTime(home.time), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = 8.dp), + modifier = Modifier + .padding(bottom = 8.dp) + .clearAndSetSemantics { contentDescription = 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 8f7d729..bac1301 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 @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -40,14 +40,17 @@ internal class WorldClockSource @Inject constructor( private val storedHomeZoneId: Flow = settings.defaults.map { it.homeZoneId }.distinctUntilChanged() - private val showSeconds: Flow = - settings.defaults.map { it.worldClockSeconds }.distinctUntilChanged() + private val showSeconds: Flow = settings.worldClockSeconds /** * The tab's state. Both the instant and the device zone are re-read on - * **every tick**, and [ClockChanges] is a tick of its own, so `TIME_SET` - * and `TIMEZONE_CHANGED` show at once rather than at the next minute, and - * `TIME_TICK` catches the minute up after a deep sleep stretched a `delay`. + * **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 + * 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. * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: @@ -57,9 +60,14 @@ internal class WorldClockSource @Inject constructor( */ @OptIn(ExperimentalCoroutinesApi::class) fun state(): Flow = - combine(worldClocks.worldClocks(), storedHomeZoneId, showSeconds, ::Triple) + combine( + worldClocks.worldClocks(), + storedHomeZoneId, + showSeconds, + clockChanges.changes().onStart { emit(Unit) }, + ) { clocks, storedHome, seconds, _ -> Triple(clocks, storedHome, seconds) } .flatMapLatest { (clocks, storedHome, seconds) -> - merge(ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)), clockChanges.changes()) + ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) .map { build(clocks, storedHome, seconds) } } .distinctUntilChanged() diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index a37f912..b8b64fe 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -69,7 +69,7 @@ class BackupEngineTest { assertThat(target.settings.themeMode.first()).isEqualTo(ThemeMode.DARK) assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7) assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York") - assertThat(target.settings.currentDefaults().worldClockSeconds).isTrue() + assertThat(target.settings.worldClockSeconds.first()).isTrue() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index 10a01cd..c876048 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -60,9 +60,9 @@ class BackupCodecTest { timerRingtoneUri = "content://media/2", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "America/New_York", - worldClockSeconds = true, ), timerPresets = listOf(1.minutes, 2.minutes, 10.minutes), + worldClockSeconds = true, ), ) @@ -267,7 +267,7 @@ class BackupCodecTest { @Test fun `a file from before show-seconds existed keeps it off`() { val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings - assertThat(settings.defaults.worldClockSeconds).isFalse() + assertThat(settings.worldClockSeconds).isFalse() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt index 78f800c..43225db 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -53,7 +53,6 @@ class ClockPrefsTest { prefs.setTimerRingtoneUri("content://t") prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") - prefs.setWorldClockSeconds(true) assertThat(prefs.defaults.first()).isEqualTo( ClockDefaults( @@ -65,7 +64,6 @@ class ClockPrefsTest { timerRingtoneUri = "content://t", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", - worldClockSeconds = true, ), ) } @@ -84,6 +82,21 @@ class ClockPrefsTest { } } + @Test + fun `show-seconds round-trips without touching the clock defaults`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.defaults.test { + assertThat(awaitItem()).isEqualTo(ClockDefaults()) + + prefs.setWorldClockSeconds(true) + + expectNoEvents() + cancel() + } + assertThat(prefs.worldClockSeconds.first()).isTrue() + } + @Test fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt index c68a416..2dd8c52 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt @@ -128,7 +128,7 @@ class SettingsViewModelTest { assertThat(defaults.snoozeLimit).isEqualTo(2) assertThat(defaults.volumeRampSeconds).isEqualTo(30) assertThat(defaults.dismissChallenge).isEqualTo(DismissChallenge.MATH) - assertThat(defaults.worldClockSeconds).isTrue() + assertThat((viewModel.state.value as SettingsUiState.Loaded).worldClockSeconds).isTrue() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt index dbaf462..247cca3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt @@ -355,7 +355,7 @@ class WorldClockViewModelTest { advanceUntilIdle() - assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.MinuteTick) + assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.tickFor(showSeconds = false)) assertThat(harness.ticker.requestedPeriods).isEmpty() } @@ -376,9 +376,9 @@ class WorldClockViewModelTest { assertThat(harness.loaded().showSeconds).isFalse() assertThat(harness.ticker.requestedUnits).containsExactly( - WorldClockDefaults.MinuteTick, - WorldClockDefaults.SecondTick, - WorldClockDefaults.MinuteTick, + WorldClockDefaults.tickFor(showSeconds = false), + WorldClockDefaults.tickFor(showSeconds = true), + WorldClockDefaults.tickFor(showSeconds = false), ).inOrder() } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68b8cd1..7693c73 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -595,7 +595,7 @@ 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` | +| 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 | | 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` | @@ -2458,12 +2458,21 @@ 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) is merged into the tick: those two, plus `TIME_TICK`, -make the state re-read the clock and the zone at once. `TIME_TICK` also +while the tab collects) restarts the tick: those two, plus `TIME_TICK`, 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. Every one of these is -an extra `build`, and `distinctUntilChanged` drops it if nothing moved. +main looper's clock stops while the device sleeps. 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. + +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 diff --git a/docs/BACKUP.md b/docs/BACKUP.md index b53cf5b..e934ca4 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -145,7 +145,8 @@ Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older files may also carry `default_timer_duration_millis`, from a default timer length setting that has since been removed; it is ignored on import like any other unknown field. Files from before `world_clock_show_seconds` existed -import with it off, its default. Every +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.