Merge branch 'feat/24-automatic-home-clock' into release/v1.0.0

# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRowState.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
This commit is contained in:
makiolaj committed 2026-10-26 20:56:34 +01:00
commit 813bb52c03
24 files changed
+566 -57

No files matched your search

@@ -143,6 +143,7 @@ object BackupCodec {
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
volumeButtons = defaults.volumeButtons.name,
automaticLocalClock = defaults.automaticLocalClock,
worldClockSeconds = worldClockSeconds,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
bedtime = bedtime?.toDto(),
@@ -300,6 +301,7 @@ object BackupCodec {
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
automaticLocalClock = settingsDto.automaticLocalClock,
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
worldClockSeconds = settingsDto.worldClockSeconds,
@@ -93,6 +93,8 @@ data class SettingsDto(
*/
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
/** Absent in files from before #24, which keeps the default: on. */
@SerialName("automatic_local_clock") val automaticLocalClock: Boolean = true,
/** Absent in files from before it existed, which keeps the default: off. */
@SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false,
/**
@@ -74,6 +74,13 @@ object ClockPrefs {
*/
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
/**
* 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) })
@@ -79,9 +79,14 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
) { timerRingtoneUri, timerVibrate, timerVolumeRampSeconds ->
Triple(timerRingtoneUri, timerVibrate, timerVolumeRampSeconds)
},
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), alarmRingtoneUri, homeZoneId ->
combine(
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
store.flow(ClockPrefs.automaticLocalClock),
) { alarmRingtoneUri, homeZoneId, automaticLocalClock ->
Triple(alarmRingtoneUri, homeZoneId, automaticLocalClock)
},
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), (alarmRingtoneUri, homeZoneId, automaticLocalClock) ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
@@ -91,6 +96,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
alarmSilenceAfterMinutes = alarmSilenceAfter,
timerSilenceAfterMinutes = timerSilenceAfter,
volumeButtons = volumeButtons,
automaticLocalClock = automaticLocalClock,
)
}.distinctUntilChanged()
@@ -118,6 +124,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
prefs[ClockPrefs.automaticLocalClock] = backup.defaults.automaticLocalClock
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
@@ -169,6 +176,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)
// --- world clock display (#23) ---
/** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */
@@ -33,6 +33,8 @@ data class ClockDefaults(
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
/** The World clock tab's temporary local-time row while travelling (#24). */
val automaticLocalClock: Boolean = true,
) {
/** How long a ringing alarm rings before it is silenced as missed. */
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
@@ -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,48 @@ 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.
*
* [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,
homeZone: ZoneId,
deviceZone: ZoneId,
savedZoneIds: List<String>,
at: Instant,
): ZoneId? {
if (!enabled || !Zones.isValid(deviceZone.id)) return null
val deviceOffset = ZoneComparisons.utcOffsetMinutes(deviceZone, at)
if (ZoneComparisons.utcOffsetMinutes(homeZone, at) == deviceOffset) return null
val alreadyShown = savedZoneIds.any { id ->
Zones.isValid(id) && ZoneComparisons.utcOffsetMinutes(ZoneId.of(id), at) == deviceOffset
}
return deviceZone.takeUnless { alreadyShown }
}
}
@@ -57,13 +57,32 @@ 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 }
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()
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. */
fun sorted(entries: List<ZoneEntry>): List<ZoneEntry> =
entries.sortedWith(compareBy({ ZoneSearch.fold(it.city) }, { it.zoneId }))
@@ -35,7 +35,7 @@ internal fun ClockSettingsScreen(
GroupedRow(
title = stringResource(R.string.settings_world_clock_seconds),
summary = stringResource(R.string.settings_world_clock_seconds_hint),
position = Position.Bottom,
position = Position.Middle,
trailing = {
Switch(
checked = state.worldClockSeconds,
@@ -44,5 +44,29 @@ internal fun ClockSettingsScreen(
},
onClick = { viewModel.onWorldClockSecondsChanged(!state.worldClockSeconds) },
)
// 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) },
)
}
}
@@ -282,6 +282,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
}
@@ -3,13 +3,16 @@ 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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.NearMe
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -18,6 +21,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
@@ -134,45 +138,17 @@ 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 = timeStyle.style,
// One line only when capped (seconds on); without seconds
// the time wraps at a huge font scale, as it always did.
maxLines = if (timeStyle.capped) 1 else Int.MAX_VALUE,
softWrap = !timeStyle.capped,
overflow = if (timeStyle.capped) TextOverflow.Ellipsis else TextOverflow.Clip,
// The row's description already says the time, to the
// minute; the ticking text itself stays out of the tree.
modifier = Modifier
.widthIn(max = timeStyle.maxWidth)
.clearAndSetSemantics {},
color = if (isDragging) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
RowText(
name = row.name,
summary = summary,
time = time,
timeStyle = timeStyle,
timeColor = if (isDragging) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
@@ -234,6 +210,108 @@ internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime)
}
}
/**
* 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,
formatSpokenTime: (LocalTime) -> String,
timeStyle: RowTimeStyle,
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))
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.
// Spoken to the minute, as a saved row is.
val spokenTime = row.time?.let(formatSpokenTime)
val description = if (spokenTime != null) {
stringResource(R.string.world_clock_row_description, row.name, spokenTime, summary)
} else {
stringResource(R.string.world_clock_row_description_unknown, row.name, summary)
}
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),
)
// 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, timeStyle = timeStyle, summaryMaxLines = 2)
}
}
}
/** 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?,
timeStyle: RowTimeStyle,
timeColor: Color = MaterialTheme.colorScheme.onSurface,
summaryMaxLines: Int = 1,
) {
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 = summaryMaxLines,
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 = timeStyle.style,
// One line only when capped (seconds on); without seconds
// the time wraps at a huge font scale, as it always did.
maxLines = if (timeStyle.capped) 1 else Int.MAX_VALUE,
softWrap = !timeStyle.capped,
overflow = if (timeStyle.capped) TextOverflow.Ellipsis else TextOverflow.Clip,
// The row's description already says the time, to the
// minute; the ticking text itself stays out of the tree.
modifier = Modifier
.widthIn(max = timeStyle.maxWidth)
.clearAndSetSemantics {},
color = 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.
@@ -17,7 +17,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, or the second with seconds on. Non-null **exactly** when [known]. */
val time: LocalTime?,
@@ -52,6 +52,13 @@ internal sealed interface WorldClockUiState {
val canAdd: Boolean,
/** The user's "show seconds": the readouts' format and the face's second hand. */
val showSeconds: 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
}
@@ -93,6 +100,30 @@ 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.
*/
fun local(
deviceZone: ZoneId,
homeZone: ZoneId,
now: Instant,
entries: Map<String, ZoneEntry>,
zoneNames: Map<String, String?>,
showSeconds: Boolean,
): WorldClockRowState = from(
clocks = listOf(WorldClock(id = LOCAL_ROW_ID, zoneId = deviceZone.id, label = null, sortOrder = 0)),
homeZone = homeZone,
now = now,
entries = entries,
zoneNames = zoneNames,
showSeconds = showSeconds,
).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,
@@ -134,6 +134,18 @@ fun WorldClockScreen(
formatSpokenTime = formatSpokenTime,
)
// 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,
formatSpokenTime = formatSpokenTime,
timeStyle = rowTimeStyle,
)
Spacer(Modifier.height(8.dp))
}
if (loaded.rows.isEmpty()) {
EmptyWorldClocks()
} else {
@@ -9,6 +9,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
@@ -38,8 +39,12 @@ 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<ZoneEntry>, val deviceCity: String)
/** The two settings the tab reads: the stored home zone and the local-clock switch. */
private val tabSettings: Flow<Pair<String?, Boolean>> =
settings.defaults.map { it.homeZoneId to it.automaticLocalClock }.distinctUntilChanged()
private val storedHomeZoneId: Flow<String?> =
settings.defaults.map { it.homeZoneId }.distinctUntilChanged()
tabSettings.map { it.first }.distinctUntilChanged()
/**
* The tab's state. Both the instant and the device zone are re-read on
@@ -50,6 +55,8 @@ internal class WorldClockSource @Inject constructor(
* registered once per subscription rather than once per list edit, and
* `onStart` lets the first state through without waiting for a broadcast.
* `SystemEventReceiver` still needs no new branch (D2).
* The same read is what brings the automatic local clock (#24) in and out
* as the device's zone changes under an open tab.
*
* [refreshes] does the same for what no broadcast announces: the screen
* sends one on every resume, which covers a per-app language change (the
@@ -66,13 +73,14 @@ internal class WorldClockSource @Inject constructor(
fun state(refreshes: Flow<Unit>): Flow<WorldClockUiState.Loaded> =
combine(
worldClocks.worldClocks(),
storedHomeZoneId,
tabSettings,
settings.worldClockSeconds,
merge(clockChanges.changes(), refreshes).onStart { emit(Unit) },
) { clocks, storedHome, seconds, _ -> Triple(clocks, storedHome, seconds) }
.flatMapLatest { (clocks, storedHome, seconds) ->
) { clocks, tab, seconds, _ -> Triple(clocks, tab, seconds) }
.flatMapLatest { (clocks, tab, seconds) ->
val (storedHome, localClock) = tab
ticker.alignedTicks(WorldClockDefaults.tickFor(seconds))
.map { build(clocks, storedHome, seconds) }
.map { build(clocks, storedHome, localClock, seconds) }
}
.distinctUntilChanged()
@@ -121,11 +129,21 @@ internal class WorldClockSource @Inject constructor(
private suspend fun build(
clocks: List<WorldClock>,
storedHome: String?,
localClock: Boolean,
showSeconds: 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 savedZoneIds = clocks.map { it.zoneId }
val localZone = LocalClock.zoneToShow(
enabled = localClock,
homeZone = homeZone,
deviceZone = deviceZone,
savedZoneIds = savedZoneIds,
at = now,
)
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
@@ -137,6 +155,7 @@ internal class WorldClockSource @Inject constructor(
rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds),
canAdd = clocks.size < WorldClocks.MAX,
showSeconds = showSeconds,
local = localZone?.let { WorldClockRows.local(it, homeZone, now, entries, zoneNames, showSeconds) },
)
}
+6 -1
View File
@@ -261,6 +261,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>
@@ -350,11 +352,14 @@
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone and seconds</string>
<string name="settings_clock_subtitle">Home time zone, seconds and local clock</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_world_clock_seconds">Display time with seconds</string>
<string name="settings_world_clock_seconds_hint">The world clock shows seconds and a second hand. Uses a little more battery.</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>
@@ -72,6 +72,7 @@ class BackupCodecTest {
alarmSilenceAfterMinutes = 25,
timerSilenceAfterMinutes = SilenceAfter.NEVER,
volumeButtons = VolumeButtonAction.SNOOZE,
automaticLocalClock = false,
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
worldClockSeconds = true,
@@ -476,6 +477,17 @@ class BackupCodecTest {
assertThat(BackupCodec.encode(sampleContent())).contains("\"world_clock_show_seconds\": true")
}
@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\": []}")
@@ -60,6 +60,7 @@ class ClockPrefsTest {
prefs.setAlarmSilenceAfterMinutes(25)
prefs.setTimerSilenceAfterMinutes(SilenceAfter.NEVER)
prefs.setVolumeButtons(VolumeButtonAction.DISMISS)
prefs.setAutomaticLocalClock(false)
assertThat(prefs.defaults.first()).isEqualTo(
ClockDefaults(
@@ -76,6 +77,7 @@ class ClockPrefsTest {
alarmSilenceAfterMinutes = 25,
timerSilenceAfterMinutes = SilenceAfter.NEVER,
volumeButtons = VolumeButtonAction.DISMISS,
automaticLocalClock = false,
),
)
}
@@ -91,6 +91,7 @@ class SettingsPrefsTest {
volumeButtons = VolumeButtonAction.NOTHING,
timerVibrate = false,
timerVolumeRampSeconds = 99,
automaticLocalClock = false,
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
worldClockSeconds = false,
@@ -107,6 +108,7 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().volumeButtons).isEqualTo(VolumeButtonAction.NOTHING)
assertThat(prefs.currentDefaults().timerVibrate).isFalse()
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
}
@@ -0,0 +1,98 @@
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, HomeZone.resolve(storedHome, device), 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 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)
}
@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()
}
}
@@ -224,4 +224,13 @@ class ZoneCatalogTest {
assertThat(picked).containsExactly("Europe/Kyiv")
}
@Test
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")
}
}
@@ -276,4 +276,33 @@ 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(), showSeconds = false)
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()
}
@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(), showSeconds = false)
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(), showSeconds = false)
assertThat(home.city).isEqualTo("GMT+9")
}
}
@@ -302,6 +302,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) =