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
This commit is contained in:
2026-10-21 18:48:15 +02:00
parent 3f431a7f32
commit 785adf715b
30 changed files with 455 additions and 65 deletions
+2 -1
View File
@@ -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
@@ -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,
)
@@ -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
@@ -35,6 +35,8 @@ object ClockPrefs {
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
.map(decode = Zones::normalise, encode = Zones::normalise)
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
@@ -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) ---
/**
@@ -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<Unit> = flow {
while (true) {
emit(Unit)
delay(WallBoundaries.untilNext(wallClock.now(), unit))
}
}
}
@@ -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,
)
/**
@@ -40,4 +40,12 @@ interface ZoneProvider {
*/
interface Ticker {
fun ticks(period: Duration): Flow<Unit>
/**
* 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<Unit>
}
@@ -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
}
}
@@ -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) {
@@ -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) },
)
}
}
@@ -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() {
@@ -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
@@ -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()))
}
@@ -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<WorldClockRowState>,
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<WorldClock>,
@@ -71,6 +74,7 @@ internal object WorldClockRows {
now: Instant,
entries: Map<String, ZoneEntry>,
zoneNames: Map<String, String?>,
showSeconds: Boolean = false,
): List<WorldClockRowState> = 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<String, ZoneEntry>,
zoneNames: Map<String, String?>,
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, ZoneEntry>): String =
entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId)
}
@@ -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,
@@ -37,22 +37,26 @@ internal class WorldClockSource @Inject constructor(
private val storedHomeZoneId: Flow<String?> =
settings.defaults.map { it.homeZoneId }.distinctUntilChanged()
private val showSeconds: Flow<Boolean> =
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<WorldClockUiState.Loaded> =
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<WorldClock>,
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,
)
}
+3 -1
View File
@@ -301,9 +301,11 @@
<string name="settings_timers_sound_subtitle">Sound for new timers</string>
<string name="settings_timer_sound_hint">New timers ring with this sound. Each timer can still change it in its editor.</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone</string>
<string name="settings_clock_subtitle">Home time zone and seconds</string>
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
<string name="settings_home_zone">Home time zone</string>
<string name="settings_world_clock_seconds">Display time with seconds</string>
<string name="settings_world_clock_seconds_hint">The world clock shows seconds and a second hand. Uses a little more battery.</string>
<string name="settings_self_check">Why might my alarm not ring?</string>
<string name="settings_self_check_title">Why might my alarm not ring?</string>
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
@@ -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
@@ -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\": []}")
@@ -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,
),
)
}
@@ -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)
}
}
@@ -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<IllegalArgumentException> { WallBoundaries.untilNext(onASecond, Duration.ZERO) }
}
}
@@ -13,12 +13,26 @@ class FakeTicker : Ticker {
private val streams = mutableListOf<MutableSharedFlow<Unit>>()
private val periods = mutableListOf<Duration>()
private val units = mutableListOf<Duration>()
/** Every period asked for, in order, so a test can pin the cadence. */
val requestedPeriods: List<Duration> get() = periods.toList()
/** Every aligned unit asked for, in order, so a test can pin that cadence too. */
val requestedUnits: List<Duration> get() = units.toList()
override fun ticks(period: Duration): Flow<Unit> {
periods += period
return stream()
}
/** Driven by [tick] like [ticks]: the alignment is `RealTicker`'s, tested there. */
override fun alignedTicks(unit: Duration): Flow<Unit> {
units += unit
return stream()
}
private fun stream(): Flow<Unit> {
val stream = MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64)
check(stream.tryEmit(Unit))
streams += stream
@@ -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
@@ -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<String, String?> = 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))
}
@@ -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 */
+35 -12
View File
@@ -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<ClockDefaults>` |
| 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<ClockDefaults>` |
| 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
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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,