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.