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
This commit is contained in:
1 parent
3f431a7f32
commit
f5388042c6
20 files changed
+445
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,6 +35,13 @@ object ClockPrefs {
|
||||
val homeZoneId: Pref<String?> = 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<Boolean> = booleanPref("automatic_local_clock", true)
|
||||
|
||||
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
|
||||
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
|
||||
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<String>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,6 +51,13 @@ internal sealed interface WorldClockUiState {
|
||||
/** In the repository's order — `sort_order`, then `id`. Never re-sorted here. */
|
||||
val rows: List<WorldClockRowState>,
|
||||
val canAdd: Boolean,
|
||||
/**
|
||||
* The 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<String, ZoneEntry>,
|
||||
zoneNames: Map<String, String?>,
|
||||
): 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String?> =
|
||||
settings.defaults.map { it.homeZoneId }.distinctUntilChanged()
|
||||
|
||||
private val automaticLocalClock: Flow<Boolean> =
|
||||
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<WorldClockUiState.Loaded> =
|
||||
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<WorldClock>,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,8 @@
|
||||
<string name="world_clock_limit">Add up to %1$d cities</string>
|
||||
<string name="world_clock_home_action">Home time zone</string>
|
||||
<string name="world_clock_home">Home</string>
|
||||
<!-- #24: labels the temporary row for the device's own time zone while it differs from home. -->
|
||||
<string name="world_clock_local">Local time</string>
|
||||
<string name="world_clock_empty_hint">Add a city to compare its time with your own.</string>
|
||||
<string name="world_clock_offset_same">Same time as home</string>
|
||||
<string name="world_clock_ahead">%1$s ahead</string>
|
||||
@@ -304,6 +306,9 @@
|
||||
<string name="settings_clock_subtitle">Home time zone</string>
|
||||
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
|
||||
<string name="settings_home_zone">Home time zone</string>
|
||||
<string name="settings_automatic_local_clock">Automatic local clock</string>
|
||||
<string name="settings_automatic_local_clock_hint">While you’re somewhere the time differs from home, show the local time under the home clock.</string>
|
||||
<string name="settings_automatic_local_clock_needs_home">Needs a home time zone. Home follows the device now, so it’s always local time.</string>
|
||||
<string name="settings_self_check">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_title">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
|
||||
|
||||
@@ -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\": []}")
|
||||
|
||||
@@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) =
|
||||
|
||||
+28
-3
@@ -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<ClockDefaults>` |
|
||||
| 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<ClockDefaults>` |
|
||||
| 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 `<uses-permission>`.
|
||||
| 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
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user