From 785adf715b95afd3881e9904ea573b50843c078a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 18:48:15 +0200 Subject: [PATCH] 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,