From f5388042c6b0a6b16b18184f0ba7ada54d5a73b3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:06:13 +0200 Subject: [PATCH 1/7] 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. From 755150881b8de33a205b27299c52c4cde63b1375 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:11:46 +0200 Subject: [PATCH 2/7] refactor(worldclock): share the row text between saved and local rows The local-time row copied the saved row's name, summary and time layout. Both now draw it through one RowText, so a change to how a row looks lands on both. The Settings hub's Clock entry also names the new switch, so it can be found from the list. --- .../clockula/ui/worldclock/WorldClockRow.kt | 101 +++++++++--------- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 50 insertions(+), 53 deletions(-) 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 22efd09..226b653 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 @@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.ui.worldclock import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.heightIn @@ -17,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -115,36 +117,16 @@ internal fun WorldClockRow( tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = dragHandle.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, - ) - } - if (time != null) { - Text( - 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, - color = if (isDragging) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } + RowText( + name = row.name, + summary = summary, + time = time, + timeColor = if (isDragging) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) } } } @@ -188,32 +170,47 @@ internal fun LocalClockRow( 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, - ) + RowText(name = row.name, summary = summary, time = time) } } } +/** The name over the summary, then the time: what a saved row and the local row share. */ +@Composable +private fun RowScope.RowText( + name: String, + summary: String, + time: String?, + timeColor: Color = MaterialTheme.colorScheme.onSurface, +) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = 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, + ) + } + if (time != null) { + Text( + 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, + color = timeColor, + ) + } +} + /** * 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index b84e2ed..dd6aff3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,7 @@ Sound for new timers New timers ring with this sound. Each timer can still change it in its editor. Clock - Home time zone + Home time zone and local clock World clocks show how far each city is from your home time zone. Home time zone Automatic local clock From 1333001aa9914cbf0b8c24e058da3465320bb584 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:18:10 +0200 Subject: [PATCH 3/7] fix(worldclock): name an offset-set local row by its real offset A device set by UTC offset rather than by city reports an id like Etc/GMT-9, which is UTC+9; the local row took its last segment as the city and read "GMT-9". It is now named by its actual offset. LocalClock takes the already-resolved home zone rather than resolving it a second time, the tab reads both of its settings from one subscription, and the local row falls back to the unknown-zone sentence for TalkBack instead of an empty time. --- .../clockula/domain/worldclock/WorldClocks.kt | 19 ++++++------- .../clockula/domain/worldclock/ZoneCatalog.kt | 7 +++++ .../clockula/ui/worldclock/WorldClockRow.kt | 11 +++++--- .../ui/worldclock/WorldClockRowState.kt | 27 +++++++++++++------ .../ui/worldclock/WorldClockSource.kt | 17 +++++++----- .../domain/worldclock/LocalClockTest.kt | 2 +- .../domain/worldclock/ZoneCatalogTest.kt | 8 ++++++ .../ui/worldclock/WorldClockRowsTest.kt | 9 +++++++ docs/ARCHITECTURE.md | 5 +++- 9 files changed, 76 insertions(+), 29 deletions(-) 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 fb23501..4092074 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 @@ -60,24 +60,25 @@ object LocalClock { * 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. + * [homeZone] is `HomeZone.resolve`'s answer. With no home stored it *is* + * [deviceZone], so a home that follows the device never shows a row. + * + * Null when [enabled] is off, when [deviceZone] is not a zone id the + * tzdata lists (a bare offset, which no row could be read against), 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?, + homeZone: ZoneId, 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 + if (ZoneComparisons.utcOffsetMinutes(homeZone, at) == deviceOffset) return null val alreadyShown = savedZoneIds.any { id -> Zones.isValid(id) && ZoneComparisons.utcOffsetMinutes(ZoneId.of(id), at) == deviceOffset } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt index 5b23db1..a66adac 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -68,6 +68,13 @@ object ZoneCatalog { fun sorted(entries: List): List = entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId })) + /** + * True for an id named after a place (`Asia/Tokyo`) or for [UTC]; false for + * the offset and abbreviation ids tzdata also lists (`Etc/GMT-9`, `CET`), + * whose last segment is no city and, for `Etc/GMT±n`, has the sign reversed. + */ + fun isCityId(zoneId: String): Boolean = zoneId == UTC || isRegionId(zoneId) + private fun isRegionId(zoneId: String): Boolean = zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS } 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 226b653..4282acf 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 @@ -147,9 +147,14 @@ internal fun LocalClockRow( // "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) + val time = row.time?.let(formatTime) + // `LocalClock` only offers a zone the device lists, so the time is there; + // the fallback keeps TalkBack from reading an empty slot if that changes. + val description = if (time != null) { + stringResource(R.string.world_clock_row_description, row.name, time, summary) + } else { + stringResource(R.string.world_clock_row_description_unknown, row.name, summary) + } GroupedSurface( position = Position.Alone, 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 eca7916..eae5540 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 @@ -100,7 +100,11 @@ 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. + * stored row can have. + * + * A device set by UTC offset rather than by city (Android 13+ offers it) + * reports an id like `Etc/GMT-9`, whose last segment is no city and has + * its sign reversed, so such a row is named by its offset ("GMT+9"). */ fun local( deviceZone: ZoneId, @@ -108,13 +112,20 @@ internal object WorldClockRows { 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() + ): WorldClockRowState { + val name = if (ZoneCatalog.isCityId(deviceZone.id)) { + null + } else { + ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(deviceZone, now)) + } + return from( + clocks = listOf(WorldClock(id = LOCAL_ROW_ID, zoneId = deviceZone.id, label = name, 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 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 24767ae..bd54447 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 @@ -38,8 +38,9 @@ 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 two settings the tab reads, from one subscription to the defaults. */ + private val tabSettings: Flow> = + settings.defaults.map { it.homeZoneId to it.automaticLocalClock }.distinctUntilChanged() /** * The tab's state. Both the instant and the device zone are re-read on @@ -55,8 +56,9 @@ internal class WorldClockSource @Inject constructor( */ @OptIn(ExperimentalCoroutinesApi::class) fun state(): Flow = - combine(worldClocks.worldClocks(), storedHomeZoneId, automaticLocalClock, ::Triple) - .flatMapLatest { (clocks, storedHome, localClock) -> + combine(worldClocks.worldClocks(), tabSettings, ::Pair) + .flatMapLatest { (clocks, tab) -> + val (storedHome, localClock) = tab ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome, localClock) } } .distinctUntilChanged() @@ -111,14 +113,15 @@ internal class WorldClockSource @Inject constructor( val now = wallClock.now() val deviceZone = zones.current() val homeZone = HomeZone.resolve(storedHome, deviceZone) + val savedZoneIds = clocks.map { it.zoneId } val localZone = LocalClock.zoneToShow( enabled = localClock, - storedHomeZoneId = storedHome, + homeZone = homeZone, deviceZone = deviceZone, - savedZoneIds = clocks.map { it.zoneId }, + savedZoneIds = savedZoneIds, at = now, ) - val zoneIds = (clocks.map { it.zoneId } + homeZone.id + listOfNotNull(localZone?.id)).distinct() + val zoneIds = (savedZoneIds + 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 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 index 7c982a5..d980afb 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt @@ -25,7 +25,7 @@ class LocalClockTest { saved: List = emptyList(), enabled: Boolean = true, at: Instant = july, - ): ZoneId? = LocalClock.zoneToShow(enabled, storedHome, device, saved, at) + ): ZoneId? = LocalClock.zoneToShow(enabled, HomeZone.resolve(storedHome, device), device, saved, at) @Test fun `a device away from a stored home is shown`() { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt index 4529be9..f18cb5b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt @@ -224,4 +224,12 @@ class ZoneCatalogTest { assertThat(picked).containsExactly("Europe/Kyiv") } + + @Test + fun `a city id is a region id or UTC, never an offset or abbreviation`() { + assertThat(ZoneCatalog.isCityId("Asia/Tokyo")).isTrue() + assertThat(ZoneCatalog.isCityId("UTC")).isTrue() + assertThat(ZoneCatalog.isCityId("Etc/GMT-9")).isFalse() + assertThat(ZoneCatalog.isCityId("CET")).isFalse() + } } 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 6ee42b5..d5af1b3 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 @@ -256,4 +256,13 @@ class WorldClockRowsTest { assertThat(local.time).isEqualTo(LocalTime.of(7, 0)) assertThat(local.known).isTrue() } + + @Test + fun `a device set by UTC offset names the local row by its real offset`() { + // Etc/GMT-9 is UTC+9: the id's sign is reversed, so its last segment is no name. + val local = WorldClockRows.local(ZoneId.of("Etc/GMT-9"), BERLIN, january, entries, emptyMap()) + + assertThat(local.name).isEqualTo("GMT+9") + assertThat(local.offset).isEqualTo(OffsetLabel.Ahead(8, 0)) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7435098..58f296c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2404,7 +2404,10 @@ 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. +Clock says so under the switch rather than hiding it. A device set by UTC +offset rather than by city reports an id like `Etc/GMT-9`, whose last segment +has its sign reversed, so that row is named by its real offset ("GMT+9") +instead (`ZoneCatalog.isCityId`). 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 From f46b7e1e370e50357c6e5025870b3000ea86176a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 20:24:24 +0200 Subject: [PATCH 4/7] fix(worldclock): keep the local row's day label and legacy city names The local row prepends "Local time" to the offset and day, so on a phone a one-line summary cut off the day ("Tomorrow"), the part that matters most on a long trip. It now wraps to two lines. Only Etc/ offsets and bare abbreviations are named by their offset; a legacy place id such as US/Pacific keeps its place name. The picker's home selection now derives from the same settings subscription the tab uses. --- .../clockula/domain/worldclock/ZoneCatalog.kt | 11 +++++++---- .../clockula/ui/worldclock/WorldClockRow.kt | 7 +++++-- .../clockula/ui/worldclock/WorldClockRowState.kt | 2 +- .../clockula/ui/worldclock/WorldClockSource.kt | 8 ++++---- .../clockula/domain/worldclock/LocalClockTest.kt | 5 ++--- .../clockula/domain/worldclock/ZoneCatalogTest.kt | 11 ++++++----- docs/ARCHITECTURE.md | 2 +- 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt index a66adac..b72ad6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -69,11 +69,14 @@ object ZoneCatalog { entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId })) /** - * True for an id named after a place (`Asia/Tokyo`) or for [UTC]; false for - * the offset and abbreviation ids tzdata also lists (`Etc/GMT-9`, `CET`), - * whose last segment is no city and, for `Etc/GMT±n`, has the sign reversed. + * True when [cityFallbackFor] gives a fair name for [zoneId]: a place id, + * current (`Asia/Tokyo`) or legacy (`US/Pacific`), or [UTC]. False for the + * `Etc/` offsets and the bare abbreviations tzdata also lists (`Etc/GMT-9`, + * `CET`), whose last segment is no place and, for `Etc/GMT±n`, has the sign + * reversed. */ - fun isCityId(zoneId: String): Boolean = zoneId == UTC || isRegionId(zoneId) + fun hasPlaceName(zoneId: String): Boolean = + zoneId == UTC || ('/' in zoneId && !zoneId.startsWith("Etc/")) private fun isRegionId(zoneId: String): Boolean = zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS 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 4282acf..31a25ef 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 @@ -175,7 +175,9 @@ internal fun LocalClockRow( tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) - RowText(name = row.name, summary = summary, time = time) + // Two lines, not one: "Local time · " is prepended, and on a phone + // the day ("Tomorrow") is what an ellipsis would otherwise eat. + RowText(name = row.name, summary = summary, time = time, summaryMaxLines = 2) } } } @@ -187,6 +189,7 @@ private fun RowScope.RowText( summary: String, time: String?, timeColor: Color = MaterialTheme.colorScheme.onSurface, + summaryMaxLines: Int = 1, ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -200,7 +203,7 @@ private fun RowScope.RowText( text = summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + maxLines = summaryMaxLines, overflow = TextOverflow.Ellipsis, ) } 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 eae5540..0bded4d 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 @@ -113,7 +113,7 @@ internal object WorldClockRows { entries: Map, zoneNames: Map, ): WorldClockRowState { - val name = if (ZoneCatalog.isCityId(deviceZone.id)) { + val name = if (ZoneCatalog.hasPlaceName(deviceZone.id)) { null } else { ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(deviceZone, now)) 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 bd54447..d7f655f 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 @@ -35,13 +35,13 @@ internal class WorldClockSource @Inject constructor( /** 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) - private val storedHomeZoneId: Flow = - settings.defaults.map { it.homeZoneId }.distinctUntilChanged() - - /** The two settings the tab reads, from one subscription to the defaults. */ + /** The two settings the tab reads: the stored home zone and the local-clock switch. */ private val tabSettings: Flow> = settings.defaults.map { it.homeZoneId to it.automaticLocalClock }.distinctUntilChanged() + private val storedHomeZoneId: Flow = + tabSettings.map { it.first }.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` 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 index d980afb..86ad5e3 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/LocalClockTest.kt @@ -48,9 +48,8 @@ class LocalClockTest { } @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. + fun `the offset decides, not the zone id`() { + // Lisbon and London are different ids on the same offset; Berlin is not. assertThat(show(storedHome = "Europe/Lisbon", device = london)).isNull() assertThat(show(storedHome = "Europe/Berlin", device = london)).isEqualTo(london) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt index f18cb5b..4843e01 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt @@ -226,10 +226,11 @@ class ZoneCatalogTest { } @Test - fun `a city id is a region id or UTC, never an offset or abbreviation`() { - assertThat(ZoneCatalog.isCityId("Asia/Tokyo")).isTrue() - assertThat(ZoneCatalog.isCityId("UTC")).isTrue() - assertThat(ZoneCatalog.isCityId("Etc/GMT-9")).isFalse() - assertThat(ZoneCatalog.isCityId("CET")).isFalse() + fun `a place id or UTC has a place name, an offset or abbreviation does not`() { + assertThat(ZoneCatalog.hasPlaceName("Asia/Tokyo")).isTrue() + assertThat(ZoneCatalog.hasPlaceName("US/Pacific")).isTrue() + assertThat(ZoneCatalog.hasPlaceName("UTC")).isTrue() + assertThat(ZoneCatalog.hasPlaceName("Etc/GMT-9")).isFalse() + assertThat(ZoneCatalog.hasPlaceName("CET")).isFalse() } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58f296c..23fedc9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2407,7 +2407,7 @@ 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. A device set by UTC offset rather than by city reports an id like `Etc/GMT-9`, whose last segment has its sign reversed, so that row is named by its real offset ("GMT+9") -instead (`ZoneCatalog.isCityId`). +instead (`ZoneCatalog.hasPlaceName`). 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 From 40b4c9e2e4f133d234073a508fe30dc82d580ebc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 07:38:49 +0200 Subject: [PATCH 5/7] fix(worldclock): name offset-set zones by their real offset everywhere The Etc/GMT-n naming fix only covered the local row. A home that follows a device set by UTC offset still put "GMT-9" under a UTC+9 face. The rule now lives in the one place every row and the face take their city from. --- .../ui/worldclock/WorldClockRowState.kt | 43 +++++++++---------- .../ui/worldclock/WorldClockRowsTest.kt | 7 +++ docs/ARCHITECTURE.md | 4 +- 3 files changed, 30 insertions(+), 24 deletions(-) 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 0bded4d..fd1411a 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 @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.ui.worldclock import de.jeanlucmakiola.clockula.domain.WorldClock +import de.jeanlucmakiola.clockula.domain.Zones import de.jeanlucmakiola.clockula.domain.isKnownZone import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel import de.jeanlucmakiola.clockula.domain.worldclock.DayNight @@ -88,7 +89,7 @@ internal object WorldClockRows { WorldClockRowState( id = clock.id, zoneId = clock.zoneId, - name = clock.label ?: cityOf(clock.zoneId, entries), + name = clock.label ?: cityOf(clock.zoneId, entries, now), time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES), offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, @@ -101,10 +102,6 @@ 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. - * - * A device set by UTC offset rather than by city (Android 13+ offers it) - * reports an id like `Etc/GMT-9`, whose last segment is no city and has - * its sign reversed, so such a row is named by its offset ("GMT+9"). */ fun local( deviceZone: ZoneId, @@ -112,20 +109,13 @@ internal object WorldClockRows { now: Instant, entries: Map, zoneNames: Map, - ): WorldClockRowState { - val name = if (ZoneCatalog.hasPlaceName(deviceZone.id)) { - null - } else { - ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(deviceZone, now)) - } - return from( - clocks = listOf(WorldClock(id = LOCAL_ROW_ID, zoneId = deviceZone.id, label = name, sortOrder = 0)), - homeZone = homeZone, - now = now, - entries = entries, - zoneNames = zoneNames, - ).single() - } + ): 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 @@ -143,13 +133,22 @@ internal object WorldClockRows { val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS) return HomeFaceState( zoneId = homeZone.id, - city = cityOf(homeZone.id, entries), + city = cityOf(homeZone.id, entries, now), zoneName = zoneNames[homeZone.id], time = local, dayFraction = DayNight.fractionAt(local), ) } - private fun cityOf(zoneId: String, entries: Map): String = - entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) + /** + * A device set by UTC offset rather than by city (Android 13+ offers it) + * reports an id like `Etc/GMT-9`, whose last segment is no place and has + * its sign reversed, so such a zone is named by its real offset ("GMT+9"). + */ + private fun cityOf(zoneId: String, entries: Map, now: Instant): String = + if (!ZoneCatalog.hasPlaceName(zoneId) && Zones.isValid(zoneId)) { + ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(ZoneId.of(zoneId), now)) + } else { + entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) + } } 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 d5af1b3..51d40bf 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 @@ -265,4 +265,11 @@ class WorldClockRowsTest { assertThat(local.name).isEqualTo("GMT+9") assertThat(local.offset).isEqualTo(OffsetLabel.Ahead(8, 0)) } + + @Test + fun `a home that follows a device set by UTC offset is named by its real offset`() { + val home = WorldClockRows.home(ZoneId.of("Etc/GMT-9"), T0, entries, emptyMap()) + + assertThat(home.city).isEqualTo("GMT+9") + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23fedc9..007bf77 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2406,8 +2406,8 @@ 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. A device set by UTC offset rather than by city reports an id like `Etc/GMT-9`, whose last segment -has its sign reversed, so that row is named by its real offset ("GMT+9") -instead (`ZoneCatalog.hasPlaceName`). +has its sign reversed, so that zone is named by its real offset ("GMT+9") +instead, on the local row and on the face alike (`ZoneCatalog.hasPlaceName`). 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 From b79b38bd2b6ad3fb645e16901077cca8adc68edd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 07:44:54 +0200 Subject: [PATCH 6/7] fix(worldclock): fix Etc/GMT names at the fallback, not per caller The previous rule named every non-place id by its current offset, which renamed legacy ids such as Japan, called Etc/UTC "GMT" and made a CET row's name change at DST. Only the Etc/GMT+-n ids have a misleading last segment, so ZoneCatalog.cityFallbackFor now flips just those to their real offset. That also reaches the zone picker's device row, which takes its name from the same fallback. --- .../clockula/domain/worldclock/ZoneCatalog.kt | 27 ++++++++++++------- .../ui/worldclock/WorldClockRowState.kt | 18 +++---------- .../domain/worldclock/ZoneCatalogTest.kt | 12 ++++----- docs/ARCHITECTURE.md | 2 +- 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt index b72ad6a..cfdbac1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -60,24 +60,31 @@ object ZoneCatalog { * input trimmed when that is blank. Never throws. */ fun cityFallbackFor(zoneId: String): String { + etcOffsetName(zoneId)?.let { return it } val segment = zoneId.substringAfterLast('/').replace('_', ' ').trim() return segment.ifBlank { zoneId.trim() } } + /** + * `Etc/GMT-9` is UTC**+**9: POSIX signs these ids the other way round, so + * their last segment would name the zone with the wrong sign. A device set + * by UTC offset rather than by city (Android 13+ offers it) reports one, so + * the face and the local row name it by its real offset ("GMT+9"). + */ + private fun etcOffsetName(zoneId: String): String? { + val match = ETC_OFFSET.matchEntire(zoneId.trim()) ?: return null + val hours = match.groupValues[2].toInt() + if (hours == 0) return "GMT" + val sign = if (match.groupValues[1] == "-") "+" else "-" + return "GMT$sign$hours" + } + + private val ETC_OFFSET = Regex("""Etc/GMT([+-])(\d{1,2})""") + /** By `ZoneSearch.fold(city)` ascending, then [ZoneEntry.zoneId] ascending. */ fun sorted(entries: List): List = entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId })) - /** - * True when [cityFallbackFor] gives a fair name for [zoneId]: a place id, - * current (`Asia/Tokyo`) or legacy (`US/Pacific`), or [UTC]. False for the - * `Etc/` offsets and the bare abbreviations tzdata also lists (`Etc/GMT-9`, - * `CET`), whose last segment is no place and, for `Etc/GMT±n`, has the sign - * reversed. - */ - fun hasPlaceName(zoneId: String): Boolean = - zoneId == UTC || ('/' in zoneId && !zoneId.startsWith("Etc/")) - private fun isRegionId(zoneId: String): Boolean = zoneId.contains('/') && zoneId.substringBefore('/') in REGIONS } 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 fd1411a..d54e877 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 @@ -1,7 +1,6 @@ package de.jeanlucmakiola.clockula.ui.worldclock import de.jeanlucmakiola.clockula.domain.WorldClock -import de.jeanlucmakiola.clockula.domain.Zones import de.jeanlucmakiola.clockula.domain.isKnownZone import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel import de.jeanlucmakiola.clockula.domain.worldclock.DayNight @@ -89,7 +88,7 @@ internal object WorldClockRows { WorldClockRowState( id = clock.id, zoneId = clock.zoneId, - name = clock.label ?: cityOf(clock.zoneId, entries, now), + name = clock.label ?: cityOf(clock.zoneId, entries), time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES), offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) }, day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) }, @@ -133,22 +132,13 @@ internal object WorldClockRows { val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS) return HomeFaceState( zoneId = homeZone.id, - city = cityOf(homeZone.id, entries, now), + city = cityOf(homeZone.id, entries), zoneName = zoneNames[homeZone.id], time = local, dayFraction = DayNight.fractionAt(local), ) } - /** - * A device set by UTC offset rather than by city (Android 13+ offers it) - * reports an id like `Etc/GMT-9`, whose last segment is no place and has - * its sign reversed, so such a zone is named by its real offset ("GMT+9"). - */ - private fun cityOf(zoneId: String, entries: Map, now: Instant): String = - if (!ZoneCatalog.hasPlaceName(zoneId) && Zones.isValid(zoneId)) { - ZoneOffsetFormat.gmtLabel(ZoneComparisons.utcOffsetMinutes(ZoneId.of(zoneId), now)) - } else { - entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) - } + private fun cityOf(zoneId: String, entries: Map): String = + entries[zoneId]?.city ?: ZoneCatalog.cityFallbackFor(zoneId) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt index 4843e01..9591787 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalogTest.kt @@ -226,11 +226,11 @@ class ZoneCatalogTest { } @Test - fun `a place id or UTC has a place name, an offset or abbreviation does not`() { - assertThat(ZoneCatalog.hasPlaceName("Asia/Tokyo")).isTrue() - assertThat(ZoneCatalog.hasPlaceName("US/Pacific")).isTrue() - assertThat(ZoneCatalog.hasPlaceName("UTC")).isTrue() - assertThat(ZoneCatalog.hasPlaceName("Etc/GMT-9")).isFalse() - assertThat(ZoneCatalog.hasPlaceName("CET")).isFalse() + fun `an Etc offset id falls back to its real offset, not its reversed sign`() { + assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT-9")).isEqualTo("GMT+9") + assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT+5")).isEqualTo("GMT-5") + assertThat(ZoneCatalog.cityFallbackFor("Etc/GMT+0")).isEqualTo("GMT") + assertThat(ZoneCatalog.cityFallbackFor("Etc/UTC")).isEqualTo("UTC") + assertThat(ZoneCatalog.cityFallbackFor("CET")).isEqualTo("CET") } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 007bf77..46682c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2407,7 +2407,7 @@ 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. A device set by UTC offset rather than by city reports an id like `Etc/GMT-9`, whose last segment has its sign reversed, so that zone is named by its real offset ("GMT+9") -instead, on the local row and on the face alike (`ZoneCatalog.hasPlaceName`). +instead, wherever a zone falls back to its id for a name (`ZoneCatalog.cityFallbackFor`). 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 From 93f6fdf98dfc0edca6ce3b4a27497492a55308c7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 07:52:47 +0200 Subject: [PATCH 7/7] test(prefs): cover the automatic local clock's round trip and restore Also reuse ZoneOffsetFormat.gmtLabel for the Etc/GMT names rather than a second formatter, and bring the city-fallback docs up to date. --- .../clockula/domain/worldclock/ZoneCatalog.kt | 10 ++++++---- .../clockula/ui/worldclock/WorldClockRowState.kt | 2 +- .../clockula/data/prefs/ClockPrefsTest.kt | 2 ++ .../clockula/data/prefs/SettingsPrefsTest.kt | 8 +++++++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt index cfdbac1..fd397dc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/worldclock/ZoneCatalog.kt @@ -57,7 +57,8 @@ object ZoneCatalog { /** * The last `/`-segment with underscores replaced by spaces, trimmed; the - * input trimmed when that is blank. Never throws. + * input trimmed when that is blank. `Etc/GMT±n` is the exception: it is + * named by its real offset instead. Never throws. */ fun cityFallbackFor(zoneId: String): String { etcOffsetName(zoneId)?.let { return it } @@ -74,11 +75,12 @@ object ZoneCatalog { private fun etcOffsetName(zoneId: String): String? { val match = ETC_OFFSET.matchEntire(zoneId.trim()) ?: return null val hours = match.groupValues[2].toInt() - if (hours == 0) return "GMT" - val sign = if (match.groupValues[1] == "-") "+" else "-" - return "GMT$sign$hours" + val sign = if (match.groupValues[1] == "-") 1 else -1 + return ZoneOffsetFormat.gmtLabel(sign * hours * MINUTES_PER_HOUR) } + private const val MINUTES_PER_HOUR = 60 + private val ETC_OFFSET = Regex("""Etc/GMT([+-])(\d{1,2})""") /** By `ZoneSearch.fold(city)` ascending, then [ZoneEntry.zoneId] ascending. */ 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 d54e877..cbd1fda 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 @@ -18,7 +18,7 @@ import kotlin.time.toJavaInstant internal data class WorldClockRowState( val id: Long, val zoneId: String, - /** The user's label when set, else the ICU city, else the id's last segment. Never blank. */ + /** The user's label when set, else the ICU city, else `ZoneCatalog.cityFallbackFor`. Never blank. */ val name: String, /** Truncated to the minute. Non-null **exactly** when [known]. */ val time: LocalTime?, 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 869cca3..de4bf47 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 @@ -53,6 +53,7 @@ class ClockPrefsTest { prefs.setTimerRingtoneUri("content://t") prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") + prefs.setAutomaticLocalClock(false) assertThat(prefs.defaults.first()).isEqualTo( ClockDefaults( @@ -64,6 +65,7 @@ class ClockPrefsTest { timerRingtoneUri = "content://t", dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", + automaticLocalClock = false, ), ) } 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 71ead3e..82ddb9d 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 @@ -74,7 +74,12 @@ class SettingsPrefsTest { BackupSettings( themeMode = ThemeMode.DARK, dynamicColor = false, - defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "), + defaults = ClockDefaults( + snoozeMinutes = 99, + volumeRampSeconds = 99, + alarmRingtoneUri = " ", + automaticLocalClock = false, + ), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), ), ) @@ -84,6 +89,7 @@ class SettingsPrefsTest { assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60) assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60) assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull() + assertThat(prefs.currentDefaults().automaticLocalClock).isFalse() assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() }