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

+7
View File
@@ -67,6 +67,13 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
them by dragging or with a screen reader, and set a home time zone or follow
the device. "Display time with seconds" in Settings > Clock adds seconds to
every time on the tab and a second hand to the face.
- 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
@@ -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) =
+31 -3
View File
@@ -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 |
@@ -612,7 +612,7 @@ Six 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_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `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_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override), `automatic_local_clock` (#24) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| 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 |
@@ -1094,7 +1094,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 |
@@ -2639,6 +2638,35 @@ 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. A device set by UTC
offset rather than by city reports an id like `Etc/GMT-9`, whose last segment
has its sign reversed, so that zone is named by its real offset ("GMT+9")
instead, wherever a zone falls back to its id for a name (`ZoneCatalog.cityFallbackFor`).
The row is not stored. It is `WorldClockUiState.Loaded.local`, built per tick
by the source from the same `zones.current()` read the face uses, so a
`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
View File
@@ -160,6 +160,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_timer_silence_after_minutes": 10,
"volume_buttons_while_ringing": "CONTROL_VOLUME",
"world_clock_show_seconds": false,
"automatic_local_clock": true,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
"bedtime": {
"enabled": false,
@@ -202,7 +203,8 @@ rings for that long anyway); a negative one reads as the default, 10.
Files from before `world_clock_show_seconds` existed import with it off, its
default. It travels with the settings but is not a clock default: nothing an
alarm or timer inherits depends on it.
alarm or timer inherits depends on it. 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.