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:
+2
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+46
-4
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user