diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c563a..64ee5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 3cdc85a..9cb6281 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -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(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index f9a9628..789c426 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 6b2cede..20d3b12 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index afd6f72..b7e5a05 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -5,11 +5,13 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider +import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges import de.jeanlucmakiola.clockula.data.time.RealTicker import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock @@ -39,4 +41,7 @@ abstract class TimeModule { @Binds abstract fun bindTicker(impl: RealTicker): Ticker + + @Binds + abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 04d5ce0..7c31485 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -66,6 +66,14 @@ object ClockPrefs { val homeZoneId: Pref = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) + /** + * The world clock tab's readouts to the second, with a second hand on the + * face (#23). Off by default: it is what makes the tab wake every second + * rather than every minute. Not part of `ClockDefaults`, which alarms and + * timers inherit from, so flipping it re-emits nothing they watch. + */ + val worldClockSeconds: Pref = booleanPref("world_clock_show_seconds", false) + private fun Pref.clampedTo(range: IntRange): Pref = map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) }) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index 1d949d8..3cfe447 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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 = store.flow(ClockPrefs.worldClockSeconds) + + suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled) + // --- timer presets (M6) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt new file mode 100644 index 0000000..2397192 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -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 = 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) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt index 4db5bdd..4fee0e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt @@ -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 = flow { + while (true) { + emit(Unit) + delay(WallBoundaries.untilNext(wallClock.now(), unit)) + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt index 2bd5f17..ea784bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/zones/ZoneDirectory.kt @@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor( /** * One display name per id in [zoneIds], on the same memo. Batched for the - * same reason [entriesFor] is: the tab asks five times a second, and a - * dispatch and a lock per row would be a hundred and twenty-five of each. + * same reason [entriesFor] is: the tab asks on every tick (once a second with + * seconds on), and a dispatch and a lock per row would be twenty-five of each. */ suspend fun displayNamesOf(zoneIds: List, at: Instant): Map = withContext(io) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt index 557791c..f264fbf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt @@ -17,8 +17,8 @@ object Zones { /** * Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry - * `HashSet` on every call, and the world clock asks per row per 200 ms tick - * — twenty-four cities would be a hundred and twenty-five throwaway sets a + * `HashSet` on every call, and the world clock asks per row per tick — + * with seconds on, twenty-four cities would be twenty-five throwaway sets a * second. The device's tzdata arrives as an APEX module that takes effect * at reboot, so the answer cannot change under a running process. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index 00a4000..55bb73d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -61,6 +61,8 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, + /** 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index e943df4..4bb48b0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -40,4 +40,22 @@ interface ZoneProvider { */ interface Ticker { fun ticks(period: Duration): Flow + + /** + * Emits once immediately, then just after every wall-clock instant that is + * a whole multiple of [unit] ([WallBoundaries.untilNext]). For a readout of + * whole seconds or minutes: it wakes exactly when the readout changes and + * not otherwise, so it is both on time and as rare as it can be. + */ + fun alignedTicks(unit: Duration): Flow +} + +/** + * 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt new file mode 100644 index 0000000..951438c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/WallBoundaries.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.clockula.domain.time + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * Where the next whole second (or minute) of the wall clock falls. A readout + * that shows whole units changes exactly at these instants, so waking there + * rather than on a free-running cadence is what keeps it on time without + * waking more often than it changes. + */ +object WallBoundaries { + + /** + * How long from [now] until the next instant that is a whole multiple of + * [unit] since the epoch. Always in `(0, unit]`: standing exactly on a + * boundary waits for the *next* one, so a loop over this never spins. + * + * Every zone in use today is offset from UTC by whole minutes, so a + * boundary of the epoch is a boundary of every zone's local second and + * minute alike. + */ + fun untilNext(now: Instant, unit: Duration): Duration { + val unitMillis = unit.inWholeMilliseconds + require(unitMillis > 0) { "unit must be at least a millisecond: $unit" } + val into = Math.floorMod(now.toEpochMilliseconds(), unitMillis) + return (unitMillis - into).milliseconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index bf77123..3abe881 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -16,20 +16,24 @@ import java.time.format.FormatStyle * alarm list, the editor and the world clock all read a time through it, so a * second `DateFormat.is24HourFormat` call site would be the start of two * answers (M5, M8 D17). + * + * [withSeconds] is the world clock's "show seconds" (#23): the same two-step, + * one unit finer — `HH:mm:ss`, or the locale's medium form, which is its short + * form with the seconds in the place that locale puts them. */ @Composable -internal fun rememberLocalTimeFormatter(): (LocalTime) -> String { +internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTime) -> String { val context = LocalContext.current // The locale can change under a running activity, and the 12/24-hour // choice rides the same configuration, so both are re-read with it. val configuration = LocalConfiguration.current - val formatter = remember(configuration) { + val formatter = remember(configuration, withSeconds) { // The device's 12/24-hour choice, then the locale's own short form — // the same two-step the ring notification already formats with. if (DateFormat.is24HourFormat(context)) { - DateTimeFormatter.ofPattern("HH:mm") + DateTimeFormatter.ofPattern(if (withSeconds) "HH:mm:ss" else "HH:mm") } else { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT) } } return remember(formatter) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt index d669685..6949415 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt @@ -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) }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 2b59f87..80a6372 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index e6f123e..5f6faeb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt index cb23809..7cbd918 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockDefaults.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt index 2c9ea05..204f998 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt @@ -29,7 +29,9 @@ import java.time.LocalTime /** * The app's one deliberate showpiece: a `MaterialShapes.Circle`↔`Sunny` dial - * whose morph progress is [dayFraction], twelve hour marks and three hands. + * whose morph progress is [dayFraction], twelve hour marks and two hands, or + * three with [showSecondHand] — the world clock's "show seconds" (#23). Without + * it the tab ticks once a minute, and a second hand there would stand still. * * The morph carries **information**, which is the whole reason it is here * (`docs/PLAN.md` §8, D5): a plain circle in the middle of the night there, a @@ -48,6 +50,7 @@ import java.time.LocalTime internal fun WorldClockFace( time: LocalTime, dayFraction: Float, + showSecondHand: Boolean, modifier: Modifier = Modifier, ) { val dialColor = MaterialTheme.colorScheme.secondaryContainer @@ -62,9 +65,9 @@ internal fun WorldClockFace( val dialPath = remember { Path() } val dialMatrix = remember { Matrix() } - // Reduce motion touches the morph, not the hands: the second hand is a - // discrete position change carrying information, and a clock whose second - // hand stops is a broken clock (D19). + // Reduce motion touches the morph, not the hands: the second hand, when + // shown, is a discrete position change carrying information, and a clock + // whose second hand stops is a broken clock (D19). val progress by animateFloatAsState( targetValue = dayFraction, animationSpec = if (reduceMotion) { @@ -82,7 +85,7 @@ internal fun WorldClockFace( ) { drawDial(morph, progress, dialPath, dialMatrix, dialColor) drawTicks(markColor) - drawHands(time, markColor, accentColor) + drawHands(time, showSecondHand, markColor, accentColor) } } @@ -127,11 +130,13 @@ private fun DrawScope.drawTicks(color: Color) { } } -private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) { +private fun DrawScope.drawHands(time: LocalTime, showSecondHand: Boolean, handColor: Color, accentColor: Color) { val radius = size.minDimension / 2f hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor) hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor) - hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + if (showSecondHand) { + hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + } drawCircle(accentColor, radius = 4.dp.toPx(), center = center) drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx())) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt index 095d3fa..b6c6219 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt @@ -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 = 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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt index fce2531..e516355 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt @@ -11,7 +11,6 @@ import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat import java.time.LocalTime import java.time.ZoneId -import java.time.temporal.ChronoUnit import kotlin.time.Instant import kotlin.time.toJavaInstant @@ -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, 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, @@ -71,6 +73,7 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, + showSeconds: Boolean, ): List = 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, zoneNames: Map, + 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), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index abac9b9..791959c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt index f77254a..d38424b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt @@ -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, 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 = - combine(worldClocks.worldClocks(), storedHomeZoneId, ::Pair) - .flatMapLatest { (clocks, storedHome) -> - ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome) } + fun state(refreshes: Flow): Flow = + 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, 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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt index 3a6f516..6f14b9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.domain.Zones import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -36,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(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ - internal val state: StateFlow = source.state() - // `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and + internal val state: StateFlow = source.state(refreshes) + // `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and // eager sharing would keep it running with nobody looking. The timeout // is what gives rotation its resilience, as on every other tab. .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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22a826e..6f856e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,9 +350,11 @@ How timers ring. A timer can still pick its own sound in its editor. Off. A timer set to Silent still vibrates Clock - Home time zone + Home time zone and seconds World clocks show how far each city is from your home time zone. Home time zone + Display time with seconds + The world clock shows seconds and a second hand. Uses a little more battery. Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index 5408624..8a88a8f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -43,6 +43,7 @@ class BackupEngineTest { source.settings.setThemeMode(ThemeMode.DARK) source.settings.setSnoozeMinutes(7) source.settings.setHomeZoneId("America/New_York") + source.settings.setWorldClockSeconds(true) val exported = source.backupEngine.export() @@ -68,6 +69,7 @@ class BackupEngineTest { assertThat(target.settings.themeMode.first()).isEqualTo(ThemeMode.DARK) assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7) assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York") + assertThat(target.settings.worldClockSeconds.first()).isTrue() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index 6e74a0b..6e805d3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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\": []}") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt index afce3f7..d6a8eba 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -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))) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 315a5d7..df0dfb7 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -93,6 +93,7 @@ class SettingsPrefsTest { timerVolumeRampSeconds = 99, ), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), + worldClockSeconds = false, ), ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt index 1afa53e..34526e4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt @@ -1,15 +1,19 @@ package de.jeanlucmakiola.clockula.data.time import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.testing.FakeWallClock import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant /** * The ticker is nothing but `delay`, which is why it is injected: under @@ -18,7 +22,19 @@ import kotlin.time.Duration.Companion.seconds */ class RealTickerTest { - private val ticker = RealTicker() + private val wallClock = FakeWallClock() + + private val ticker = RealTicker(wallClock) + + /** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */ + private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L) + + /** Lets virtual time pass with the wall clock moving in step, as a real one would. */ + private fun TestScope.advanceBoth(by: kotlin.time.Duration) { + advanceTimeBy(by) + wallClock.advance(by) + runCurrent() + } @Test fun `the first tick arrives immediately`() = runTest { @@ -74,4 +90,58 @@ class RealTickerTest { assertThat(ticks).hasSize(3) } + + // --- aligned --- + + @Test + fun `an aligned tick arrives immediately, then on the next whole second`() = runTest { + wallClock.instant = onASecond + 300.milliseconds + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } } + runCurrent() + assertThat(ticks).isEqualTo(1) + + advanceBoth(699.milliseconds) + assertThat(ticks).isEqualTo(1) + + advanceBoth(1.milliseconds) + assertThat(ticks).isEqualTo(2) + + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(3) + } + + @Test + fun `a minute cadence wakes on the minute and not in between`() = runTest { + wallClock.instant = onASecond + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.minutes).collect { ticks++ } } + runCurrent() + + advanceBoth(39.seconds) + assertThat(ticks).isEqualTo(1) + + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(2) + + advanceBoth(59.seconds) + assertThat(ticks).isEqualTo(2) + } + + @Test + fun `a wall clock set mid-wait is caught up on the next boundary`() = runTest { + wallClock.instant = onASecond + var ticks = 0 + backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } } + runCurrent() + + // The user sets the clock a quarter-second on while the ticker waits. + wallClock.advance(250.milliseconds) + advanceBoth(1.seconds) + assertThat(ticks).isEqualTo(2) + + // Re-measured from the new wall clock: three quarters to the boundary. + advanceBoth(750.milliseconds) + assertThat(ticks).isEqualTo(3) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt new file mode 100644 index 0000000..09b3ed8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/time/WallBoundariesTest.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.clockula.domain.time + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** The aligned cadence's arithmetic: always in `(0, unit]`, so never a spin. */ +class WallBoundariesTest { + + /** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */ + private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L) + + @Test + fun `mid-second waits for the rest of that second`() { + val now = onASecond + 250.milliseconds + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(750.milliseconds) + } + + @Test + fun `exactly on a boundary waits a whole unit rather than none`() { + assertThat(WallBoundaries.untilNext(onASecond, 1.seconds)).isEqualTo(1.seconds) + } + + @Test + fun `a minute boundary is the next whole minute, not sixty seconds on`() { + assertThat(WallBoundaries.untilNext(onASecond, 1.minutes)).isEqualTo(40.seconds) + } + + @Test + fun `one millisecond short of a boundary waits that millisecond`() { + val now = onASecond - 1.milliseconds + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(1.milliseconds) + } + + @Test + fun `before the epoch still lands on a boundary`() { + val now = Instant.fromEpochMilliseconds(-250L) + + assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(250.milliseconds) + } + + @Test + fun `waking where it says lands on a boundary`() { + val now = onASecond + 12_345.milliseconds + val woken = now + WallBoundaries.untilNext(now, 1.minutes) + + assertThat(woken.toEpochMilliseconds() % 60_000L).isEqualTo(0L) + assertThat(woken > now).isTrue() + } + + @Test + fun `a unit under a millisecond is refused`() { + assertThrows { WallBoundaries.untilNext(onASecond, Duration.ZERO) } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt index cbc475b..bae45e4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.testing +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.Ticker import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -13,12 +14,26 @@ class FakeTicker : Ticker { private val streams = mutableListOf>() private val periods = mutableListOf() + private val units = mutableListOf() /** Every period asked for, in order, so a test can pin the cadence. */ val requestedPeriods: List get() = periods.toList() + /** Every aligned unit asked for, in order, so a test can pin that cadence too. */ + val requestedUnits: List get() = units.toList() + override fun ticks(period: Duration): Flow { periods += period + return stream() + } + + /** Driven by [tick] like [ticks]: the alignment is `RealTicker`'s, tested there. */ + override fun alignedTicks(unit: Duration): Flow { + units += unit + return stream() + } + + private fun stream(): Flow { val stream = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) check(stream.tryEmit(Unit)) streams += stream @@ -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(extraBufferCapacity = 64) + + override fun changes(): Flow = stream + + /** One `TIME_SET`/`TIMEZONE_CHANGED` to every collector. */ + suspend fun fire() { + stream.emit(Unit) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt index e823c70..a49f607 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/WorldClockHarness.kt @@ -36,6 +36,7 @@ class WorldClockHarness( val wallClock: FakeWallClock = FakeWallClock(now) val zones: FakeZoneProvider = FakeZoneProvider() val ticker: FakeTicker = FakeTicker() + val clockChanges: FakeClockChanges = FakeClockChanges() val names: FakeZoneNames = FakeZoneNames() val store: PrefStore = PrefStore( @@ -58,6 +59,7 @@ class WorldClockHarness( zones = zones, wallClock = wallClock, ticker = ticker, + clockChanges = clockChanges, ) val viewModel: WorldClockViewModel = WorldClockViewModel( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt index 5945ea9..8e951ce 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt index 892ae24..3ee6ac9 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowsTest.kt @@ -11,14 +11,15 @@ import de.jeanlucmakiola.clockula.testing.UTC_ZONE import org.junit.jupiter.api.Test import java.time.LocalTime import java.time.ZoneId +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** - * §5.7 — 18 cases. The tab as pure data: the row's name, its offset and day - * relative to **home**, the truncation that makes the 200 ms tick cheap (D18), - * and the row whose zone the device no longer knows (D14). + * §5.7 — 18 cases, and #23's seconds. The tab as pure data: the row's name, + * its offset and day relative to **home**, the truncation to the unit the tab + * ticks in (D18), and the row whose zone the device no longer knows (D14). */ class WorldClockRowsTest { @@ -59,7 +60,8 @@ class WorldClockRowsTest { homeZone: ZoneId = BERLIN, now: Instant = T0, zoneNames: Map = emptyMap(), - ) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames) + showSeconds: Boolean = false, + ) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds) // --- from --- @@ -149,6 +151,29 @@ class WorldClockRowsTest { assertThat(after).isEqualTo(before) } + /** #23 */ + @Test + fun `with seconds on a row's time carries the second`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0, showSeconds = true).single() + val after = rows(clocks, now = T0 + 1.seconds, showSeconds = true).single() + + assertThat(before.time).isEqualTo(LocalTime.of(17, 13, 20)) + assertThat(after.time).isEqualTo(LocalTime.of(17, 13, 21)) + } + + /** #23 */ + @Test + fun `with seconds on the millisecond is still dropped`() { + val clocks = listOf(clock(zoneId = newYork)) + + val before = rows(clocks, now = T0, showSeconds = true) + val after = rows(clocks, now = T0 + 999.milliseconds, showSeconds = true) + + assertThat(after).isEqualTo(before) + } + /** §5.7 #10 */ @Test fun `the truncation does not freeze the row`() { @@ -217,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) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt index f80f4e6..236858e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt @@ -20,6 +20,7 @@ import org.junit.jupiter.api.io.TempDir import java.nio.file.Files import java.nio.file.Path import java.time.ZoneId +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** @@ -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("Берлін") } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7db3c2d..2493144 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | +| 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 diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 5d4b94c..4b23403 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e6ccda9..87b9df6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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,