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:
commit
813bb52c03
24 files changed
+566
-57
No files matched your search
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user