From f5388042c6b0a6b16b18184f0ba7ada54d5a73b3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:06:13 +0200 Subject: [PATCH] feat(worldclock): show local time automatically while travelling Issue #24 asks for Google Clock's "automatic home clock": a temporary Home row while the device is somewhere the time differs from home. In Google Clock that works because the hero clock is local time. Clockula is the other way round: the hero face is home (the stored home zone, or the device's zone when none is stored), every row is read against home, and with a stored home the face already shows home time on a trip. A Home row would only repeat the face; what goes missing while travelling is the local time. So the feature is turned round. With a home zone stored and the device's zone on a different UTC offset right now, the tab shows the device's zone as a temporary "Local time" row between the face and the saved cities. It is never stored, sits outside the reorderable column, has no move or remove actions and does not count towards the 24-city cap. The comparison is the current UTC offset, as Google's is, so Paris with home in Berlin shows nothing. The row is hidden when a saved city is already on the device's offset. It is rebuilt on every tick from the same device-zone read the face uses, so it appears and disappears within a tick of a time-zone change while the tab is open. Home that follows the device can never differ from it. Settings > Clock gets an "Automatic local clock" switch (on by default) under the home zone row; while home follows the device the row is dimmed and its hint says it needs a home time zone. The switch is stored as automatic_local_clock and included in backups. Files written before this change restore it as on. Fixes #24 --- CHANGELOG.md | 7 ++ .../clockula/data/backup/BackupCodec.kt | 2 + .../clockula/data/backup/BackupDto.kt | 2 + .../clockula/data/prefs/ClockPrefs.kt | 7 ++ .../clockula/data/prefs/SettingsPrefs.kt | 8 +- .../clockula/domain/ClockDefaults.kt | 2 + .../clockula/domain/worldclock/WorldClocks.kt | 45 +++++++++ .../ui/settings/ClockSettingsScreen.kt | 27 ++++- .../clockula/ui/settings/SettingsViewModel.kt | 3 + .../clockula/ui/worldclock/WorldClockRow.kt | 67 +++++++++++++ .../ui/worldclock/WorldClockRowState.kt | 29 ++++++ .../ui/worldclock/WorldClockScreen.kt | 7 ++ .../ui/worldclock/WorldClockSource.kt | 27 +++-- app/src/main/res/values/strings.xml | 5 + .../clockula/data/backup/BackupCodecTest.kt | 12 +++ .../domain/worldclock/LocalClockTest.kt | 99 +++++++++++++++++++ .../ui/worldclock/WorldClockRowsTest.kt | 13 +++ .../ui/worldclock/WorldClockViewModelTest.kt | 60 +++++++++++ docs/ARCHITECTURE.md | 31 +++++- docs/BACKUP.md | 4 +- 20 files changed, 445 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..430e7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,13 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. 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. +- An automatic local clock while travelling. When you've set a home time zone + and your device is somewhere the time differs, the World clock tab adds a + "Local time" row under the home face. It goes away again once you're back + on home time, and stays hidden if one of your cities already shows that + time. It's Google Clock's "automatic home clock" turned round: Clockula's + face already shows home, so the clock that goes missing on a trip is the + local one. Turn it off in Settings › Clock. - A live pill that shows whatever is counting from every tab. - Swipe between tabs on phones. Tablets and foldables get a side rail. - Other apps can set, show, snooze and dismiss alarms and timers through diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..efe5bff 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -127,6 +127,7 @@ object BackupCodec { timerRingtoneUri = defaults.timerRingtoneUri, dismissChallenge = defaults.dismissChallenge.name, homeZoneId = defaults.homeZoneId, + automaticLocalClock = defaults.automaticLocalClock, timerPresets = timerPresets.map { it.inWholeMilliseconds }, ) @@ -233,6 +234,7 @@ object BackupCodec { timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), + automaticLocalClock = settingsDto.automaticLocalClock, ), timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..a00fa50 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -71,6 +71,8 @@ data class SettingsDto( @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", @SerialName("home_zone_id") val homeZoneId: String? = null, + /** Absent in files from before #24, which keeps the default: on. */ + @SerialName("automatic_local_clock") val automaticLocalClock: Boolean = true, /** * Absent is not empty: an empty list is a user who removed every preset, * while a file without the field (an older or hand-written one) keeps the diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 0cb43c1..38d0b25 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -35,6 +35,13 @@ object ClockPrefs { val homeZoneId: Pref = nullableStringPref("home_zone_id") .map(decode = Zones::normalise, encode = Zones::normalise) + /** + * The World clock tab's temporary local-time row while the device is in a + * zone whose time differs from home (#24). On by default, as Google Clock's + * automatic home clock is. + */ + val automaticLocalClock: Pref = booleanPref("automatic_local_clock", true) + private fun Pref.clampedTo(range: IntRange): Pref = map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) }) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..082cd93 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -65,11 +65,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.flow(ClockPrefs.alarmRingtoneUri), store.flow(ClockPrefs.timerRingtoneUri), store.flow(ClockPrefs.homeZoneId), - ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> + store.flow(ClockPrefs.automaticLocalClock), + ) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId, automaticLocalClock -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, homeZoneId = homeZoneId, + automaticLocalClock = automaticLocalClock, ) }.distinctUntilChanged() @@ -92,6 +94,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId + prefs[ClockPrefs.automaticLocalClock] = backup.defaults.automaticLocalClock prefs[TimerPrefs.presets] = backup.timerPresets } } @@ -113,6 +116,9 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) + suspend fun setAutomaticLocalClock(enabled: Boolean) = + store.set(ClockPrefs.automaticLocalClock, enabled) + // --- timer presets (M6) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 73499b3..16aeace 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -13,6 +13,8 @@ data class ClockDefaults( val timerRingtoneUri: String? = null, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, + /** The World clock tab's temporary local-time row while travelling (#24). */ + val automaticLocalClock: Boolean = true, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt index fc38f28..fb23501 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/WorldClocks.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.domain.worldclock import de.jeanlucmakiola.clockula.domain.Zones import java.time.ZoneId +import kotlin.time.Instant object WorldClocks { /** @@ -39,3 +40,47 @@ object HomeZone { ?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone } + +/** + * The automatic local clock (#24): the device's own zone as a temporary row + * while it is somewhere the time differs from home. + * + * It is Google Clock's "automatic home clock" turned round. Google's hero is + * local time and adds home as a row; Clockula's hero face *is* home, so home is + * already on screen while travelling and the clock that goes missing is the + * local one. + */ +object LocalClock { + + /** + * [deviceZone] when the tab should show it as a temporary row, else null. + * + * "Different" means a different UTC offset **at [at]**, as Google decides + * it: Paris while home is Berlin shows no row, and neither does London in + * winter while home is Lisbon. It is asked again on every tick, so a DST + * change on either side moves it with the clock. + * + * Null when [enabled] is off, when [deviceZone] is not a listed zone id, when no home zone is stored (home then *is* + * the device's zone, so it can never differ), and when [savedZoneIds] + * already holds a known zone at the device's offset: that row already + * shows the local time, and a second one would be a duplicate. + */ + fun zoneToShow( + enabled: Boolean, + storedHomeZoneId: String?, + deviceZone: ZoneId, + savedZoneIds: List, + at: Instant, + ): ZoneId? { + // A device zone that is not an id the tzdata lists (a bare offset, say) + // has no city to name the row after. + if (!enabled || !Zones.isValid(deviceZone.id)) return null + val home = HomeZone.resolve(storedHomeZoneId, deviceZone) + val deviceOffset = ZoneComparisons.utcOffsetMinutes(deviceZone, at) + if (ZoneComparisons.utcOffsetMinutes(home, at) == deviceOffset) return null + val alreadyShown = savedZoneIds.any { id -> + Zones.isValid(id) && ZoneComparisons.utcOffsetMinutes(ZoneId.of(id), at) == deviceOffset + } + return deviceZone.takeUnless { alreadyShown } + } +} 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..d616ebc 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 @@ -25,8 +26,32 @@ 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, ) + // Home that follows the device is always local time, so the row this + // switches can never appear. The switch stays live (the choice is + // remembered for when a home is set) and the hint says why it is idle. + val followsDevice = state.defaults.homeZoneId == null + val automaticLocalClock = state.defaults.automaticLocalClock + GroupedRow( + title = stringResource(R.string.settings_automatic_local_clock), + summary = stringResource( + if (followsDevice) { + R.string.settings_automatic_local_clock_needs_home + } else { + R.string.settings_automatic_local_clock_hint + }, + ), + position = Position.Bottom, + dimmed = followsDevice, + trailing = { + Switch( + checked = automaticLocalClock, + onCheckedChange = viewModel::onAutomaticLocalClockChanged, + ) + }, + onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) }, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 7d90968..7cbad35 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 @@ -258,6 +258,9 @@ class SettingsViewModel @Inject internal constructor( viewModelScope.launch { settings.setHomeZoneId(null) } } + fun onAutomaticLocalClockChanged(value: Boolean) = + viewModelScope.launch { settings.setAutomaticLocalClock(value) } + private companion object { const val SUBSCRIPTION_GRACE_MS = 5_000L } 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..22efd09 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 @@ -5,10 +5,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.NearMe import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -147,6 +149,71 @@ internal fun WorldClockRow( } } +/** + * The automatic local clock (#24): the device's own zone while it is somewhere + * the time differs from home. Drawn like a city row but outside the reorderable + * column, with a location glyph where the drag handle would be and no actions + * at all — it is not stored, so there is nothing to move or remove, and it goes + * away by itself once the device is back on home time. + */ +@Composable +internal fun LocalClockRow( + row: WorldClockRowState, + formatTime: (LocalTime) -> String, + modifier: Modifier = Modifier, +) { + // "Local time · 7 hours ahead": the label is what tells it apart from a + // saved city, so it leads the summary rather than hiding in a glyph. + val summary = stringResource(R.string.world_clock_summary, stringResource(R.string.world_clock_local), summaryFor(row)) + // Only built for a zone the device lists, so the time is always there. + val time = row.time?.let(formatTime).orEmpty() + val description = stringResource(R.string.world_clock_row_description, row.name, time, summary) + + GroupedSurface( + position = Position.Alone, + modifier = modifier.padding(horizontal = GroupedListInset), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 80.dp) + .padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp) + .semantics(mergeDescendants = true) { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.NearMe, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = time, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + /** * 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..eca7916 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 @@ -51,6 +51,13 @@ internal sealed interface WorldClockUiState { /** In the repository's order — `sort_order`, then `id`. Never re-sorted here. */ val rows: List, val canAdd: Boolean, + /** + * The device's own zone while it is somewhere the time differs from + * home (#24); null otherwise. Never stored, never in [rows], so it can + * be neither reordered nor removed, and it does not count towards + * `WorldClocks.MAX`. + */ + val local: WorldClockRowState? = null, ) : WorldClockUiState } @@ -90,6 +97,28 @@ internal object WorldClockRows { ) } + /** + * The automatic local clock's row for [deviceZone], read against home like + * any other row. Its [WorldClockRowState.id] is [LOCAL_ROW_ID], which no + * stored row can have, and it never carries a user label. + */ + fun local( + deviceZone: ZoneId, + homeZone: ZoneId, + now: Instant, + entries: Map, + zoneNames: Map, + ): WorldClockRowState = from( + clocks = listOf(WorldClock(id = LOCAL_ROW_ID, zoneId = deviceZone.id, label = null, sortOrder = 0)), + homeZone = homeZone, + now = now, + entries = entries, + zoneNames = zoneNames, + ).single() + + /** Room ids start at 1, so a row with this id is never a stored one. */ + const val LOCAL_ROW_ID: Long = -1L + /** Pure; never throws. */ fun home( homeZone: ZoneId, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index abac9b9..5627550 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 @@ -114,6 +114,13 @@ fun WorldClockScreen( if (loaded != null) { HomeFace(home = loaded.home, formatTime = formatTime) + // Above the saved cities and outside their column: it is not one + // of them, and a drag must not be able to land between the two. + loaded.local?.let { local -> + LocalClockRow(row = local, formatTime = formatTime) + Spacer(Modifier.height(8.dp)) + } + if (loaded.rows.isEmpty()) { EmptyWorldClocks() } else { 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..24767ae 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 @@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.ZoneProvider import de.jeanlucmakiola.clockula.domain.worldclock.HomeZone +import de.jeanlucmakiola.clockula.domain.worldclock.LocalClock import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,11 +38,15 @@ internal class WorldClockSource @Inject constructor( private val storedHomeZoneId: Flow = settings.defaults.map { it.homeZoneId }.distinctUntilChanged() + private val automaticLocalClock: Flow = + settings.defaults.map { it.automaticLocalClock }.distinctUntilChanged() + /** * The tab's state. Both the instant and the device zone are re-read on * **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED` * free: the tab self-heals within one tick and `SystemEventReceiver` needs - * no new branch (D2). + * no new branch (D2). The same read is what brings the automatic local + * clock (#24) in and out as the device's zone changes under an open tab. * * 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 @@ -50,9 +55,9 @@ internal class WorldClockSource @Inject constructor( */ @OptIn(ExperimentalCoroutinesApi::class) fun state(): Flow = - combine(worldClocks.worldClocks(), storedHomeZoneId, ::Pair) - .flatMapLatest { (clocks, storedHome) -> - ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome) } + combine(worldClocks.worldClocks(), storedHomeZoneId, automaticLocalClock, ::Triple) + .flatMapLatest { (clocks, storedHome, localClock) -> + ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome, localClock) } } .distinctUntilChanged() @@ -101,10 +106,19 @@ internal class WorldClockSource @Inject constructor( private suspend fun build( clocks: List, storedHome: String?, + localClock: Boolean, ): WorldClockUiState.Loaded { val now = wallClock.now() - val homeZone = HomeZone.resolve(storedHome, zones.current()) - val zoneIds = (clocks.map { it.zoneId } + homeZone.id).distinct() + val deviceZone = zones.current() + val homeZone = HomeZone.resolve(storedHome, deviceZone) + val localZone = LocalClock.zoneToShow( + enabled = localClock, + storedHomeZoneId = storedHome, + deviceZone = deviceZone, + savedZoneIds = clocks.map { it.zoneId }, + at = now, + ) + val zoneIds = (clocks.map { it.zoneId } + homeZone.id + listOfNotNull(localZone?.id)).distinct() // Both memos live inside the directory, keyed on the locale it reports: // a cache here would survive a per-app language change and keep every // city in the old language while the ICU zone name switched. Both are @@ -115,6 +129,7 @@ internal class WorldClockSource @Inject constructor( home = WorldClockRows.home(homeZone, now, entries, zoneNames), rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames), canAdd = clocks.size < WorldClocks.MAX, + local = localZone?.let { WorldClockRows.local(it, homeZone, now, entries, zoneNames) }, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..b84e2ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,6 +220,8 @@ Add up to %1$d cities Home time zone Home + + Local time Add a city to compare its time with your own. Same time as home %1$s ahead @@ -304,6 +306,9 @@ Home time zone World clocks show how far each city is from your home time zone. Home time zone + Automatic local clock + While you’re somewhere the time differs from home, show the local time under the home clock. + Needs a home time zone. Home follows the device now, so it’s always local time. 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/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..2cb799c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -60,6 +60,7 @@ class BackupCodecTest { timerRingtoneUri = "content://media/2", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "America/New_York", + automaticLocalClock = false, ), timerPresets = listOf(1.minutes, 2.minutes, 10.minutes), ), @@ -263,6 +264,17 @@ class BackupCodecTest { assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN) } + @Test + fun `a file from before the automatic local clock keeps it on`() { + val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings + assertThat(settings.defaults.automaticLocalClock).isTrue() + } + + @Test + fun `the automatic local clock is written under its on-disk key`() { + assertThat(BackupCodec.encode(sampleContent())).contains("\"automatic_local_clock\": false") + } + @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/domain/worldclock/LocalClockTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt new file mode 100644 index 0000000..7c982a5 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt @@ -0,0 +1,99 @@ +package de.jeanlucmakiola.clockula.domain.worldclock + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.instantAt +import org.junit.jupiter.api.Test +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.time.Instant + +/** + * #24 — when the device's own zone is shown as a temporary row. "Different" + * is a different UTC offset at the instant, as Google Clock decides it. + */ +class LocalClockTest { + + private val tokyo: ZoneId = ZoneId.of("Asia/Tokyo") + private val paris: ZoneId = ZoneId.of("Europe/Paris") + private val london: ZoneId = ZoneId.of("Europe/London") + private val july: Instant = instantAt(BERLIN, "2026-07-01T12:00") + + private fun show( + storedHome: String?, + device: ZoneId, + saved: List = emptyList(), + enabled: Boolean = true, + at: Instant = july, + ): ZoneId? = LocalClock.zoneToShow(enabled, storedHome, device, saved, at) + + @Test + fun `a device away from a stored home is shown`() { + assertThat(show(storedHome = "Europe/Berlin", device = tokyo)).isEqualTo(tokyo) + } + + @Test + fun `home that follows the device can never differ from it`() { + assertThat(show(storedHome = null, device = tokyo)).isNull() + } + + @Test + fun `a device at home is not shown`() { + assertThat(show(storedHome = "Europe/Berlin", device = BERLIN)).isNull() + } + + @Test + fun `another zone on home's offset is not a different time`() { + assertThat(show(storedHome = "Europe/Berlin", device = paris)).isNull() + } + + @Test + fun `the offset is the one at the instant, so DST counts`() { + // London is an hour behind Berlin all year, but Lisbon and London agree + // in summer and in winter: the rule is the offset now, not the zone id. + assertThat(show(storedHome = "Europe/Lisbon", device = london)).isNull() + assertThat(show(storedHome = "Europe/Berlin", device = london)).isEqualTo(london) + } + + @Test + fun `a DST change on one side alone can bring it in`() { + // Phoenix keeps MST; Denver is on it only in winter. + val phoenix = ZoneId.of("America/Phoenix") + val january = instantAt(BERLIN, "2026-01-15T12:00") + assertThat(show(storedHome = "America/Denver", device = phoenix, at = january)).isNull() + assertThat(show(storedHome = "America/Denver", device = phoenix, at = july)).isEqualTo(phoenix) + } + + @Test + fun `switched off, it is never shown`() { + assertThat(show(storedHome = "Europe/Berlin", device = tokyo, enabled = false)).isNull() + } + + @Test + fun `a saved city on the device's offset already shows the local time`() { + assertThat(show(storedHome = "Europe/Berlin", device = tokyo, saved = listOf("Asia/Seoul"))).isNull() + assertThat(show(storedHome = "Europe/Berlin", device = tokyo, saved = listOf("Asia/Tokyo"))).isNull() + } + + @Test + fun `a saved city on another offset does not hide it`() { + assertThat(show(storedHome = "Europe/Berlin", device = tokyo, saved = listOf("America/New_York"))) + .isEqualTo(tokyo) + } + + @Test + fun `a saved zone the device no longer knows is skipped, not thrown on`() { + assertThat(show(storedHome = "Europe/Berlin", device = tokyo, saved = listOf("Mars/Olympus"))) + .isEqualTo(tokyo) + } + + @Test + fun `a stored home the device dropped falls back to the device, so nothing differs`() { + assertThat(show(storedHome = "Mars/Olympus", device = tokyo)).isNull() + } + + @Test + fun `a device on a bare offset has no city to show`() { + assertThat(show(storedHome = "Europe/Berlin", device = ZoneOffset.ofHours(9))).isNull() + } +} 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..6ee42b5 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 @@ -243,4 +243,17 @@ class WorldClockRowsTest { assertThat(night.dayFraction).isWithin(0.001f).of(0f) assertThat(day.dayFraction).isWithin(0.001f).of(1f) } + + // --- local (#24) --- + + @Test + fun `the local row is the device's zone, read against home, never stored`() { + val local = WorldClockRows.local(ZoneId.of(newYork), BERLIN, january, entries, emptyMap()) + + assertThat(local.id).isEqualTo(WorldClockRows.LOCAL_ROW_ID) + assertThat(local.name).isEqualTo("New York") + assertThat(local.offset).isEqualTo(OffsetLabel.Behind(6, 0)) + assertThat(local.time).isEqualTo(LocalTime.of(7, 0)) + assertThat(local.known).isTrue() + } } 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..ade6ae9 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 @@ -301,6 +301,66 @@ class WorldClockViewModelTest { assertThat(harness.loaded().home.zoneId).isEqualTo(harness.zones.current().id) } + /** #24 — the device's zone appears as a temporary row while it is not on home time. */ + @Test + fun `travelling away from a stored home shows a local row`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.settings.setHomeZoneId("Europe/Berlin") + advanceUntilIdle() + assertThat(harness.loaded().local).isNull() + + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.ticker.tick() + advanceUntilIdle() + + val local = harness.loaded().local + assertThat(local?.zoneId).isEqualTo("Asia/Tokyo") + assertThat(local?.id).isEqualTo(WorldClockRows.LOCAL_ROW_ID) + assertThat(harness.loaded().home.zoneId).isEqualTo("Europe/Berlin") + assertThat(harness.loaded().rows).isEmpty() + assertThat(harness.stored()).isEmpty() + + // Back home, on the next tick, with nothing to clean up. + harness.zones.zone = ZoneId.of("Europe/Berlin") + harness.ticker.tick() + advanceUntilIdle() + + assertThat(harness.loaded().local).isNull() + } + + /** #24 */ + @Test + fun `the switch turns the local row off`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val harness = collecting(worldClockHarness(tempDir)) + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.settings.setHomeZoneId("Europe/Berlin") + advanceUntilIdle() + assertThat(harness.loaded().local).isNotNull() + + harness.settings.setAutomaticLocalClock(false) + advanceUntilIdle() + + assertThat(harness.loaded().local).isNull() + } + + /** #24 — a saved city on the local offset already shows the local time. */ + @Test + fun `a saved city on the device's offset hides the local row`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Asia/Seoul", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + harness.zones.zone = ZoneId.of("Asia/Tokyo") + harness.settings.setHomeZoneId("Europe/Berlin") + advanceUntilIdle() + + assertThat(harness.loaded().local).isNull() + assertThat(harness.loaded().rows.map { it.zoneId }).containsExactly("Asia/Seoul") + } + /** §5.9 #17 */ @Test fun `the picker's query filters its rows and clearing it restores them`(@TempDir tempDir: Path) = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..7435098 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -91,7 +91,7 @@ kit module: the kit's roadmap keeps schedulers app-local. | `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` | | `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` | | `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker` | -| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) | +| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone` + `LocalClock` (when the device's zone gets a temporary row), `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) | | `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals; the only file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) | | `domain/text/` | `TextFolding` (M9): NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search | | `domain/live/` | `LivePillSelector` and its state: which running thing the live pill is about, as a pure function | @@ -595,7 +595,7 @@ Four slices: | Slice | Keys | Owner | |---|---|---| | Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names | -| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | +| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id`, `automatic_local_clock` (#24) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow` | | Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | @@ -1072,7 +1072,6 @@ M10 adds no ``. | An analog stopwatch face or a `MaterialShapes` showpiece on the Stopwatch tab. `PLAN.md` §8 sanctions the showpiece for the analog world-clock face, the ring screen and timer progress; a stopwatch has no progress to be a fraction of | not planned for v1 | | Surfacing `StopwatchReading.anchorIsStale` in the UI, for the same reason the timers' is not: the repair makes it momentary, and the value displayed is already honest | not planned for v1 | | A rename UI for `world_clocks.label`. The column is honoured on **read** (a row shows the label when it has one), and M10's backup now carries it on export/import, but there is still no way to *write* a label in-app | not planned for v1 | -| An automatic "home" row while travelling. Google Clock's automatic home clock is a separate feature with its own preference and its own edge cases; M8's answer to "where am I" is the hero face, which already follows the device when no home zone is stored | not planned for v1 | | A per-city detail screen, a map, sunrise/sunset times or a day-length bar. `DayNight` is a stated convention for a shape morph, not an ephemeris (§16) | not planned for v1 | | Live times or long zone names in the zone picker. 450 rows of ICU long names is real work for a list the user scrolls past, and a ticking picker is a ticking picker | not planned for v1 | | Reordering world clocks by anything but `sort_order`: no grouping by continent, no sort-by-offset, no pinning. The list is the order the user put it in | not planned for v1 | @@ -2389,6 +2388,32 @@ the user cannot reach is not handling; M10 may surface it a second time. **The home zone may also be added as a row**, which is not prevented: `add` is idempotent on `zone_id`, and that row simply reads "Same time as home". +### The automatic local clock (#24) + +Google Clock's "automatic home clock" adds a home row while you travel, +because its hero is *local* time. Clockula's hero is home, so on a trip home +is already on screen and the clock that goes missing is the local one. The +feature is therefore turned round: while a home zone is **stored** and the +device's zone is on a different UTC offset **at this instant**, the tab shows +the device's zone as a temporary row between the face and the saved cities, +labelled "Local time" and read against home like any other row. + +`LocalClock.zoneToShow` is the whole rule, pure and tested. The comparison is +the offset now, as Google's is, not the zone id: Paris while home is Berlin +shows nothing, and Phoenix while home is Denver shows a row only in summer. +The row is hidden when a saved city is already on the device's offset, since +that city already shows the local time. Home that follows the device can never +differ from it, so with no stored home the rule is always null, and Settings › +Clock says so under the switch rather than hiding it. + +The row is not stored. It is `WorldClockUiState.Loaded.local`, built per tick +by the source from the same `zones.current()` read the face uses, so a +`TIMEZONE_CHANGED` brings it in or takes it out within a tick with no receiver +(D2). It sits outside the `ReorderableColumn`, carries no move or remove +actions, has the reserved id `WorldClockRows.LOCAL_ROW_ID` (`-1`; Room ids +start at 1), and does not count towards `WorldClocks.MAX`. The switch is +`automatic_local_clock`, on by default and carried in the backup's settings. + ### The face, and why its morph is information `PLAN.md` §8 sanctions a `MaterialShapes` morph on the analog face and warns it diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..2db2572 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -136,6 +136,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands "default_timer_ringtone_uri": null, "default_dismiss_challenge": "NONE", "home_zone_id": null, + "automatic_local_clock": true, "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] } ``` @@ -143,7 +144,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older files may also carry `default_timer_duration_millis`, from a default timer length setting that has since been removed; it is ignored on import like any -other unknown field. Every +other unknown field. A file without `automatic_local_clock` (one written +before it existed) restores it as `true`, its default. Every numeric/duration value is clamped on *apply* using the same ranges and encoding as the existing preference setters, never on parse.