From 785adf715b95afd3881e9904ea573b50843c078a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:48:15 +0200 Subject: [PATCH 01/11] feat(worldclock): add an option to display time with seconds One switch in Settings > Clock adds seconds to the home readout and every city row, in the same 12/24-hour and locale form as before, and gives the analog face its second hand. Off by default. The tab now ticks on the wall-clock boundary through a new Ticker.alignedTicks: once a minute with seconds off, once a second only with them on, replacing the free-running 200 ms tick. The setting is in backups as world_clock_show_seconds; older files import with it off. Fixes #23 --- CHANGELOG.md | 3 +- .../clockula/data/backup/BackupCodec.kt | 2 + .../clockula/data/backup/BackupDto.kt | 2 + .../clockula/data/prefs/ClockPrefs.kt | 2 + .../clockula/data/prefs/SettingsPrefs.kt | 7 +- .../clockula/data/time/RealTicker.kt | 17 ++++- .../clockula/domain/ClockDefaults.kt | 6 ++ .../clockula/domain/time/Clocks.kt | 8 +++ .../clockula/domain/time/WallBoundaries.kt | 30 ++++++++ .../clockula/ui/common/AlarmTimeFormatter.kt | 12 ++-- .../ui/settings/ClockSettingsScreen.kt | 20 +++++- .../clockula/ui/settings/SettingsViewModel.kt | 3 + .../ui/worldclock/WorldClockDefaults.kt | 15 +++- .../clockula/ui/worldclock/WorldClockFace.kt | 19 +++-- .../ui/worldclock/WorldClockRowState.kt | 25 ++++--- .../ui/worldclock/WorldClockScreen.kt | 7 +- .../ui/worldclock/WorldClockSource.kt | 24 ++++--- app/src/main/res/values/strings.xml | 4 +- .../clockula/backup/BackupEngineTest.kt | 2 + .../clockula/data/backup/BackupCodecTest.kt | 12 ++++ .../clockula/data/prefs/ClockPrefsTest.kt | 2 + .../clockula/data/time/RealTickerTest.kt | 72 ++++++++++++++++++- .../domain/time/WallBoundariesTest.kt | 62 ++++++++++++++++ .../clockula/testing/FakeTicker.kt | 14 ++++ .../ui/settings/SettingsViewModelTest.kt | 2 + .../ui/worldclock/WorldClockRowsTest.kt | 43 +++++++++-- .../ui/worldclock/WorldClockViewModelTest.kt | 50 +++++++++++-- docs/ARCHITECTURE.md | 47 ++++++++---- docs/BACKUP.md | 4 +- docs/ROADMAP.md | 4 +- 30 files changed, 455 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..c646960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. device's time-zone database, searchable in your language. Each city shows the offset from your time and whether it's a different day there. Reorder them by dragging or with a screen reader, and set a home time zone or follow - the device. + the device. "Display time with seconds" in Settings > Clock adds seconds to + every time on the tab and a second hand to the face. - A live pill that shows whatever is counting from every tab. - Swipe between tabs on phones. Tablets and foldables get a side rail. - Other apps can set, show, snooze and dismiss alarms and timers through 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 c6daf96..7c1b147 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,6 +127,7 @@ object BackupCodec { timerRingtoneUri = defaults.timerRingtoneUri, dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, + worldClockSeconds = defaults.worldClockSeconds, timerPresets = timerPresets.map { it.inWholeMilliseconds }, ) @@ -233,6 +234,7 @@ 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..26cdc3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -71,6 +71,8 @@ data class SettingsDto( @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", @SerialName("home_zone_id") val homeZoneId: String? = null, + /** Absent in files from before it existed, which keeps the default: off. */ + @SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false, /** * Absent is not empty: an empty list is a user who removed every preset, * while a file without the field (an older or hand-written one) keeps the 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 0cb43c1..278f650 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,8 @@ object ClockPrefs { val homeZoneId: Pref = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) + val worldClockSeconds: Pref = booleanPref("world_clock_show_seconds", false) + private fun Pref.clampedTo(range: IntRange): Pref = map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) }) 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 b76785b..d0eb078 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,11 +65,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.flow(ClockPrefs.alarmRingtoneUri), store.flow(ClockPrefs.timerRingtoneUri), store.flow(ClockPrefs.homeZoneId), - ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> + store.flow(ClockPrefs.worldClockSeconds), + ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId, worldClockSeconds -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, homeZoneId = homeZoneId, + worldClockSeconds = worldClockSeconds, ) }.distinctUntilChanged() @@ -92,6 +94,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[TimerPrefs.presets] = backup.timerPresets } } @@ -113,6 +116,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) + 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 4db5bdd..3cd528a 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 @@ -1,6 +1,8 @@ package de.jeanlucmakiola.clockula.data.time import de.jeanlucmakiola.clockula.domain.time.Ticker +import de.jeanlucmakiola.clockula.domain.time.WallBoundaries +import de.jeanlucmakiola.clockula.domain.time.WallClock import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -10,7 +12,7 @@ import javax.inject.Singleton /** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */ @Singleton -class RealTicker @Inject constructor() : Ticker { +class RealTicker @Inject constructor(private val wallClock: WallClock) : Ticker { /** * Cold, and per-collector: two readouts on different cadences get two @@ -24,4 +26,17 @@ class RealTicker @Inject constructor() : Ticker { delay(period) } } + + /** + * 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. + */ + override fun alignedTicks(unit: Duration): Flow = flow { + while (true) { + emit(Unit) + delay(WallBoundaries.untilNext(wallClock.now(), unit)) + } + } } 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 73499b3..5e8939d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -13,6 +13,12 @@ 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/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index e943df4..2b1d575 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 @@ -40,4 +40,12 @@ interface ZoneProvider { */ interface Ticker { fun ticks(period: Duration): Flow + + /** + * Emits once immediately, then just after every wall-clock instant that is + * a whole multiple of [unit] ([WallBoundaries.untilNext]). For a readout of + * whole seconds or minutes: it wakes exactly when the readout changes and + * not otherwise, so it is both on time and as rare as it can be. + */ + fun alignedTicks(unit: Duration): Flow } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt new file mode 100644 index 0000000..951438c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.clockula.domain.time + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * Where the next whole second (or minute) of the wall clock falls. A readout + * that shows whole units changes exactly at these instants, so waking there + * rather than on a free-running cadence is what keeps it on time without + * waking more often than it changes. + */ +object WallBoundaries { + + /** + * How long from [now] until the next instant that is a whole multiple of + * [unit] since the epoch. Always in `(0, unit]`: standing exactly on a + * boundary waits for the *next* one, so a loop over this never spins. + * + * Every zone in use today is offset from UTC by whole minutes, so a + * boundary of the epoch is a boundary of every zone's local second and + * minute alike. + */ + fun untilNext(now: Instant, unit: Duration): Duration { + val unitMillis = unit.inWholeMilliseconds + require(unitMillis > 0) { "unit must be at least a millisecond: $unit" } + val into = Math.floorMod(now.toEpochMilliseconds(), unitMillis) + return (unitMillis - into).milliseconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index bf77123..3abe881 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -16,20 +16,24 @@ import java.time.format.FormatStyle * alarm list, the editor and the world clock all read a time through it, so a * second `DateFormat.is24HourFormat` call site would be the start of two * answers (M5, M8 D17). + * + * [withSeconds] is the world clock's "show seconds" (#23): the same two-step, + * one unit finer — `HH:mm:ss`, or the locale's medium form, which is its short + * form with the seconds in the place that locale puts them. */ @Composable -internal fun rememberLocalTimeFormatter(): (LocalTime) -> String { +internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTime) -> String { val context = LocalContext.current // The locale can change under a running activity, and the 12/24-hour // choice rides the same configuration, so both are re-read with it. val configuration = LocalConfiguration.current - val formatter = remember(configuration) { + val formatter = remember(configuration, withSeconds) { // The device's 12/24-hour choice, then the locale's own short form — // the same two-step the ring notification already formats with. if (DateFormat.is24HourFormat(context)) { - DateTimeFormatter.ofPattern("HH:mm") + DateTimeFormatter.ofPattern(if (withSeconds) "HH:mm:ss" else "HH:mm") } else { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT) } } return remember(formatter) { 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 d669685..0135223 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 @@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.settings import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -11,7 +12,10 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position -/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */ +/** + * The clock tab's settings: the home zone, and whether the tab shows seconds. + * The zone picker itself is ViewModel state, drawn by the hub. + */ @Composable internal fun ClockSettingsScreen( state: SettingsUiState.Loaded, @@ -25,8 +29,20 @@ internal fun ClockSettingsScreen( GroupedRow( title = stringResource(R.string.settings_home_zone), summary = state.defaults.homeZoneId ?: stringResource(R.string.zone_picker_device_zone), - position = Position.Alone, + position = Position.Top, onClick = viewModel::onOpenHomeZonePicker, ) + GroupedRow( + title = stringResource(R.string.settings_world_clock_seconds), + summary = stringResource(R.string.settings_world_clock_seconds_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.defaults.worldClockSeconds, + onCheckedChange = viewModel::onWorldClockSecondsChanged, + ) + }, + onClick = { viewModel.onWorldClockSecondsChanged(!state.defaults.worldClockSeconds) }, + ) } } 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 7d90968..2a2666b 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 @@ -229,6 +229,9 @@ class SettingsViewModel @Inject internal constructor( DefaultRingtoneKind.TIMER -> timerRingtoneUri } + fun onWorldClockSecondsChanged(enabled: Boolean) = + viewModelScope.launch { settings.setWorldClockSeconds(enabled) } + // --- the home time-zone picker --- fun onOpenHomeZonePicker() { 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 cb23809..73724e3 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 @@ -3,11 +3,20 @@ package de.jeanlucmakiola.clockula.ui.worldclock import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds internal object WorldClockDefaults { - /** 200 ms — the second hand is never a fifth of a second behind (D18). */ - val Tick: Duration = 200.milliseconds + /** + * 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). + */ + val MinuteTick: Duration = 1.minutes + + val SecondTick: Duration = 1.seconds + + fun tickFor(showSeconds: Boolean): Duration = if (showSeconds) SecondTick else MinuteTick val HeroFaceSize: Dp = 200.dp diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt index 2c9ea05..204f998 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt @@ -29,7 +29,9 @@ import java.time.LocalTime /** * The app's one deliberate showpiece: a `MaterialShapes.Circle`↔`Sunny` dial - * whose morph progress is [dayFraction], twelve hour marks and three hands. + * whose morph progress is [dayFraction], twelve hour marks and two hands, or + * three with [showSecondHand] — the world clock's "show seconds" (#23). Without + * it the tab ticks once a minute, and a second hand there would stand still. * * The morph carries **information**, which is the whole reason it is here * (`docs/PLAN.md` §8, D5): a plain circle in the middle of the night there, a @@ -48,6 +50,7 @@ import java.time.LocalTime internal fun WorldClockFace( time: LocalTime, dayFraction: Float, + showSecondHand: Boolean, modifier: Modifier = Modifier, ) { val dialColor = MaterialTheme.colorScheme.secondaryContainer @@ -62,9 +65,9 @@ internal fun WorldClockFace( val dialPath = remember { Path() } val dialMatrix = remember { Matrix() } - // Reduce motion touches the morph, not the hands: the second hand is a - // discrete position change carrying information, and a clock whose second - // hand stops is a broken clock (D19). + // Reduce motion touches the morph, not the hands: the second hand, when + // shown, is a discrete position change carrying information, and a clock + // whose second hand stops is a broken clock (D19). val progress by animateFloatAsState( targetValue = dayFraction, animationSpec = if (reduceMotion) { @@ -82,7 +85,7 @@ internal fun WorldClockFace( ) { drawDial(morph, progress, dialPath, dialMatrix, dialColor) drawTicks(markColor) - drawHands(time, markColor, accentColor) + drawHands(time, showSecondHand, markColor, accentColor) } } @@ -127,11 +130,13 @@ private fun DrawScope.drawTicks(color: Color) { } } -private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) { +private fun DrawScope.drawHands(time: LocalTime, showSecondHand: Boolean, handColor: Color, accentColor: Color) { val radius = size.minDimension / 2f hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor) hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor) - hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + if (showSecondHand) { + hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + } drawCircle(accentColor, radius = 4.dp.toPx(), center = center) drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx())) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt index fce2531..6f729b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -20,7 +20,7 @@ internal data class WorldClockRowState( val zoneId: String, /** The user's label when set, else the ICU city, else the id's last segment. Never blank. */ val name: String, - /** Truncated to the minute. Non-null **exactly** when [known]. */ + /** Truncated to the minute, or the second with seconds on. Non-null **exactly** when [known]. */ val time: LocalTime?, /** Non-null exactly when [known]. */ val offset: OffsetLabel?, @@ -36,7 +36,7 @@ internal data class HomeFaceState( val zoneId: String, val city: String, val zoneName: String?, - /** Truncated to the second. */ + /** Truncated to the minute, or the second with seconds on. */ val time: LocalTime, /** 0f night … 1f day — what the dial's shape morph carries. */ val dayFraction: Float, @@ -51,6 +51,8 @@ internal sealed interface WorldClockUiState { /** In the repository's order — `sort_order`, then `id`. Never re-sorted here. */ val rows: List, val canAdd: Boolean, + /** The user's "show seconds": the readouts' format and the face's second hand. */ + val showSeconds: Boolean, ) : WorldClockUiState } @@ -62,8 +64,9 @@ internal object WorldClockRows { * knows keeps its row with all three readings blank rather than being * rewritten into something the app invented. * - * The time is truncated to the **minute**, which is what makes the 200 ms - * tick cheap: four ticks in five change nothing a row can show. + * The time is truncated to the unit the tab ticks in, the **minute** unless + * [showSeconds]: a state carrying more precision than is shown would + * differ on every wake and change nothing anybody could see. */ fun from( clocks: List, @@ -71,6 +74,7 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, + showSeconds: Boolean = false, ): List = clocks.map { clock -> val known = clock.isKnownZone val comparison = if (known) { @@ -82,7 +86,7 @@ internal object WorldClockRows { id = clock.id, zoneId = clock.zoneId, name = clock.label ?: cityOf(clock.zoneId, entries), - time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES), + time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(unitFor(showSeconds)), offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, zoneName = zoneNames[clock.zoneId], @@ -96,11 +100,11 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, + showSeconds: Boolean = false, ): HomeFaceState { - // To the second, not the millisecond: the second hand steps, so a state - // that carried more precision would differ on every tick and change - // nothing anybody could see. - val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS) + // To the unit shown, as the rows are. Without seconds the face has no + // second hand, so its hands and its readout move once a minute. + val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(unitFor(showSeconds)) return HomeFaceState( zoneId = homeZone.id, city = cityOf(homeZone.id, entries), @@ -110,6 +114,9 @@ internal object WorldClockRows { ) } + private fun unitFor(showSeconds: Boolean): ChronoUnit = + if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES + private fun cityOf(zoneId: String, entries: Map): String = entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) } 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 abac9b9..d20dc58 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 @@ -67,8 +67,8 @@ fun WorldClockScreen( val state by viewModel.state.collectAsStateWithLifecycle() val picker by viewModel.picker.collectAsStateWithLifecycle() val pillInset = LocalLivePillInset.current - val formatTime = rememberLocalTimeFormatter() val loaded = state as? WorldClockUiState.Loaded + val formatTime = rememberLocalTimeFormatter(withSeconds = loaded?.showSeconds == true) // Saveable, not remembered: a rotation mid-confirmation must not lose the // question it was asking (D13). @@ -112,7 +112,7 @@ fun WorldClockScreen( }, ) { if (loaded != null) { - HomeFace(home = loaded.home, formatTime = formatTime) + HomeFace(home = loaded.home, showSeconds = loaded.showSeconds, formatTime = formatTime) if (loaded.rows.isEmpty()) { EmptyWorldClocks() @@ -184,7 +184,7 @@ fun WorldClockScreen( } @Composable -private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) { +private fun HomeFace(home: HomeFaceState, showSeconds: Boolean, formatTime: (LocalTime) -> String) { Column( modifier = Modifier .fillMaxWidth() @@ -195,6 +195,7 @@ private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) { WorldClockFace( time = home.time, dayFraction = home.dayFraction, + showSecondHand = showSeconds, modifier = Modifier.padding(bottom = 12.dp), ) // One TalkBack stop for the time and whose time it is ("14:05, Home, 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 f77254a..2f6f4c6 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 @@ -37,22 +37,26 @@ 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() + /** * The tab's state. Both the instant and the device zone are re-read on * **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED` * free: the tab self-heals within one tick and `SystemEventReceiver` needs * no new branch (D2). * - * The cost of a 200 ms cadence is paid back by the state being stable — - * the face is truncated to the second and the rows to the minute — so - * `distinctUntilChanged` collapses five emissions a second into about one - * (D18). + * The tick is aligned to the wall clock and only as fine as the readouts: + * once a minute, or once a second when the user turned seconds on (#23). + * Everything shown is truncated to that same unit, so each tick is one new + * state and `distinctUntilChanged` only drops the odd early wake (D18). */ @OptIn(ExperimentalCoroutinesApi::class) fun state(): Flow = - combine(worldClocks.worldClocks(), storedHomeZoneId, ::Pair) - .flatMapLatest { (clocks, storedHome) -> - ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome) } + combine(worldClocks.worldClocks(), storedHomeZoneId, showSeconds, ::Triple) + .flatMapLatest { (clocks, storedHome, seconds) -> + ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) + .map { build(clocks, storedHome, seconds) } } .distinctUntilChanged() @@ -101,6 +105,7 @@ internal class WorldClockSource @Inject constructor( private suspend fun build( clocks: List, storedHome: String?, + showSeconds: Boolean, ): WorldClockUiState.Loaded { val now = wallClock.now() val homeZone = HomeZone.resolve(storedHome, zones.current()) @@ -112,9 +117,10 @@ internal class WorldClockSource @Inject constructor( val entries = directory.entriesFor(zoneIds) val zoneNames = directory.displayNamesOf(zoneIds, now) return WorldClockUiState.Loaded( - home = WorldClockRows.home(homeZone, now, entries, zoneNames), - rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames), + home = WorldClockRows.home(homeZone, now, entries, zoneNames, showSeconds), + rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds), canAdd = clocks.size < WorldClocks.MAX, + showSeconds = showSeconds, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..728a0ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -301,9 +301,11 @@ Sound for new timers New timers ring with this sound. Each timer can still change it in its editor. Clock - Home time zone + Home time zone and seconds World clocks show how far each city is from your home time zone. Home time zone + Display time with seconds + The world clock shows seconds and a second hand. Uses a little more battery. Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings 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 f572b7d..a37f912 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -43,6 +43,7 @@ class BackupEngineTest { source.settings.setThemeMode(ThemeMode.DARK) source.settings.setSnoozeMinutes(7) source.settings.setHomeZoneId("America/New_York") + source.settings.setWorldClockSeconds(true) val exported = source.backupEngine.export() @@ -68,6 +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() } @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 db57734..10a01cd 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,6 +60,7 @@ class BackupCodecTest { timerRingtoneUri = "content://media/2", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "America/New_York", + worldClockSeconds = true, ), timerPresets = listOf(1.minutes, 2.minutes, 10.minutes), ), @@ -263,6 +264,17 @@ class BackupCodecTest { assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN) } + @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() + } + + @Test + fun `show-seconds is written under its preference key`() { + assertThat(BackupCodec.encode(sampleContent())).contains("\"world_clock_show_seconds\": true") + } + @Test fun `an empty preset list is honoured as all removed`() { val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}") 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 869cca3..78f800c 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,6 +53,7 @@ class ClockPrefsTest { prefs.setTimerRingtoneUri("content://t") prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") + prefs.setWorldClockSeconds(true) assertThat(prefs.defaults.first()).isEqualTo( ClockDefaults( @@ -64,6 +65,7 @@ class ClockPrefsTest { timerRingtoneUri = "content://t", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", + worldClockSeconds = true, ), ) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt index 1afa53e..34526e4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt @@ -1,15 +1,19 @@ package de.jeanlucmakiola.clockula.data.time import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.testing.FakeWallClock import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant /** * The ticker is nothing but `delay`, which is why it is injected: under @@ -18,7 +22,19 @@ import kotlin.time.Duration.Companion.seconds */ class RealTickerTest { - private val ticker = RealTicker() + private val wallClock = FakeWallClock() + + private val ticker = RealTicker(wallClock) + + /** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */ + private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L) + + /** Lets virtual time pass with the wall clock moving in step, as a real one would. */ + private fun TestScope.advanceBoth(by: kotlin.time.Duration) { + advanceTimeBy(by) + wallClock.advance(by) + runCurrent() + } @Test fun `the first tick arrives immediately`() = runTest { @@ -74,4 +90,58 @@ class RealTickerTest { assertThat(ticks).hasSize(3) } + + // --- aligned --- + + @Test + fun `an aligned tick arrives immediately, then on the next whole second`() = runTest { + wallClock.instant = onASecond + 300.milliseconds + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } } + runCurrent() + assertThat(ticks).isEqualTo(1) + + advanceBoth(699.milliseconds) + assertThat(ticks).isEqualTo(1) + + advanceBoth(1.milliseconds) + assertThat(ticks).isEqualTo(2) + + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(3) + } + + @Test + fun `a minute cadence wakes on the minute and not in between`() = runTest { + wallClock.instant = onASecond + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.minutes).collect { ticks++ } } + runCurrent() + + advanceBoth(39.seconds) + assertThat(ticks).isEqualTo(1) + + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(2) + + advanceBoth(59.seconds) + assertThat(ticks).isEqualTo(2) + } + + @Test + fun `a wall clock set mid-wait is caught up on the next boundary`() = runTest { + wallClock.instant = onASecond + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } } + runCurrent() + + // The user sets the clock a quarter-second on while the ticker waits. + wallClock.advance(250.milliseconds) + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(2) + + // Re-measured from the new wall clock: three quarters to the boundary. + advanceBoth(750.milliseconds) + assertThat(ticks).isEqualTo(3) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt new file mode 100644 index 0000000..09b3ed8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.clockula.domain.time + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** The aligned cadence's arithmetic: always in `(0, unit]`, so never a spin. */ +class WallBoundariesTest { + + /** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */ + private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L) + + @Test + fun `mid-second waits for the rest of that second`() { + val now = onASecond + 250.milliseconds + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(750.milliseconds) + } + + @Test + fun `exactly on a boundary waits a whole unit rather than none`() { + assertThat(WallBoundaries.untilNext(onASecond, 1.seconds)).isEqualTo(1.seconds) + } + + @Test + fun `a minute boundary is the next whole minute, not sixty seconds on`() { + assertThat(WallBoundaries.untilNext(onASecond, 1.minutes)).isEqualTo(40.seconds) + } + + @Test + fun `one millisecond short of a boundary waits that millisecond`() { + val now = onASecond - 1.milliseconds + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(1.milliseconds) + } + + @Test + fun `before the epoch still lands on a boundary`() { + val now = Instant.fromEpochMilliseconds(-250L) + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(250.milliseconds) + } + + @Test + fun `waking where it says lands on a boundary`() { + val now = onASecond + 12_345.milliseconds + val woken = now + WallBoundaries.untilNext(now, 1.minutes) + + assertThat(woken.toEpochMilliseconds() % 60_000L).isEqualTo(0L) + assertThat(woken > now).isTrue() + } + + @Test + fun `a unit under a millisecond is refused`() { + assertThrows { WallBoundaries.untilNext(onASecond, Duration.ZERO) } + } +} 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 cbc475b..e696ff5 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -13,12 +13,26 @@ class FakeTicker : Ticker { private val streams = mutableListOf>() private val periods = mutableListOf() + private val units = mutableListOf() /** Every period asked for, in order, so a test can pin the cadence. */ val requestedPeriods: List get() = periods.toList() + /** Every aligned unit asked for, in order, so a test can pin that cadence too. */ + val requestedUnits: List get() = units.toList() + override fun ticks(period: Duration): Flow { periods += period + return stream() + } + + /** Driven by [tick] like [ticks]: the alignment is `RealTicker`'s, tested there. */ + override fun alignedTicks(unit: Duration): Flow { + units += unit + return stream() + } + + private fun stream(): Flow { val stream = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) check(stream.tryEmit(Unit)) streams += stream 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 1af202e..c68a416 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 @@ -119,6 +119,7 @@ class SettingsViewModelTest { viewModel.onSnoozeLimitChanged(2) viewModel.onVolumeRampChanged(30) viewModel.onDismissChallengeChanged(DismissChallenge.MATH) + viewModel.onWorldClockSecondsChanged(true) advanceUntilIdle() val defaults = (viewModel.state.value as SettingsUiState.Loaded).defaults @@ -127,6 +128,7 @@ class SettingsViewModelTest { assertThat(defaults.snoozeLimit).isEqualTo(2) assertThat(defaults.volumeRampSeconds).isEqualTo(30) assertThat(defaults.dismissChallenge).isEqualTo(DismissChallenge.MATH) + assertThat(defaults.worldClockSeconds).isTrue() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt index 892ae24..02595af 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt @@ -11,14 +11,15 @@ import de.jeanlucmakiola.clockula.testing.UTC_ZONE import org.junit.jupiter.api.Test import java.time.LocalTime import java.time.ZoneId +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** - * §5.7 — 18 cases. The tab as pure data: the row's name, its offset and day - * relative to **home**, the truncation that makes the 200 ms tick cheap (D18), - * and the row whose zone the device no longer knows (D14). + * §5.7 — 18 cases, and #23's seconds. The tab as pure data: the row's name, + * its offset and day relative to **home**, the truncation to the unit the tab + * ticks in (D18), and the row whose zone the device no longer knows (D14). */ class WorldClockRowsTest { @@ -59,7 +60,8 @@ class WorldClockRowsTest { homeZone: ZoneId = BERLIN, now: Instant = T0, zoneNames: Map = emptyMap(), - ) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames) + showSeconds: Boolean = false, + ) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds) // --- from --- @@ -149,6 +151,29 @@ class WorldClockRowsTest { assertThat(after).isEqualTo(before) } + /** #23 */ + @Test + fun `with seconds on a row's time carries the second`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0, showSeconds = true).single() + val after = rows(clocks, now = T0 + 1.seconds, showSeconds = true).single() + + assertThat(before.time).isEqualTo(LocalTime.of(17, 13, 20)) + assertThat(after.time).isEqualTo(LocalTime.of(17, 13, 21)) + } + + /** #23 */ + @Test + fun `with seconds on the millisecond is still dropped`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0, showSeconds = true) + val after = rows(clocks, now = T0 + 999.milliseconds, showSeconds = true) + + assertThat(after).isEqualTo(before) + } + /** §5.7 #10 */ @Test fun `the truncation does not freeze the row`() { @@ -217,11 +242,19 @@ class WorldClockRowsTest { /** §5.7 #16 */ @Test - fun `the hero face is home, to the second`() { + fun `the hero face is home, to the minute`() { val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) assertThat(home.zoneId).isEqualTo(berlin) assertThat(home.city).isEqualTo("Berlin") + assertThat(home.time).isEqualTo(LocalTime.of(23, 13)) + } + + /** #23 */ + @Test + fun `with seconds on the hero face is home to the second`() { + val home = WorldClockRows.home(BERLIN, T0 + 400.milliseconds, entries, emptyMap(), showSeconds = true) + assertThat(home.time).isEqualTo(LocalTime.of(23, 13, 20)) } 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 f80f4e6..9843858 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,12 +355,36 @@ class WorldClockViewModelTest { advanceUntilIdle() - assertThat(harness.ticker.requestedPeriods).contains(WorldClockDefaults.Tick) + assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.MinuteTick) + assertThat(harness.ticker.requestedPeriods).isEmpty() } - /** §5.9 #20 */ + /** #23: the tab wakes every second only while the user asked for seconds. */ @Test - fun `a second of wall clock moves the face and leaves the rows alone`(@TempDir tempDir: Path) = + fun `turning seconds on and off moves the cadence with it`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + advanceUntilIdle() + assertThat(harness.loaded().showSeconds).isFalse() + + harness.settings.setWorldClockSeconds(true) + advanceUntilIdle() + assertThat(harness.loaded().showSeconds).isTrue() + + harness.settings.setWorldClockSeconds(false) + advanceUntilIdle() + assertThat(harness.loaded().showSeconds).isFalse() + + assertThat(harness.ticker.requestedUnits).containsExactly( + WorldClockDefaults.MinuteTick, + WorldClockDefaults.SecondTick, + WorldClockDefaults.MinuteTick, + ).inOrder() + } + + /** #23 */ + @Test + fun `with seconds off a second of wall clock changes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) { val harness = collecting( worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(1))), @@ -372,9 +396,27 @@ class WorldClockViewModelTest { harness.ticker.tick() advanceUntilIdle() + assertThat(harness.loaded()).isEqualTo(before) + } + + /** §5.9 #20, with #23's seconds on. */ + @Test + fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting( + worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(1))), + ) + harness.settings.setWorldClockSeconds(true) + advanceUntilIdle() + val before = harness.loaded() + + harness.wallClock.advance(1.seconds) + harness.ticker.tick() + advanceUntilIdle() + val after = harness.loaded() assertThat(after.home.time).isEqualTo(before.home.time.plusSeconds(1)) - assertThat(after.rows).isEqualTo(before.rows) + assertThat(after.rows.single().time).isEqualTo(before.rows.single().time?.plusSeconds(1)) } /** §5.9 #21 */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..e6c8b3c 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` | `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` | | 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` | @@ -1423,6 +1423,12 @@ 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 long `delay` or a set clock does not accumulate. +The world clock is its one caller (§16, "The cadence"). + ### The dismiss challenge, and why it cannot strand anyone `ChallengeGate` is a pure state machine whose single boolean `open` means @@ -2409,8 +2415,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 @@ -2426,15 +2432,32 @@ 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), `WorldClockDefaults.MinuteTick` +or `SecondTick`, 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`. + +`TIME_SET` and `TIMEZONE_CHANGED` still self-heal within one tick, which with +seconds off is now up to a minute. That is only reachable by changing the +clock in system settings and returning within the five-second +`WhileSubscribed` grace; any longer and the flow restarts and reads the new +time at once. ### Twenty-four cities, and a reorder a screen reader can reach diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..b53cf5b 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -136,6 +136,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands "default_timer_ringtone_uri": null, "default_dismiss_challenge": "NONE", "home_zone_id": null, + "world_clock_show_seconds": false, "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] } ``` @@ -143,7 +144,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands 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. Every +other unknown field. Files from before `world_clock_show_seconds` existed +import with it off, its default. Every numeric/duration value is clamped on *apply* using the same ranges and encoding as the existing preference setters, never on parse. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..2b4b4cc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -452,7 +452,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, From a4cec23e5c63716b81ef4ca8f62cb27719396fae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:57:10 +0200 Subject: [PATCH 02/11] fix(worldclock): keep the seconds option from costing reorder and catch-up Rows that tick every second re-seeded the reorderable column mid-drag, so it is handed ids now. A zone or clock change no longer waits for the next minute: TIME_SET, TIMEZONE_CHANGED and TIME_TICK are merged into the tick through a new ClockChanges seam. With seconds on, the row's time drops a size so the city name keeps its room. The truncation unit and the tick come from one place, and stale 200 ms comments are gone. --- .../clockula/data/di/TimeModule.kt | 5 +++ .../clockula/data/time/AndroidClockChanges.kt | 43 +++++++++++++++++++ .../clockula/data/zones/ZoneDirectory.kt | 4 +- .../clockula/domain/WorldClock.kt | 4 +- .../clockula/domain/time/Clocks.kt | 10 +++++ .../ui/worldclock/WorldClockDefaults.kt | 16 ++++--- .../clockula/ui/worldclock/WorldClockRow.kt | 11 ++++- .../ui/worldclock/WorldClockRowState.kt | 12 ++---- .../ui/worldclock/WorldClockScreen.kt | 14 ++++-- .../ui/worldclock/WorldClockSource.kt | 12 ++++-- .../ui/worldclock/WorldClockViewModel.kt | 2 +- .../clockula/testing/FakeTicker.kt | 14 ++++++ .../clockula/testing/WorldClockHarness.kt | 2 + .../ui/worldclock/WorldClockRowsTest.kt | 8 ++-- .../ui/worldclock/WorldClockViewModelTest.kt | 15 +++++++ docs/ARCHITECTURE.md | 18 +++++--- 16 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index afd6f72..b7e5a05 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -5,11 +5,13 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider +import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges import de.jeanlucmakiola.clockula.data.time.RealTicker import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock @@ -39,4 +41,7 @@ abstract class TimeModule { @Binds abstract fun bindTicker(impl: RealTicker): Ticker + + @Binds + abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges } 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 new file mode 100644 index 0000000..5e4f12c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.clockula.data.time + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.domain.time.ClockChanges +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +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. + */ +@Singleton +class AndroidClockChanges @Inject constructor( + @param:ApplicationContext private val context: Context, +) : ClockChanges { + + override fun changes(): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_TIME_TICK) + } + // Protected system broadcasts still arrive at a non-exported receiver, + // and nothing else on the device gets to poke the tab. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { context.unregisterReceiver(receiver) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt index 2bd5f17..ea784bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt @@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor( /** * One display name per id in [zoneIds], on the same memo. Batched for the - * same reason [entriesFor] is: the tab asks five times a second, and a - * dispatch and a lock per row would be a hundred and twenty-five of each. + * same reason [entriesFor] is: the tab asks on every tick (once a second with + * seconds on), and a dispatch and a lock per row would be twenty-five of each. */ suspend fun displayNamesOf(zoneIds: List, at: Instant): Map = withContext(io) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt index 557791c..f264fbf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt @@ -17,8 +17,8 @@ object Zones { /** * Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry - * `HashSet` on every call, and the world clock asks per row per 200 ms tick - * — twenty-four cities would be a hundred and twenty-five throwaway sets a + * `HashSet` on every call, and the world clock asks per row per tick — + * with seconds on, twenty-four cities would be twenty-five throwaway sets a * second. The device's tzdata arrives as an APEX module that takes effect * at reboot, so the answer cannot change under a running process. */ 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 2b1d575..8167665 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 @@ -49,3 +49,13 @@ interface Ticker { */ fun alignedTicks(unit: Duration): Flow } + +/** + * 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. + */ +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 73724e3..028ba46 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 @@ -2,9 +2,9 @@ package de.jeanlucmakiola.clockula.ui.worldclock import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import java.time.temporal.ChronoUnit import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration internal object WorldClockDefaults { /** @@ -12,11 +12,17 @@ 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 = 1.minutes + val MinuteTick: Duration = ChronoUnit.MINUTES.duration.toKotlinDuration() - val SecondTick: Duration = 1.seconds + val SecondTick: Duration = ChronoUnit.SECONDS.duration.toKotlinDuration() - fun tickFor(showSeconds: Boolean): Duration = if (showSeconds) SecondTick else MinuteTick + /** + * 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. + */ + fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES + + 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 095d3fa..1c42776 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 @@ -52,6 +52,7 @@ internal fun WorldClockRow( row: WorldClockRowState, position: Position, formatTime: (LocalTime) -> String, + showSeconds: Boolean, dragHandle: Modifier, isDragging: Boolean, onMoveUp: () -> Unit, @@ -134,7 +135,15 @@ internal fun WorldClockRow( text = time, // Tabular, because M7 made the headline role tabular // app-wide: a column of times must not jitter as it ticks. - style = MaterialTheme.typography.headlineMedium, + // 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 + }, + maxLines = 1, + softWrap = false, fontWeight = FontWeight.Bold, color = if (isDragging) { MaterialTheme.colorScheme.primary diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt index 6f729b3..e516355 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -11,7 +11,6 @@ import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat import java.time.LocalTime import java.time.ZoneId -import java.time.temporal.ChronoUnit import kotlin.time.Instant import kotlin.time.toJavaInstant @@ -74,7 +73,7 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, - showSeconds: Boolean = false, + showSeconds: Boolean, ): List = clocks.map { clock -> val known = clock.isKnownZone val comparison = if (known) { @@ -86,7 +85,7 @@ internal object WorldClockRows { id = clock.id, zoneId = clock.zoneId, name = clock.label ?: cityOf(clock.zoneId, entries), - time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(unitFor(showSeconds)), + time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(WorldClockDefaults.unitFor(showSeconds)), offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, zoneName = zoneNames[clock.zoneId], @@ -100,11 +99,11 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, - showSeconds: Boolean = false, + showSeconds: Boolean, ): HomeFaceState { // To the unit shown, as the rows are. Without seconds the face has no // second hand, so its hands and its readout move once a minute. - val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(unitFor(showSeconds)) + val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(WorldClockDefaults.unitFor(showSeconds)) return HomeFaceState( zoneId = homeZone.id, city = cityOf(homeZone.id, entries), @@ -114,9 +113,6 @@ internal object WorldClockRows { ) } - private fun unitFor(showSeconds: Boolean): ChronoUnit = - if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES - private fun cityOf(zoneId: String, entries: Map): String = entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) } 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 d20dc58..00dd802 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 @@ -117,17 +117,23 @@ fun WorldClockScreen( if (loaded.rows.isEmpty()) { EmptyWorldClocks() } else { + // The column is handed ids, not rows: it re-seeds its working + // order whenever its items change, and rows that tick (every + // second with seconds on) would snap a drag back mid-gesture. + val rowsById = loaded.rows.associateBy { it.id } ReorderableColumn( - items = loaded.rows, - keyOf = { it.id }, - onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) }, + items = loaded.rows.map { it.id }, + keyOf = { it }, + onReorder = viewModel::onReorder, modifier = Modifier.fillMaxWidth(), rowHeight = 80.dp, - ) { row, position, dragHandle, isDragging -> + ) { id, position, dragHandle, isDragging -> + val row = rowsById[id] ?: return@ReorderableColumn WorldClockRow( row = row, position = position, formatTime = formatTime, + showSeconds = loaded.showSeconds, dragHandle = dragHandle, isDragging = isDragging, onMoveUp = { viewModel.onMoveUp(row.id) }, 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 2f6f4c6..8f7d729 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 @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.ZoneProvider @@ -17,6 +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 java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -30,6 +32,7 @@ internal class WorldClockSource @Inject constructor( private val zones: ZoneProvider, private val wallClock: WallClock, private val ticker: Ticker, + private val clockChanges: ClockChanges, ) { /** What the picker needs before it can draw a row; null while it is still being built. */ private data class Catalog(val entries: List, val deviceCity: String) @@ -42,9 +45,10 @@ internal class WorldClockSource @Inject constructor( /** * The tab's state. Both the instant and the device zone are re-read on - * **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED` - * free: the tab self-heals within one tick and `SystemEventReceiver` needs - * no new branch (D2). + * **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`. + * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: * once a minute, or once a second when the user turned seconds on (#23). @@ -55,7 +59,7 @@ internal class WorldClockSource @Inject constructor( fun state(): Flow = combine(worldClocks.worldClocks(), storedHomeZoneId, showSeconds, ::Triple) .flatMapLatest { (clocks, storedHome, seconds) -> - ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) + merge(ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)), clockChanges.changes()) .map { build(clocks, storedHome, seconds) } } .distinctUntilChanged() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt index 3a6f516..e997ac5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -38,7 +38,7 @@ class WorldClockViewModel @Inject internal constructor( /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ internal val state: StateFlow = source.state() - // `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and + // `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and // eager sharing would keep it running with nobody looking. The timeout // is what gives rotation its resilience, as on every other tab. .stateIn( 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 e696ff5..a9cd9f4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.testing +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.Ticker import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -44,3 +45,16 @@ class FakeTicker : Ticker { streams.toList().forEach { it.emit(Unit) } } } + +/** [ClockChanges] a test fires by hand, standing in for the system's broadcasts. */ +class FakeClockChanges : ClockChanges { + + private val stream = MutableSharedFlow(extraBufferCapacity = 64) + + override fun changes(): Flow = stream + + /** One `TIME_SET`/`TIMEZONE_CHANGED`/`TIME_TICK` to every collector. */ + suspend fun fire() { + stream.emit(Unit) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt index e823c70..a49f607 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt @@ -36,6 +36,7 @@ class WorldClockHarness( val wallClock: FakeWallClock = FakeWallClock(now) val zones: FakeZoneProvider = FakeZoneProvider() val ticker: FakeTicker = FakeTicker() + val clockChanges: FakeClockChanges = FakeClockChanges() val names: FakeZoneNames = FakeZoneNames() val store: PrefStore = PrefStore( @@ -58,6 +59,7 @@ class WorldClockHarness( zones = zones, wallClock = wallClock, ticker = ticker, + clockChanges = clockChanges, ) val viewModel: WorldClockViewModel = WorldClockViewModel( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt index 02595af..3ee6ac9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt @@ -243,7 +243,7 @@ class WorldClockRowsTest { /** §5.7 #16 */ @Test fun `the hero face is home, to the minute`() { - val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) + val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false) assertThat(home.zoneId).isEqualTo(berlin) assertThat(home.city).isEqualTo("Berlin") @@ -261,7 +261,7 @@ class WorldClockRowsTest { /** §5.7 #17 */ @Test fun `a home zone with no entry falls back to its last segment`() { - val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap()) + val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap(), showSeconds = false) assertThat(home.city).isEqualTo("Ho Chi Minh") assertThat(home.zoneName).isNull() @@ -270,8 +270,8 @@ class WorldClockRowsTest { /** §5.7 #18 */ @Test fun `the dial's morph carries whether it is the middle of the night there`() { - val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap()) - val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap()) + val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false) + val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap(), showSeconds = false) assertThat(night.dayFraction).isWithin(0.001f).of(0f) assertThat(day.dayFraction).isWithin(0.001f).of(1f) 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 9843858..dbaf462 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 @@ -399,6 +399,21 @@ class WorldClockViewModelTest { assertThat(harness.loaded()).isEqualTo(before) } + /** #23: a minute-long cadence must not make a zone change wait a minute. */ + @Test + fun `a zone change shows at once even between minute ticks`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + advanceUntilIdle() + assertThat(harness.loaded().home.zoneId).isEqualTo("Europe/Berlin") + + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.clockChanges.fire() + advanceUntilIdle() + + assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo") + } + /** §5.9 #20, with #23's seconds on. */ @Test fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e6c8b3c..68b8cd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1427,7 +1427,9 @@ nothing but `delay`, which makes it testable under `runTest`'s virtual clock. 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 long `delay` or a set clock does not accumulate. -The world clock is its one caller (§16, "The cadence"). +The world clock is its one caller (§16, "The cadence"), and it merges in +`ClockChanges`, the system's own word that the clock or zone moved, so a long +cadence never means a long wait for those. ### The dismiss challenge, and why it cannot strand anyone @@ -2453,11 +2455,15 @@ 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`. -`TIME_SET` and `TIMEZONE_CHANGED` still self-heal within one tick, which with -seconds off is now up to a minute. That is only reachable by changing the -clock in system settings and returning within the five-second -`WhileSubscribed` grace; any longer and the flow restarts and reads the new -time at once. +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 +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. ### Twenty-four cities, and a reorder a screen reader can reach From 65c86af313a6a4e3750518dfeb35a4574c254e53 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:07:19 +0200 Subject: [PATCH 03/11] 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. From adc5a3bdd6d3a4519706b1bc114ba8fc62f1e20b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:14:08 +0200 Subject: [PATCH 04/11] fix(worldclock): read the whole home readout and catch up after sleep The home readout gave TalkBack its minute-precision time as a description, which replaced "Home", the city and the zone name; it is the node's text now. The clock-change receiver swaps TIME_TICK, which doubled every minute's work, for SCREEN_ON, which covers a delay stretched by deep sleep, and resets the cached default zone on TIMEZONE_CHANGED so the rebuild cannot read the old one. Doc and KDoc drift fixed. --- .../clockula/data/time/AndroidClockChanges.kt | 18 ++++++++++----- .../clockula/domain/time/Clocks.kt | 7 +++--- .../ui/worldclock/WorldClockDefaults.kt | 14 ++++++------ .../clockula/ui/worldclock/WorldClockRow.kt | 17 ++++++-------- .../ui/worldclock/WorldClockScreen.kt | 8 +++++-- .../ui/worldclock/WorldClockSource.kt | 4 ++-- .../clockula/testing/FakeTicker.kt | 2 +- docs/ARCHITECTURE.md | 22 ++++++++++++------- 8 files changed, 54 insertions(+), 38 deletions(-) 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 From 7a86e5488e92cdd64a1510c6a72b38425099d26f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:22:47 +0200 Subject: [PATCH 05/11] fix(worldclock): refresh on resume and size the row's time once With the tick a minute long, a language change left the city names in the old language until the next minute; the screen now sends a refresh on every resume. The row's time is sized once per format against a wide sample instead of searched for every second, and ellipsizes as a last resort rather than clipping. BackupSettings takes the setting without a default, so a snapshot cannot drop it silently. --- .../clockula/domain/backup/BackupContent.kt | 2 +- .../clockula/ui/worldclock/WorldClockRow.kt | 49 +++++++++++++------ .../ui/worldclock/WorldClockScreen.kt | 7 +++ .../ui/worldclock/WorldClockSource.kt | 16 +++--- .../ui/worldclock/WorldClockViewModel.kt | 16 +++++- .../clockula/data/backup/BackupCodecTest.kt | 1 + .../clockula/data/prefs/SettingsPrefsTest.kt | 1 + .../ui/worldclock/WorldClockViewModelTest.kt | 19 +++++++ docs/ARCHITECTURE.md | 6 ++- 9 files changed, 92 insertions(+), 25 deletions(-) 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 f4ac30e..d06af97 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 @@ -57,5 +57,5 @@ data class BackupSettings( val defaults: ClockDefaults, val timerPresets: List, /** The world clock's "Display time with seconds" (#23). */ - val worldClockSeconds: Boolean = false, + val worldClockSeconds: Boolean, ) 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 a3b2de5..2ece850 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 @@ -8,15 +8,16 @@ 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 import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -24,10 +25,11 @@ 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.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer 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 @@ -139,11 +141,14 @@ internal fun WorldClockRow( ) } if (time != null) { - val timeStyle = if (showSeconds) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.headlineMedium - } + val timeStyle = fittedTimeStyle( + base = if (showSeconds) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.headlineMedium + }, + formatTime = formatTime, + ) Text( text = time, // Tabular, because M7 made the headline role tabular @@ -153,19 +158,12 @@ internal fun WorldClockRow( style = timeStyle, 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 = timeStyle.fontSize, - ), + overflow = TextOverflow.Ellipsis, // 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 } else { @@ -180,7 +178,26 @@ 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 +/** A wide time in every format: two-digit hour, eights, and PM where there is one. */ +private val WidestSample: LocalTime = LocalTime.of(22, 58, 58) + +/** + * [base], bold, shrunk just enough that [WidestSample] fits [TimeMaxWidth], so + * at a large font scale the time cannot take the whole row and leave the city + * name no width at all. Measured once per format and scale rather than per + * tick: the digits are tabular, so every time is as wide as the sample. + */ +@Composable +private fun fittedTimeStyle(base: TextStyle, formatTime: (LocalTime) -> String): TextStyle { + val measurer = rememberTextMeasurer(cacheSize = 0) + val density = LocalDensity.current + return remember(base, formatTime, density) { + val style = base.copy(fontWeight = FontWeight.Bold) + val width = measurer.measure(formatTime(WidestSample), style, maxLines = 1, softWrap = false).size.width + val cap = with(density) { TimeMaxWidth.toPx() } + if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + } +} /** * The offset, and the day only when it differs: "Same time as home" stands 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 7cfe2e7..0741c28 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 @@ -33,6 +33,7 @@ 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 +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks @@ -68,6 +69,12 @@ fun WorldClockScreen( viewModel: WorldClockViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + // A language change recreates the activity and nothing else tells the + // tab: without this, city names would wait for the next minute's tick. + LifecycleResumeEffect(viewModel) { + viewModel.onResumed() + onPauseOrDispose {} + } val picker by viewModel.picker.collectAsStateWithLifecycle() val pillInset = LocalLivePillInset.current val loaded = state as? WorldClockUiState.Loaded 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 c0533b1..7144c81 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 @@ -15,9 +15,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow 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 @@ -40,8 +42,6 @@ internal class WorldClockSource @Inject constructor( private val storedHomeZoneId: Flow = settings.defaults.map { it.homeZoneId }.distinctUntilChanged() - private val showSeconds: Flow = settings.worldClockSeconds - /** * 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 @@ -50,7 +50,11 @@ internal class WorldClockSource @Inject constructor( * 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. + * first state through without waiting for a broadcast. [refreshes] does + * the same for what no broadcast announces: the screen sends one on every + * resume, which covers a per-app language change (the activity is + * recreated, the ViewModel and this flow are not) without waiting for the + * next minute. * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: @@ -59,12 +63,12 @@ internal class WorldClockSource @Inject constructor( * state and `distinctUntilChanged` only drops the odd early wake (D18). */ @OptIn(ExperimentalCoroutinesApi::class) - fun state(): Flow = + fun state(refreshes: Flow = emptyFlow()): Flow = combine( worldClocks.worldClocks(), storedHomeZoneId, - showSeconds, - clockChanges.changes().onStart { emit(Unit) }, + settings.worldClockSeconds, + merge(clockChanges.changes(), refreshes).onStart { emit(Unit) }, ) { clocks, storedHome, seconds, _ -> Triple(clocks, storedHome, seconds) } .flatMapLatest { (clocks, storedHome, seconds) -> ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt index e997ac5..6f14b9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.domain.Zones import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -36,8 +38,11 @@ class WorldClockViewModel @Inject internal constructor( private val query = MutableStateFlow("") + /** One per screen resume; see [onResumed]. Buffered, so a send never suspends. */ + private val refreshes = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ - internal val state: StateFlow = source.state() + internal val state: StateFlow = source.state(refreshes) // `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and // eager sharing would keep it running with nobody looking. The timeout // is what gives rotation its resilience, as on every other tab. @@ -134,6 +139,15 @@ class WorldClockViewModel @Inject internal constructor( } } + /** + * The screen came back. Re-reads the clock, the zone and the names at once + * rather than at the next minute: a language change recreates the activity + * but not this ViewModel, and announces itself with no broadcast. + */ + fun onResumed() { + refreshes.tryEmit(Unit) + } + private companion object { /** The window a rotation has to re-subscribe in before the ticker is dropped. */ const val SUBSCRIPTION_GRACE_MS = 5_000L 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 c876048..4dca1df 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 @@ -85,6 +85,7 @@ class BackupCodecTest { dynamicColor = true, defaults = ClockDefaults(), timerPresets = emptyList(), + worldClockSeconds = false, ), ) val decoded = BackupCodec.decode(BackupCodec.encode(original)) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..c5b1d57 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -76,6 +76,7 @@ class SettingsPrefsTest { dynamicColor = false, defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), + worldClockSeconds = false, ), ) 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 247cca3..0666b93 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 @@ -470,6 +470,25 @@ class WorldClockViewModelTest { harness.ticker.tick() advanceUntilIdle() + assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін") + } + + /** #23: with the tick a minute long, a resume is what brings a new language in. */ + @Test + fun `a resume renames the rows without waiting for a tick`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Europe/Berlin", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + harness.names.cities = mapOf("Europe/Berlin" to "Berlin") + advanceUntilIdle() + + harness.names.localeTag = "uk" + harness.names.cities = mapOf("Europe/Berlin" to "Берлін") + harness.viewModel.onResumed() + advanceUntilIdle() + assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін") } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7de204..acd72fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2473,7 +2473,11 @@ 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 -`distinctUntilChanged` drops when nothing moved. +`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, and the city names (§16 above) would otherwise wait for the next +minute to change language. 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 From 71875e95e42581ae07b7451f56143afd320f870a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:32:44 +0200 Subject: [PATCH 06/11] fix(worldclock): fit the seconds readouts without touching the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's time is fitted only with seconds on, against both day periods since an AM marker can be the wider one, and the style is built once by the screen rather than per row. With seconds off the row is exactly as before. The home readout stays on one line with seconds, shrinking to fit instead of wrapping. SCREEN_ON is dropped: the tab cannot be seen again without a resume, which already refreshes. §5 no longer claims a clock change heals within one tick for free. --- .../clockula/data/time/AndroidClockChanges.kt | 11 ++-- .../clockula/domain/time/Clocks.kt | 7 +-- .../clockula/ui/worldclock/WorldClockRow.kt | 58 ++++++++++--------- .../ui/worldclock/WorldClockScreen.kt | 12 +++- .../ui/worldclock/WorldClockSource.kt | 8 +-- .../clockula/testing/FakeTicker.kt | 2 +- docs/ARCHITECTURE.md | 32 +++++----- 7 files changed, 71 insertions(+), 59 deletions(-) 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 58c0e2e..82d49f2 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 @@ -15,12 +15,10 @@ import javax.inject.Inject import javax.inject.Singleton /** - * `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. + * `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver that is registered + * only while somebody collects. Both already have their manifest receiver + * (`SystemEventReceiver`) for the alarm engine; this one is for a readout on + * screen, which must not wait out its cadence to show either. */ @Singleton class AndroidClockChanges @Inject constructor( @@ -41,7 +39,6 @@ class AndroidClockChanges @Inject constructor( val filter = IntentFilter().apply { addAction(Intent.ACTION_TIME_CHANGED) addAction(Intent.ACTION_TIMEZONE_CHANGED) - 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 f1d7e41..4bb48b0 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,10 +52,9 @@ interface Ticker { /** * The system saying the wall clock may not read what a readout last drew: the - * 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. + * time was set or the zone changed. 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/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt index 2ece850..7bf6ef4 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel @@ -59,7 +61,7 @@ internal fun WorldClockRow( position: Position, formatTime: (LocalTime) -> String, formatSpokenTime: (LocalTime) -> String, - showSeconds: Boolean, + timeStyle: RowTimeStyle, dragHandle: Modifier, isDragging: Boolean, onMoveUp: () -> Unit, @@ -141,28 +143,18 @@ internal fun WorldClockRow( ) } if (time != null) { - val timeStyle = fittedTimeStyle( - base = if (showSeconds) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.headlineMedium - }, - formatTime = formatTime, - ) 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 = timeStyle, + style = timeStyle.style, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, // 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) + .widthIn(max = timeStyle.maxWidth) .clearAndSetSemantics {}, color = if (isDragging) { MaterialTheme.colorScheme.primary @@ -175,27 +167,41 @@ internal fun WorldClockRow( } } -/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ -private val TimeMaxWidth = 168.dp +/** How a row draws its time; the same for every row, so the screen builds it once. */ +@Immutable +internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) -/** A wide time in every format: two-digit hour, eights, and PM where there is one. */ -private val WidestSample: LocalTime = LocalTime.of(22, 58, 58) +/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ +private val SecondsMaxWidth = 168.dp /** - * [base], bold, shrunk just enough that [WidestSample] fits [TimeMaxWidth], so - * at a large font scale the time cannot take the whole row and leave the city - * name no width at all. Measured once per format and scale rather than per - * tick: the digits are tabular, so every time is as wide as the sample. + * Wide times in every format: a two-digit hour, eights, and each day period, + * since a locale's AM marker can be wider than its PM one. + */ +private val WidestSamples: List = listOf(LocalTime.of(10, 58, 58), LocalTime.of(22, 58, 58)) + +/** + * Without seconds, the row's time exactly as it always was: `headlineMedium`, + * bold, uncapped. With seconds, a size down, and shrunk further only if the + * widest sample would not fit [SecondsMaxWidth], so at a large font scale the + * time cannot take the whole row and leave the city no width at all. + * Measured once per format, style and density, never per tick: the digits are + * tabular, so no time is wider than the samples. */ @Composable -private fun fittedTimeStyle(base: TextStyle, formatTime: (LocalTime) -> String): TextStyle { +internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) -> String): RowTimeStyle { + val base = if (showSeconds) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium val measurer = rememberTextMeasurer(cacheSize = 0) val density = LocalDensity.current - return remember(base, formatTime, density) { + return remember(showSeconds, base, formatTime, density) { val style = base.copy(fontWeight = FontWeight.Bold) - val width = measurer.measure(formatTime(WidestSample), style, maxLines = 1, softWrap = false).size.width - val cap = with(density) { TimeMaxWidth.toPx() } - if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + if (!showSeconds) return@remember RowTimeStyle(style, Dp.Infinity) + val width = WidestSamples.maxOf { sample -> + measurer.measure(formatTime(sample), style, maxLines = 1, softWrap = false).size.width + } + val cap = with(density) { SecondsMaxWidth.toPx() } + val fitted = if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + RowTimeStyle(fitted, SecondsMaxWidth) } } 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 0741c28..2fcba37 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Home @@ -81,6 +82,7 @@ fun WorldClockScreen( val formatTime = rememberLocalTimeFormatter(withSeconds = loaded?.showSeconds == true) // What TalkBack is given, always to the minute: see WorldClockRow. val formatSpokenTime = rememberLocalTimeFormatter() + val rowTimeStyle = rememberRowTimeStyle(showSeconds = loaded?.showSeconds == true, formatTime = formatTime) // Saveable, not remembered: a rotation mid-confirmation must not lose the // question it was asking (D13). @@ -151,7 +153,7 @@ fun WorldClockScreen( position = position, formatTime = formatTime, formatSpokenTime = formatSpokenTime, - showSeconds = loaded.showSeconds, + timeStyle = rowTimeStyle, dragHandle = dragHandle, isDragging = isDragging, onMoveUp = { viewModel.onMoveUp(row.id) }, @@ -246,6 +248,14 @@ private fun HomeFace( style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, + // With seconds, one line that shrinks to fit rather than a + // readout that wraps, and moves the list, as the hour widens. + maxLines = if (showSeconds) 1 else Int.MAX_VALUE, + autoSize = if (showSeconds) { + TextAutoSize.StepBased(maxFontSize = MaterialTheme.typography.displaySmall.fontSize) + } else { + null + }, modifier = Modifier .padding(bottom = 8.dp) .clearAndSetSemantics { text = AnnotatedString(spokenTime) }, 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 7144c81..3a7ef95 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,15 +46,15 @@ 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 `SCREEN_ON` - * catches up after a deep sleep stretched a `delay`. It sits + * wait is measured against the clock as it now reads. 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. [refreshes] does * the same for what no broadcast announces: the screen sends one on every * resume, which covers a per-app language change (the activity is - * recreated, the ViewModel and this flow are not) without waiting for the - * next minute. + * recreated, the ViewModel and this flow are not) and a `delay` stretched + * by a deep sleep (the main looper's clock stops while the device sleeps) + * without waiting for the next minute. * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: 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 b0f9dd3..bae45e4 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`/`SCREEN_ON` to every collector. */ + /** One `TIME_SET`/`TIMEZONE_CHANGED` to every collector. */ suspend fun fire() { stream.emit(Unit) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index acd72fc..14538da 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -579,9 +579,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/`. @@ -2460,24 +2462,22 @@ 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 `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. 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 +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. 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. 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 +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, and the city names (§16 above) would otherwise wait for the next -minute to change language. +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 From 6d235e097420c4b63b4f83c8e13ee549efc5ea37 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:40:22 +0200 Subject: [PATCH 07/11] fix(worldclock): fit the seconds time through px, and leave the default wrapping From Android 14 large font scales are non-linear, so scaling the sp size by the width ratio still overflowed the cap; the size is now worked out in px and converted back. Without seconds the row's time wraps as it did before instead of ellipsizing. --- .../clockula/ui/worldclock/WorldClockRow.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 7bf6ef4..61a884d 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 @@ -148,9 +148,11 @@ internal fun WorldClockRow( // Tabular, because M7 made the headline role tabular // app-wide: a column of times must not jitter as it ticks. style = timeStyle.style, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, + // One line only when capped (seconds on); without seconds + // the time wraps at a huge font scale, as it always did. + maxLines = if (timeStyle.capped) 1 else Int.MAX_VALUE, + softWrap = !timeStyle.capped, + overflow = if (timeStyle.capped) TextOverflow.Ellipsis else TextOverflow.Clip, // The row's description already says the time, to the // minute; the ticking text itself stays out of the tree. modifier = Modifier @@ -169,7 +171,9 @@ internal fun WorldClockRow( /** How a row draws its time; the same for every row, so the screen builds it once. */ @Immutable -internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) +internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) { + val capped: Boolean get() = maxWidth != Dp.Infinity +} /** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ private val SecondsMaxWidth = 168.dp @@ -200,7 +204,13 @@ internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) measurer.measure(formatTime(sample), style, maxLines = 1, softWrap = false).size.width } val cap = with(density) { SecondsMaxWidth.toPx() } - val fitted = if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + // Through px and back, not by scaling the sp value: from Android 14 + // large font scales are non-linear, so sp and px are not proportional. + val fitted = if (width <= cap) { + style + } else { + style.copy(fontSize = with(density) { (style.fontSize.toPx() * cap / width).toSp() }) + } RowTimeStyle(fitted, SecondsMaxWidth) } } From 78da94d77541cc215a7f8a0974ca2872ccce2746 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:48:30 +0200 Subject: [PATCH 08/11] fix(worldclock): leave a fitting margin and drop the zone-cache reset The fitted seconds size keeps five percent spare, since hinted glyph advances do not scale exactly. The receiver no longer resets the process's default zone: that was speculative, and it changed global state from a UI flow. The settings state takes the seconds flag without a default, and a garbled KDoc sentence is fixed. --- .../clockula/data/time/AndroidClockChanges.kt | 6 ------ .../jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt | 2 +- .../jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt | 5 ++++- .../clockula/ui/worldclock/WorldClockSource.kt | 5 +++-- docs/ARCHITECTURE.md | 4 +--- 5 files changed, 9 insertions(+), 13 deletions(-) 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 82d49f2..2397192 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,7 +10,6 @@ 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 @@ -28,11 +27,6 @@ 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) } } 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 a85d060..80a6372 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 @@ -15,7 +15,7 @@ internal sealed interface SettingsUiState { val defaults: ClockDefaults, val selfCheckProblemCount: Int? = null, /** The world clock's "Display time with seconds" (#23); not a clock default. */ - val worldClockSeconds: Boolean = false, + val worldClockSeconds: Boolean, ) : SettingsUiState } 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 61a884d..31a92e5 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 @@ -175,6 +175,8 @@ internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) { val capped: Boolean get() = maxWidth != Dp.Infinity } +private const val FitMargin = 0.95f + /** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ private val SecondsMaxWidth = 168.dp @@ -209,7 +211,8 @@ internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) val fitted = if (width <= cap) { style } else { - style.copy(fontSize = with(density) { (style.fontSize.toPx() * cap / width).toSp() }) + // A few percent spare: hinted glyph advances do not scale exactly. + style.copy(fontSize = with(density) { (style.fontSize.toPx() * FitMargin * cap / width).toSp() }) } RowTimeStyle(fitted, SecondsMaxWidth) } 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 3a7ef95..4511f61 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 @@ -45,8 +45,9 @@ 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. It sits + * `TIMEZONE_CHANGED` show at once rather than at the next minute, and the + * restarted ticker measures its next wait against the clock as it now + * reads. 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. [refreshes] does diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 14538da..86f1f56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2464,9 +2464,7 @@ 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. 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. The signal is combined +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 From d70d0c78dabe9b04127d70ed96042fbd53409cf5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:57:19 +0200 Subject: [PATCH 09/11] test(worldclock): make the seconds tests mean what they say The no-re-emission check now waits for the write to be readable before asserting, and the seconds switch has its own settings test instead of riding in the clock-defaults one. WorldClockSource.state takes its refresh signal without a default, and ARCHITECTURE.md's slice count and alignedTicks guarantee match the code. --- .../clockula/ui/worldclock/WorldClockSource.kt | 3 +-- .../clockula/data/prefs/ClockPrefsTest.kt | 3 +++ .../ui/settings/SettingsViewModelTest.kt | 17 +++++++++++++++-- docs/ARCHITECTURE.md | 10 ++++++---- 4 files changed, 25 insertions(+), 8 deletions(-) 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 4511f61..f250453 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 @@ -15,7 +15,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -64,7 +63,7 @@ internal class WorldClockSource @Inject constructor( * state and `distinctUntilChanged` only drops the odd early wake (D18). */ @OptIn(ExperimentalCoroutinesApi::class) - fun state(refreshes: Flow = emptyFlow()): Flow = + fun state(refreshes: Flow): Flow = combine( worldClocks.worldClocks(), storedHomeZoneId, 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 43225db..c11a2c6 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 @@ -90,6 +90,9 @@ class ClockPrefsTest { assertThat(awaitItem()).isEqualTo(ClockDefaults()) prefs.setWorldClockSeconds(true) + // Wait until the write is visible to a reader, so a re-emission of + // the defaults would have had its chance to arrive. + prefs.worldClockSeconds.first { it } expectNoEvents() cancel() 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 2dd8c52..4391896 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 @@ -119,7 +119,6 @@ class SettingsViewModelTest { viewModel.onSnoozeLimitChanged(2) viewModel.onVolumeRampChanged(30) viewModel.onDismissChallengeChanged(DismissChallenge.MATH) - viewModel.onWorldClockSecondsChanged(true) advanceUntilIdle() val defaults = (viewModel.state.value as SettingsUiState.Loaded).defaults @@ -128,9 +127,23 @@ class SettingsViewModelTest { assertThat(defaults.snoozeLimit).isEqualTo(2) assertThat(defaults.volumeRampSeconds).isEqualTo(30) assertThat(defaults.dismissChallenge).isEqualTo(DismissChallenge.MATH) - assertThat((viewModel.state.value as SettingsUiState.Loaded).worldClockSeconds).isTrue() } + @Test + fun `the world clock's seconds switch writes through, apart from the defaults`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val viewModel = collecting(newViewModel(tempDir)) + advanceUntilIdle() + val defaultsBefore = (viewModel.state.value as SettingsUiState.Loaded).defaults + + viewModel.onWorldClockSecondsChanged(true) + advanceUntilIdle() + + val loaded = viewModel.state.value as SettingsUiState.Loaded + assertThat(loaded.worldClockSeconds).isTrue() + assertThat(loaded.defaults).isEqualTo(defaultsBefore) + } + @Test fun `choosing a default alarm ringtone writes through and closes the picker`(@TempDir tempDir: Path) = runTest(main.dispatcher) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86f1f56..09a9585 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -592,7 +592,7 @@ 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 | |---|---|---| @@ -1429,9 +1429,11 @@ 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 long `delay` or a set clock does not accumulate. -The world clock is its one caller (§16, "The cadence"), and it merges in -`ClockChanges`, the system's own word that the clock or zone moved, so a long +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 From 0218339161236dbc0c8487268496171f7e6193e7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:11:20 +0200 Subject: [PATCH 10/11] fix(worldclock): let the seconds time grow on wide windows The row's seconds time was capped at 168dp everywhere, so on a tablet a larger font scale was shrunk back for no reason. The cap now grows to 45% of the window, with 168dp as the floor, and the fitted size is recomputed when the text measurer changes. A row the column has not re-seeded yet is skipped rather than crashing, and the source's KDoc is re-wrapped. --- .../clockula/ui/worldclock/WorldClockRow.kt | 21 +++++++++++++------ .../ui/worldclock/WorldClockScreen.kt | 4 +++- .../ui/worldclock/WorldClockSource.kt | 18 ++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) 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 31a92e5..315c91c 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -177,8 +178,14 @@ internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) { private const val FitMargin = 0.95f -/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ -private val SecondsMaxWidth = 168.dp +/** + * The least the time may take: wide enough for "12:59:59 PM" at the default + * scale. On a wider window the cap grows with it, to [SecondsMaxShare] of the + * window, so a larger font scale is honoured wherever there is room for it. + */ +private val SecondsMinCap = 168.dp + +private const val SecondsMaxShare = 0.45f /** * Wide times in every format: a two-digit hour, eights, and each day period, @@ -189,7 +196,7 @@ private val WidestSamples: List = listOf(LocalTime.of(10, 58, 58), Lo /** * Without seconds, the row's time exactly as it always was: `headlineMedium`, * bold, uncapped. With seconds, a size down, and shrunk further only if the - * widest sample would not fit [SecondsMaxWidth], so at a large font scale the + * widest sample would not fit the cap, so at a large font scale the * time cannot take the whole row and leave the city no width at all. * Measured once per format, style and density, never per tick: the digits are * tabular, so no time is wider than the samples. @@ -199,13 +206,15 @@ internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) val base = if (showSeconds) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium val measurer = rememberTextMeasurer(cacheSize = 0) val density = LocalDensity.current - return remember(showSeconds, base, formatTime, density) { + val windowWidth = LocalConfiguration.current.screenWidthDp.dp + val maxWidth = maxOf(SecondsMinCap, windowWidth * SecondsMaxShare) + return remember(showSeconds, base, formatTime, density, measurer, maxWidth) { val style = base.copy(fontWeight = FontWeight.Bold) if (!showSeconds) return@remember RowTimeStyle(style, Dp.Infinity) val width = WidestSamples.maxOf { sample -> measurer.measure(formatTime(sample), style, maxLines = 1, softWrap = false).size.width } - val cap = with(density) { SecondsMaxWidth.toPx() } + val cap = with(density) { maxWidth.toPx() } // Through px and back, not by scaling the sp value: from Android 14 // large font scales are non-linear, so sp and px are not proportional. val fitted = if (width <= cap) { @@ -214,7 +223,7 @@ internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) // A few percent spare: hinted glyph advances do not scale exactly. style.copy(fontSize = with(density) { (style.fontSize.toPx() * FitMargin * cap / width).toSp() }) } - RowTimeStyle(fitted, SecondsMaxWidth) + RowTimeStyle(fitted, maxWidth) } } 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 2fcba37..b4efa20 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 @@ -147,7 +147,9 @@ fun WorldClockScreen( modifier = Modifier.fillMaxWidth(), rowHeight = 80.dp, ) { id, position, dragHandle, isDragging -> - val row = rowsById.getValue(id) + // Always present while the column re-seeds in composition; + // drawn as nothing rather than crashing if that ever lags. + val row = rowsById[id] ?: return@ReorderableColumn WorldClockRow( row = row, position = position, 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 f250453..d38424b 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,17 +46,17 @@ internal class WorldClockSource @Inject constructor( * **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and * `TIMEZONE_CHANGED` show at once rather than at the next minute, and the * restarted ticker measures its next wait against the clock as it now - * reads. 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. [refreshes] does - * the same for what no broadcast announces: the screen sends one on every - * resume, which covers a per-app language change (the activity is - * recreated, the ViewModel and this flow are not) and a `delay` stretched - * by a deep sleep (the main looper's clock stops while the device sleeps) - * without waiting for the next minute. + * reads. 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). * + * [refreshes] does the same for what no broadcast announces: the screen + * sends one on every resume, which covers a per-app language change (the + * activity is recreated, the ViewModel and this flow are not) and a + * `delay` stretched by a deep sleep (the main looper's clock stops while + * the device sleeps) without waiting for the next minute. + * * The tick is aligned to the wall clock and only as fine as the readouts: * once a minute, or once a second when the user turned seconds on (#23). * Everything shown is truncated to that same unit, so each tick is one new From 47165ce93fda77297b2ede4dfaf5894821ee163a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 11:22:05 +0200 Subject: [PATCH 11/11] fix(worldclock): keep a ticking row's accessibility actions stable With seconds on every row recomposed each second and rebuilt its custom actions from new lambdas, so TalkBack saw changed semantics every second. The callbacks are remembered per id and the action list per label and callback. The prefs isolation test now uses a barrier on the same flow, a weak settings assertion is gone, and a clock set is tested to show at once and restart the minute tick. --- .../clockula/ui/worldclock/WorldClockRow.kt | 35 +++++++++++-------- .../ui/worldclock/WorldClockScreen.kt | 9 +++-- .../clockula/data/prefs/ClockPrefsTest.kt | 7 ++-- .../ui/settings/SettingsViewModelTest.kt | 7 ++-- .../ui/worldclock/WorldClockViewModelTest.kt | 20 +++++++++++ 5 files changed, 53 insertions(+), 25 deletions(-) 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 315c91c..b6c6219 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 @@ -83,20 +83,27 @@ internal fun WorldClockRow( } else { stringResource(R.string.world_clock_row_description_unknown, row.name, summary) } - val actions = listOf( - CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_up)) { - onMoveUp() - true - }, - CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_down)) { - onMoveDown() - true - }, - CustomAccessibilityAction(stringResource(R.string.world_clock_row_remove)) { - onRemove() - true - }, - ) + val moveUpLabel = stringResource(R.string.world_clock_row_move_up) + val moveDownLabel = stringResource(R.string.world_clock_row_move_down) + val removeLabel = stringResource(R.string.world_clock_row_remove) + // Remembered, so a row that ticks every second keeps equal actions and its + // semantics only change when what TalkBack would say changes. + val actions = remember(moveUpLabel, moveDownLabel, removeLabel, onMoveUp, onMoveDown, onRemove) { + listOf( + CustomAccessibilityAction(moveUpLabel) { + onMoveUp() + true + }, + CustomAccessibilityAction(moveDownLabel) { + onMoveDown() + true + }, + CustomAccessibilityAction(removeLabel) { + onRemove() + true + }, + ) + } GroupedSurface( position = position, 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 b4efa20..791959c 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 @@ -21,6 +21,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -158,9 +159,11 @@ fun WorldClockScreen( timeStyle = rowTimeStyle, dragHandle = dragHandle, isDragging = isDragging, - onMoveUp = { viewModel.onMoveUp(row.id) }, - onMoveDown = { viewModel.onMoveDown(row.id) }, - onRemove = { pendingRemoval = row.id }, + // Remembered per id: a row that ticks every second must + // not hand TalkBack new custom actions every second. + onMoveUp = remember(id) { { viewModel.onMoveUp(id) } }, + onMoveDown = remember(id) { { viewModel.onMoveDown(id) } }, + onRemove = remember(id) { { pendingRemoval = id } }, ) } } 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 c11a2c6..a23c986 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 @@ -90,10 +90,11 @@ class ClockPrefsTest { assertThat(awaitItem()).isEqualTo(ClockDefaults()) prefs.setWorldClockSeconds(true) - // Wait until the write is visible to a reader, so a re-emission of - // the defaults would have had its chance to arrive. - prefs.worldClockSeconds.first { it } + // A barrier on this same flow: the next item must be this write, + // so a re-emission caused by the seconds write would come first. + prefs.setSnoozeMinutes(7) + assertThat(awaitItem()).isEqualTo(ClockDefaults(snoozeMinutes = 7)) expectNoEvents() cancel() } 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 4391896..af80c34 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 @@ -130,18 +130,15 @@ class SettingsViewModelTest { } @Test - fun `the world clock's seconds switch writes through, apart from the defaults`(@TempDir tempDir: Path) = + fun `the world clock's seconds switch writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) { val viewModel = collecting(newViewModel(tempDir)) advanceUntilIdle() - val defaultsBefore = (viewModel.state.value as SettingsUiState.Loaded).defaults viewModel.onWorldClockSecondsChanged(true) advanceUntilIdle() - val loaded = viewModel.state.value as SettingsUiState.Loaded - assertThat(loaded.worldClockSeconds).isTrue() - assertThat(loaded.defaults).isEqualTo(defaultsBefore) + 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 0666b93..236858e 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 @@ -20,6 +20,7 @@ import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path import java.time.ZoneId +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** @@ -414,6 +415,25 @@ class WorldClockViewModelTest { assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo") } + /** #23: a set clock shows at once, and the tick restarts against the new time. */ + @Test + fun `a clock set shows at once and restarts the minute tick`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + advanceUntilIdle() + val before = harness.loaded().home.time + + harness.wallClock.advance(5.minutes) + harness.clockChanges.fire() + advanceUntilIdle() + + assertThat(harness.loaded().home.time).isEqualTo(before.plusMinutes(5)) + assertThat(harness.ticker.requestedUnits).containsExactly( + WorldClockDefaults.tickFor(showSeconds = false), + WorldClockDefaults.tickFor(showSeconds = false), + ) + } + /** §5.9 #20, with #23's seconds on. */ @Test fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) =