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.