Merge branch 'feat/23-world-clock-seconds' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
This commit is contained in:
makiolaj committed 2026-10-26 20:54:31 +01:00
commit fd7f5e8dcd
40 files changed
+860 -110

No files matched your search

+2 -1
View File
@@ -65,7 +65,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
@@ -115,6 +115,7 @@ class BackupEngine @Inject constructor(
dynamicColor = settings.dynamicColor.first(),
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
worldClockSeconds = settings.worldClockSeconds.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -143,6 +143,7 @@ object BackupCodec {
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
volumeButtons = defaults.volumeButtons.name,
worldClockSeconds = worldClockSeconds,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
bedtime = bedtime?.toDto(),
)
@@ -301,6 +302,7 @@ object BackupCodec {
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
worldClockSeconds = settingsDto.worldClockSeconds,
bedtime = settingsDto.bedtime?.toBedtime(),
)
@@ -93,6 +93,8 @@ data class SettingsDto(
*/
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
/** 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
@@ -5,11 +5,13 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
import de.jeanlucmakiola.clockula.data.time.RealTicker
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
@@ -39,4 +41,7 @@ abstract class TimeModule {
@Binds
abstract fun bindTicker(impl: RealTicker): Ticker
@Binds
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
}
@@ -66,6 +66,14 @@ object ClockPrefs {
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
.map(decode = Zones::normalise, encode = Zones::normalise)
/**
* The world clock tab's readouts to the second, with a second hand on the
* face (#23). Off by default: it is what makes the tab wake every second
* rather than every minute. Not part of `ClockDefaults`, which alarms and
* timers inherit from, so flipping it re-emits nothing they watch.
*/
val worldClockSeconds: Pref<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) })
@@ -118,6 +118,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
@@ -168,6 +169,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
// --- world clock display (#23) ---
/** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */
val worldClockSeconds: Flow<Boolean> = store.flow(ClockPrefs.worldClockSeconds)
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
// --- timer presets (M6) ---
/**
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.clockula.data.time
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver that is registered
* only while somebody collects. Both already have their manifest receiver
* (`SystemEventReceiver`) for the alarm engine; this one is for a readout on
* screen, which must not wait out its cadence to show either.
*/
@Singleton
class AndroidClockChanges @Inject constructor(
@param:ApplicationContext private val context: Context,
) : ClockChanges {
override fun changes(): Flow<Unit> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(Unit)
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_CHANGED)
addAction(Intent.ACTION_TIMEZONE_CHANGED)
}
// Protected system broadcasts still arrive at a non-exported receiver,
// and nothing else on the device gets to poke the tab.
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
}
@@ -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,18 @@ 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 does not accumulate. A wall clock set mid-wait is
* only caught up after that wait ends; a caller that cannot wait that long
* restarts the flow on `ClockChanges`, as the world clock does.
*/
override fun alignedTicks(unit: Duration): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(WallBoundaries.untilNext(wallClock.now(), unit))
}
}
}
@@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor(
/**
* One display name per id in [zoneIds], on the same memo. Batched for the
* same reason [entriesFor] is: the tab asks five times a second, and a
* dispatch and a lock per row would be a hundred and twenty-five of each.
* same reason [entriesFor] is: the tab asks on every tick (once a second with
* seconds on), and a dispatch and a lock per row would be twenty-five of each.
*/
suspend fun displayNamesOf(zoneIds: List<String>, at: Instant): Map<String, String?> =
withContext(io) {
@@ -17,8 +17,8 @@ object Zones {
/**
* Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry
* `HashSet` on every call, and the world clock asks per row per 200 ms tick
* — twenty-four cities would be a hundred and twenty-five throwaway sets a
* `HashSet` on every call, and the world clock asks per row per tick —
* with seconds on, twenty-four cities would be twenty-five throwaway sets a
* second. The device's tzdata arrives as an APEX module that takes effect
* at reboot, so the answer cannot change under a running process.
*/
@@ -61,6 +61,8 @@ data class BackupSettings(
val dynamicColor: Boolean,
val defaults: ClockDefaults,
val timerPresets: List<Duration>,
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
@@ -40,4 +40,22 @@ 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>
}
/**
* The system saying the wall clock may not read what a readout last drew: the
* time was set or the zone changed. A readout on a long cadence restarts its
* tick on this, so it does not wait out its own period to catch up. Emits
* only while collected.
*/
interface ClockChanges {
fun changes(): Flow<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.worldClockSeconds,
onCheckedChange = viewModel::onWorldClockSecondsChanged,
)
},
onClick = { viewModel.onWorldClockSecondsChanged(!state.worldClockSeconds) },
)
}
}
@@ -14,6 +14,8 @@ internal sealed interface SettingsUiState {
val appearance: Appearance,
val defaults: ClockDefaults,
val selfCheckProblemCount: Int? = null,
/** The world clock's "Display time with seconds" (#23); not a clock default. */
val worldClockSeconds: Boolean,
) : SettingsUiState
}
@@ -72,8 +72,9 @@ class SettingsViewModel @Inject internal constructor(
settings.appearance,
settings.defaults,
selfCheckProblemCount,
) { appearance, defaults, problemCount ->
SettingsUiState.Loaded(appearance, defaults, problemCount)
settings.worldClockSeconds,
) { appearance, defaults, problemCount, worldClockSeconds ->
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
/**
@@ -249,6 +250,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() {
@@ -2,12 +2,23 @@ package de.jeanlucmakiola.clockula.ui.worldclock
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import java.time.temporal.ChronoUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toKotlinDuration
internal object WorldClockDefaults {
/** 200 ms — the second hand is never a fifth of a second behind (D18). */
val Tick: Duration = 200.milliseconds
/**
* The unit every time on the tab is truncated to: the minute, or the second
* only when the user asked for them (D18, #23).
*/
fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES
/**
* The tab's cadence, aligned to the wall clock through
* `Ticker.alignedTicks` so the readouts change on the boundary itself.
* Derived from [unitFor], so the readouts and the cadence cannot drift apart.
*/
fun tickFor(showSeconds: Boolean): Duration = unitFor(showSeconds).duration.toKotlinDuration()
val HeroFaceSize: Dp = 200.dp
@@ -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()))
}
@@ -7,22 +7,31 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel
@@ -52,6 +61,8 @@ internal fun WorldClockRow(
row: WorldClockRowState,
position: Position,
formatTime: (LocalTime) -> String,
formatSpokenTime: (LocalTime) -> String,
timeStyle: RowTimeStyle,
dragHandle: Modifier,
isDragging: Boolean,
onMoveUp: () -> Unit,
@@ -63,26 +74,36 @@ internal fun WorldClockRow(
val time = row.time?.let(formatTime)
// One sentence for TalkBack, in the order the row is read aloud in. A row
// whose zone is gone has no time, and its summary already *is* the
// unknown-zone sentence, so naming it twice would be a stutter.
val description = if (time != null) {
stringResource(R.string.world_clock_row_description, row.name, time, summary)
// unknown-zone sentence, so naming it twice would be a stutter. Spoken to
// the minute even with seconds on: a description that changed every
// second would be re-announced every second while the row had focus.
val spokenTime = row.time?.let(formatSpokenTime)
val description = if (spokenTime != null) {
stringResource(R.string.world_clock_row_description, row.name, spokenTime, summary)
} else {
stringResource(R.string.world_clock_row_description_unknown, row.name, summary)
}
val actions = listOf(
CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_up)) {
onMoveUp()
true
},
CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_down)) {
onMoveDown()
true
},
CustomAccessibilityAction(stringResource(R.string.world_clock_row_remove)) {
onRemove()
true
},
)
val moveUpLabel = stringResource(R.string.world_clock_row_move_up)
val moveDownLabel = stringResource(R.string.world_clock_row_move_down)
val removeLabel = stringResource(R.string.world_clock_row_remove)
// Remembered, so a row that ticks every second keeps equal actions and its
// semantics only change when what TalkBack would say changes.
val actions = remember(moveUpLabel, moveDownLabel, removeLabel, onMoveUp, onMoveDown, onRemove) {
listOf(
CustomAccessibilityAction(moveUpLabel) {
onMoveUp()
true
},
CustomAccessibilityAction(moveDownLabel) {
onMoveDown()
true
},
CustomAccessibilityAction(removeLabel) {
onRemove()
true
},
)
}
GroupedSurface(
position = position,
@@ -134,8 +155,17 @@ internal fun WorldClockRow(
text = time,
// Tabular, because M7 made the headline role tabular
// app-wide: a column of times must not jitter as it ticks.
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
style = timeStyle.style,
// One line only when capped (seconds on); without seconds
// the time wraps at a huge font scale, as it always did.
maxLines = if (timeStyle.capped) 1 else Int.MAX_VALUE,
softWrap = !timeStyle.capped,
overflow = if (timeStyle.capped) TextOverflow.Ellipsis else TextOverflow.Clip,
// The row's description already says the time, to the
// minute; the ticking text itself stays out of the tree.
modifier = Modifier
.widthIn(max = timeStyle.maxWidth)
.clearAndSetSemantics {},
color = if (isDragging) {
MaterialTheme.colorScheme.primary
} else {
@@ -147,6 +177,63 @@ internal fun WorldClockRow(
}
}
/** How a row draws its time; the same for every row, so the screen builds it once. */
@Immutable
internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) {
val capped: Boolean get() = maxWidth != Dp.Infinity
}
private const val FitMargin = 0.95f
/**
* The least the time may take: wide enough for "12:59:59 PM" at the default
* scale. On a wider window the cap grows with it, to [SecondsMaxShare] of the
* window, so a larger font scale is honoured wherever there is room for it.
*/
private val SecondsMinCap = 168.dp
private const val SecondsMaxShare = 0.45f
/**
* Wide times in every format: a two-digit hour, eights, and each day period,
* since a locale's AM marker can be wider than its PM one.
*/
private val WidestSamples: List<LocalTime> = listOf(LocalTime.of(10, 58, 58), LocalTime.of(22, 58, 58))
/**
* Without seconds, the row's time exactly as it always was: `headlineMedium`,
* bold, uncapped. With seconds, a size down, and shrunk further only if the
* widest sample would not fit the cap, so at a large font scale the
* time cannot take the whole row and leave the city no width at all.
* Measured once per format, style and density, never per tick: the digits are
* tabular, so no time is wider than the samples.
*/
@Composable
internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) -> String): RowTimeStyle {
val base = if (showSeconds) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium
val measurer = rememberTextMeasurer(cacheSize = 0)
val density = LocalDensity.current
val windowWidth = LocalConfiguration.current.screenWidthDp.dp
val maxWidth = maxOf(SecondsMinCap, windowWidth * SecondsMaxShare)
return remember(showSeconds, base, formatTime, density, measurer, maxWidth) {
val style = base.copy(fontWeight = FontWeight.Bold)
if (!showSeconds) return@remember RowTimeStyle(style, Dp.Infinity)
val width = WidestSamples.maxOf { sample ->
measurer.measure(formatTime(sample), style, maxLines = 1, softWrap = false).size.width
}
val cap = with(density) { maxWidth.toPx() }
// Through px and back, not by scaling the sp value: from Android 14
// large font scales are non-linear, so sp and px are not proportional.
val fitted = if (width <= cap) {
style
} else {
// A few percent spare: hinted glyph advances do not scale exactly.
style.copy(fontSize = with(density) { (style.fontSize.toPx() * FitMargin * cap / width).toSp() })
}
RowTimeStyle(fitted, maxWidth)
}
}
/**
* The offset, and the day only when it differs: "Same time as home" stands
* alone, and "Today" after it would be a sentence saying nothing twice.
@@ -11,7 +11,6 @@ import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry
import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat
import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.time.Instant
import kotlin.time.toJavaInstant
@@ -20,7 +19,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 +35,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 +50,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 +63,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 +73,7 @@ internal object WorldClockRows {
now: Instant,
entries: Map<String, ZoneEntry>,
zoneNames: Map<String, String?>,
showSeconds: Boolean,
): List<WorldClockRowState> = clocks.map { clock ->
val known = clock.isKnownZone
val comparison = if (known) {
@@ -82,7 +85,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(WorldClockDefaults.unitFor(showSeconds)),
offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) },
day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) },
zoneName = zoneNames[clock.zoneId],
@@ -96,11 +99,11 @@ internal object WorldClockRows {
now: Instant,
entries: Map<String, ZoneEntry>,
zoneNames: Map<String, String?>,
showSeconds: Boolean,
): 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(WorldClockDefaults.unitFor(showSeconds))
return HomeFaceState(
zoneId = homeZone.id,
city = cityOf(homeZone.id, entries),
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Home
@@ -20,16 +21,21 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
@@ -65,10 +71,19 @@ fun WorldClockScreen(
viewModel: WorldClockViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// A language change recreates the activity and nothing else tells the
// tab: without this, city names would wait for the next minute's tick.
LifecycleResumeEffect(viewModel) {
viewModel.onResumed()
onPauseOrDispose {}
}
val picker by viewModel.picker.collectAsStateWithLifecycle()
val pillInset = LocalLivePillInset.current
val formatTime = rememberLocalTimeFormatter()
val loaded = state as? WorldClockUiState.Loaded
val formatTime = rememberLocalTimeFormatter(withSeconds = loaded?.showSeconds == true)
// What TalkBack is given, always to the minute: see WorldClockRow.
val formatSpokenTime = rememberLocalTimeFormatter()
val rowTimeStyle = rememberRowTimeStyle(showSeconds = loaded?.showSeconds == true, formatTime = formatTime)
// Saveable, not remembered: a rotation mid-confirmation must not lose the
// question it was asking (D13).
@@ -112,27 +127,43 @@ fun WorldClockScreen(
},
) {
if (loaded != null) {
HomeFace(home = loaded.home, formatTime = formatTime)
HomeFace(
home = loaded.home,
showSeconds = loaded.showSeconds,
formatTime = formatTime,
formatSpokenTime = formatSpokenTime,
)
if (loaded.rows.isEmpty()) {
EmptyWorldClocks()
} else {
// The column is handed ids, not rows: it re-seeds its working
// order whenever its items change, and rows that tick (every
// second with seconds on) would snap a drag back mid-gesture.
val rowsById = loaded.rows.associateBy { it.id }
ReorderableColumn(
items = loaded.rows,
keyOf = { it.id },
onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) },
items = loaded.rows.map { it.id },
keyOf = { it },
onReorder = viewModel::onReorder,
modifier = Modifier.fillMaxWidth(),
rowHeight = 80.dp,
) { row, position, dragHandle, isDragging ->
) { id, position, dragHandle, isDragging ->
// Always present while the column re-seeds in composition;
// drawn as nothing rather than crashing if that ever lags.
val row = rowsById[id] ?: return@ReorderableColumn
WorldClockRow(
row = row,
position = position,
formatTime = formatTime,
formatSpokenTime = formatSpokenTime,
timeStyle = rowTimeStyle,
dragHandle = dragHandle,
isDragging = isDragging,
onMoveUp = { viewModel.onMoveUp(row.id) },
onMoveDown = { viewModel.onMoveDown(row.id) },
onRemove = { pendingRemoval = row.id },
// Remembered per id: a row that ticks every second must
// not hand TalkBack new custom actions every second.
onMoveUp = remember(id) { { viewModel.onMoveUp(id) } },
onMoveDown = remember(id) { { viewModel.onMoveDown(id) } },
onRemove = remember(id) { { pendingRemoval = id } },
)
}
}
@@ -184,7 +215,12 @@ fun WorldClockScreen(
}
@Composable
private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
private fun HomeFace(
home: HomeFaceState,
showSeconds: Boolean,
formatTime: (LocalTime) -> String,
formatSpokenTime: (LocalTime) -> String,
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -195,6 +231,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,
@@ -206,12 +243,27 @@ private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Spoken to the minute: with seconds on, a merged node whose text
// changed every second would be re-announced every second. Set as
// the text, not a description, so the merge still reads "Home",
// the city and the zone name after it.
val spokenTime = formatSpokenTime(home.time)
Text(
text = formatTime(home.time),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.dp),
// With seconds, one line that shrinks to fit rather than a
// readout that wraps, and moves the list, as the hour widens.
maxLines = if (showSeconds) 1 else Int.MAX_VALUE,
autoSize = if (showSeconds) {
TextAutoSize.StepBased(maxFontSize = MaterialTheme.typography.displaySmall.fontSize)
} else {
null
},
modifier = Modifier
.padding(bottom = 8.dp)
.clearAndSetSemantics { text = AnnotatedString(spokenTime) },
)
// The face has no other label saying whose time it is, and a city
// here is otherwise indistinguishable from a city in the list below.
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,6 +33,7 @@ internal class WorldClockSource @Inject constructor(
private val zones: ZoneProvider,
private val wallClock: WallClock,
private val ticker: Ticker,
private val clockChanges: ClockChanges,
) {
/** What the picker needs before it can draw a row; null while it is still being built. */
private data class Catalog(val entries: List<ZoneEntry>, val deviceCity: String)
@@ -39,20 +43,36 @@ internal class WorldClockSource @Inject constructor(
/**
* The tab's state. Both the instant and the device zone are re-read on
* **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
* free: the tab self-heals within one tick and `SystemEventReceiver` needs
* no new branch (D2).
* **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and
* `TIMEZONE_CHANGED` show at once rather than at the next minute, and the
* restarted ticker measures its next wait against the clock as it now
* reads. It sits outside the `flatMapLatest`, so its receiver is
* registered once per subscription rather than once per list edit, and
* `onStart` lets the first state through without waiting for a broadcast.
* `SystemEventReceiver` still 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).
* [refreshes] does the same for what no broadcast announces: the screen
* sends one on every resume, which covers a per-app language change (the
* activity is recreated, the ViewModel and this flow are not) and a
* `delay` stretched by a deep sleep (the main looper's clock stops while
* the device sleeps) without waiting for the next minute.
*
* The tick is aligned to the wall clock and only as fine as the readouts:
* once a minute, or once a second when the user turned seconds on (#23).
* Everything shown is truncated to that same unit, so each tick is one new
* 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) }
fun state(refreshes: Flow<Unit>): Flow<WorldClockUiState.Loaded> =
combine(
worldClocks.worldClocks(),
storedHomeZoneId,
settings.worldClockSeconds,
merge(clockChanges.changes(), refreshes).onStart { emit(Unit) },
) { clocks, storedHome, seconds, _ -> Triple(clocks, storedHome, seconds) }
.flatMapLatest { (clocks, storedHome, seconds) ->
ticker.alignedTicks(WorldClockDefaults.tickFor(seconds))
.map { build(clocks, storedHome, seconds) }
}
.distinctUntilChanged()
@@ -101,6 +121,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 +133,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,
)
}
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -36,9 +38,12 @@ class WorldClockViewModel @Inject internal constructor(
private val query = MutableStateFlow("")
/** One per screen resume; see [onResumed]. Buffered, so a send never suspends. */
private val refreshes = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */
internal val state: StateFlow<WorldClockUiState> = source.state()
// `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and
internal val state: StateFlow<WorldClockUiState> = source.state(refreshes)
// `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and
// eager sharing would keep it running with nobody looking. The timeout
// is what gives rotation its resilience, as on every other tab.
.stateIn(
@@ -134,6 +139,15 @@ class WorldClockViewModel @Inject internal constructor(
}
}
/**
* The screen came back. Re-reads the clock, the zone and the names at once
* rather than at the next minute: a language change recreates the activity
* but not this ViewModel, and announces itself with no broadcast.
*/
fun onResumed() {
refreshes.tryEmit(Unit)
}
private companion object {
/** The window a rotation has to re-subscribe in before the ticker is dropped. */
const val SUBSCRIPTION_GRACE_MS = 5_000L
+3 -1
View File
@@ -350,9 +350,11 @@
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</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.worldClockSeconds.first()).isTrue()
}
@Test
@@ -74,6 +74,7 @@ class BackupCodecTest {
volumeButtons = VolumeButtonAction.SNOOZE,
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
worldClockSeconds = true,
bedtime = BedtimeSettings(
enabled = true,
bedtime = TimeOfDay(23, 15),
@@ -109,6 +110,7 @@ class BackupCodecTest {
dynamicColor = true,
defaults = ClockDefaults(),
timerPresets = emptyList(),
worldClockSeconds = false,
),
)
val decoded = BackupCodec.decode(BackupCodec.encode(original))
@@ -463,6 +465,17 @@ class BackupCodecTest {
assertThat(json).contains("\"default_timer_silence_after_minutes\": 0")
}
@Test
fun `a file from before show-seconds existed keeps it off`() {
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
assertThat(settings.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\": []}")
@@ -94,6 +94,25 @@ class ClockPrefsTest {
}
}
@Test
fun `show-seconds round-trips without touching the clock defaults`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.defaults.test {
assertThat(awaitItem()).isEqualTo(ClockDefaults())
prefs.setWorldClockSeconds(true)
// A barrier on this same flow: the next item must be this write,
// so a re-emission caused by the seconds write would come first.
prefs.setSnoozeMinutes(7)
assertThat(awaitItem()).isEqualTo(ClockDefaults(snoozeMinutes = 7))
expectNoEvents()
cancel()
}
assertThat(prefs.worldClockSeconds.first()).isTrue()
}
@Test
fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
@@ -93,6 +93,7 @@ class SettingsPrefsTest {
timerVolumeRampSeconds = 99,
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
worldClockSeconds = false,
),
)
@@ -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) }
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.Ticker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -13,12 +14,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
@@ -30,3 +45,16 @@ class FakeTicker : Ticker {
streams.toList().forEach { it.emit(Unit) }
}
}
/** [ClockChanges] a test fires by hand, standing in for the system's broadcasts. */
class FakeClockChanges : ClockChanges {
private val stream = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
override fun changes(): Flow<Unit> = stream
/** One `TIME_SET`/`TIMEZONE_CHANGED` to every collector. */
suspend fun fire() {
stream.emit(Unit)
}
}
@@ -36,6 +36,7 @@ class WorldClockHarness(
val wallClock: FakeWallClock = FakeWallClock(now)
val zones: FakeZoneProvider = FakeZoneProvider()
val ticker: FakeTicker = FakeTicker()
val clockChanges: FakeClockChanges = FakeClockChanges()
val names: FakeZoneNames = FakeZoneNames()
val store: PrefStore = PrefStore(
@@ -58,6 +59,7 @@ class WorldClockHarness(
zones = zones,
wallClock = wallClock,
ticker = ticker,
clockChanges = clockChanges,
)
val viewModel: WorldClockViewModel = WorldClockViewModel(
@@ -221,6 +221,18 @@ class SettingsViewModelTest {
advanceUntilIdle()
assertThat(alarmScheduler.autoSilence?.fireAt).isEqualTo(wallClock.instant + 25.minutes)
}
@Test
fun `the world clock's seconds switch writes through`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onWorldClockSecondsChanged(true)
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).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,18 +242,26 @@ class WorldClockRowsTest {
/** §5.7 #16 */
@Test
fun `the hero face is home, to the second`() {
val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap())
fun `the hero face is home, to the minute`() {
val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false)
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))
}
/** §5.7 #17 */
@Test
fun `a home zone with no entry falls back to its last segment`() {
val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap())
val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap(), showSeconds = false)
assertThat(home.city).isEqualTo("Ho Chi Minh")
assertThat(home.zoneName).isNull()
@@ -237,8 +270,8 @@ class WorldClockRowsTest {
/** §5.7 #18 */
@Test
fun `the dial's morph carries whether it is the middle of the night there`() {
val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap())
val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap())
val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false)
val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap(), showSeconds = false)
assertThat(night.dayFraction).isWithin(0.001f).of(0f)
assertThat(day.dayFraction).isWithin(0.001f).of(1f)
@@ -20,6 +20,7 @@ import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.time.ZoneId
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
@@ -355,12 +356,36 @@ class WorldClockViewModelTest {
advanceUntilIdle()
assertThat(harness.ticker.requestedPeriods).contains(WorldClockDefaults.Tick)
assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.tickFor(showSeconds = false))
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.tickFor(showSeconds = false),
WorldClockDefaults.tickFor(showSeconds = true),
WorldClockDefaults.tickFor(showSeconds = false),
).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 +397,61 @@ class WorldClockViewModelTest {
harness.ticker.tick()
advanceUntilIdle()
assertThat(harness.loaded()).isEqualTo(before)
}
/** #23: a minute-long cadence must not make a zone change wait a minute. */
@Test
fun `a zone change shows at once even between minute ticks`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = collecting(worldClockHarness(tempDir))
advanceUntilIdle()
assertThat(harness.loaded().home.zoneId).isEqualTo("Europe/Berlin")
harness.zones.zone = ZoneId.of("Asia/Tokyo")
harness.clockChanges.fire()
advanceUntilIdle()
assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo")
}
/** #23: a set clock shows at once, and the tick restarts against the new time. */
@Test
fun `a clock set shows at once and restarts the minute tick`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = collecting(worldClockHarness(tempDir))
advanceUntilIdle()
val before = harness.loaded().home.time
harness.wallClock.advance(5.minutes)
harness.clockChanges.fire()
advanceUntilIdle()
assertThat(harness.loaded().home.time).isEqualTo(before.plusMinutes(5))
assertThat(harness.ticker.requestedUnits).containsExactly(
WorldClockDefaults.tickFor(showSeconds = false),
WorldClockDefaults.tickFor(showSeconds = false),
)
}
/** §5.9 #20, with #23's seconds on. */
@Test
fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) =
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 */
@@ -413,6 +490,25 @@ class WorldClockViewModelTest {
harness.ticker.tick()
advanceUntilIdle()
assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін")
}
/** #23: with the tick a minute long, a resume is what brings a new language in. */
@Test
fun `a resume renames the rows without waiting for a tick`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val dao = FakeWorldClockDao(
listOf(WorldClockEntity(id = 1L, zoneId = "Europe/Berlin", label = null, sortOrder = 0)),
)
val harness = collecting(worldClockHarness(tempDir, dao = dao))
harness.names.cities = mapOf("Europe/Berlin" to "Berlin")
advanceUntilIdle()
harness.names.localeTag = "uk"
harness.names.cities = mapOf("Europe/Berlin" to "Берлін")
harness.viewModel.onResumed()
advanceUntilIdle()
assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін")
}
}
+63 -15
View File
@@ -594,9 +594,11 @@ Lap is refused rather than recorded below the last one.
**M8 adds a wall-clock surface.** The world clock is a calendar fact about
somewhere else, so it reads `WallClock.now()` and never `ElapsedRealtimeClock`,
the same column of the table above that alarms sit in. **Both the instant and the
device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. That is what
makes `TIME_SET` and `TIMEZONE_CHANGED` free for this tab: it self-heals
within one tick, and `SystemEventReceiver` needs no new branch. The zone never
device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. Since #23 the
tick is a minute long unless seconds are on, so `TIME_SET` and
`TIMEZONE_CHANGED` also restart it through `ClockChanges`, a receiver the tab
registers while it collects (§16, "The cadence"); `SystemEventReceiver` still
needs no new branch. The zone never
comes from `ZoneId.systemDefault()` at a call site; a build rule (§8) fails on
one anywhere under `domain/` or `ui/`.
@@ -605,12 +607,13 @@ one anywhere under `domain/` or `ui/`.
## 6. Preferences
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
Four slices:
Six slices:
| Slice | Keys | Owner |
|---|---|---|
| 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_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -1619,6 +1622,16 @@ 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 `delay` that ran long does not accumulate. A
clock set mid-wait is only caught up once that wait ends, so a caller on a
long cadence restarts the flow on `ClockChanges`, the system's own word that
the clock or zone moved. The world clock is its one caller (§16, "The
cadence"), and does exactly that, so a long
cadence never means a long wait for those.
### The dismiss challenge, and why it cannot strand anyone
`ChallengeGate` is a pure state machine whose single boolean `open` means
@@ -2646,8 +2659,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
@@ -2663,15 +2676,50 @@ 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), at
`WorldClockDefaults.tickFor(showSeconds)`, which is derived from the same
`unitFor` the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
up to a period late; a free-running one-second `delay` could leave the second
hand a full second behind the device clock. This replaces M8's 200 ms
free-running tick, which got within a fifth of a second only by waking five
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`.
A minute is too long to wait for `TIME_SET` or `TIMEZONE_CHANGED`, which can
arrive with the tab on screen (a network zone change on landing, split
screen). So `ClockChanges` (`AndroidClockChanges`, a receiver registered only
while the tab collects) restarts the tick: those two make the state re-read
the clock and the zone at once, and the restarted ticker measures its next
wait against the clock as it now reads. The signal is combined
with the stored list, the home zone and the setting *outside* the
`flatMapLatest`, so the receiver is registered once per subscription, not
once per list edit. Each broadcast costs one extra `build`, which
`distinctUntilChanged` drops when nothing moved. The screen also sends a
refresh on every resume, merged into the same signal. A per-app language
change recreates the activity but not the ViewModel and is announced by no
broadcast, so the city names would otherwise wait for the next minute to
change language. And an aligned `delay` has one gap of its own, a deep sleep
inside the five-second `WhileSubscribed` grace, which stretches the wait
because the main looper's clock stops while the device sleeps; the tab
cannot be seen again without a resume, which re-reads the clock.
TalkBack is given the time to the minute even with seconds on: the row's
description and the home readout's semantics use the minute formatter, and
the ticking text is kept out of the tree, because a focused node whose text
changed every second would be re-announced every second.
### Twenty-four cities, and a reorder a screen reader can reach
+5
View File
@@ -159,6 +159,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_alarm_silence_after_minutes": 10,
"default_timer_silence_after_minutes": 10,
"volume_buttons_while_ringing": "CONTROL_VOLUME",
"world_clock_show_seconds": false,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
"bedtime": {
"enabled": false,
@@ -199,6 +200,10 @@ hour). A file without them, from before the setting existed, imports as `10`,
the value it was made with. A value of 60 or more reads as 0, "Never" (which
rings for that long anyway); a negative one reads as the default, 10.
Files from before `world_clock_show_seconds` existed import with it off, its
default. It travels with the settings but is not a clock default: nothing an
alarm or timer inherits depends on it.
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.
+3 -1
View File
@@ -453,7 +453,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,