feat(prefs): clock defaults and the stopwatch run record in DataStore

Alarm and timer defaults, world-clock preferences, and the stopwatch's
running state — the things that are settings rather than rows.

The run record is read and written as a whole in one DataStore
transaction, so a reader never sees a half-applied record and a process
killed mid-write cannot persist one.

Values are clamped on read: a preferences file someone has edited by hand
degrades to the default instead of propagating nonsense upwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:22 +02:00
co-authored by Claude Opus 5
parent 1d85faa54e
commit 11b220aba0
6 changed files with 503 additions and 2 deletions
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.booleanPref
import de.jeanlucmakiola.floret.prefs.enumPref
import de.jeanlucmakiola.floret.prefs.intPref
import de.jeanlucmakiola.floret.prefs.longPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The clock defaults' on-disk keys. Reads and writes both clamp through
* [Pref.map], so a value the user could never choose — a hand-edited file, a
* restored backup from a future version — cannot come back out of the store.
*/
object ClockPrefs {
private val MIN_TIMER_DURATION = 1.seconds
private val MAX_TIMER_DURATION = 24.hours
val snoozeMinutes: Pref<Int> = intPref("default_snooze_minutes", 10).clampedTo(1..60)
val snoozeLimit: Pref<Int> = intPref("default_snooze_limit", 3).clampedTo(0..10)
val vibrate: Pref<Boolean> = booleanPref("default_vibrate", true)
val volumeRampSeconds: Pref<Int> = intPref("default_volume_ramp_seconds", 15).clampedTo(0..60)
val alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent()
val dismissChallenge: Pref<DismissChallenge> =
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
val timerDurationMillis: Pref<Duration> = longPref(
"default_timer_duration_millis",
5.minutes.inWholeMilliseconds,
).map(
decode = { it.milliseconds.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION) },
encode = { it.coerceIn(MIN_TIMER_DURATION, MAX_TIMER_DURATION).inWholeMilliseconds },
)
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
.map(decode = Zones::normalise, encode = Zones::normalise)
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
private fun Pref<String?>.blankAsAbsent(): Pref<String?> {
val trim: (String?) -> String? = { it?.trim()?.takeIf(String::isNotEmpty) }
return map(decode = trim, encode = trim)
}
}
@@ -1,17 +1,24 @@
package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.appearance
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlin.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
/**
* Clockula's app preferences. M1 covers the appearance slice only; M2 adds the
* clock defaults and the stopwatch running state to this same class.
* Clockula's app preferences: the appearance choices (M1) and the clock
* defaults every alarm and timer inherits (M2). The stopwatch's running state
* lives next door in `StopwatchStateStore`.
*/
@Singleton
class SettingsPrefs @Inject constructor(private val store: PrefStore) {
@@ -28,4 +35,61 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setThemeMode(mode: ThemeMode) = store.set(AppearancePrefs.themeMode, mode)
suspend fun setDynamicColor(enabled: Boolean) = store.set(AppearancePrefs.dynamicColor, enabled)
// --- clock defaults (M2) ---
/**
* Distinct-until-changed; built once, for the same reason [appearance] is.
* Composed from the individual keys rather than one snapshot read, so a
* write to an unrelated preference cannot re-emit it.
*/
val defaults: Flow<ClockDefaults> = combine(
combine(
store.flow(ClockPrefs.snoozeMinutes),
store.flow(ClockPrefs.snoozeLimit),
store.flow(ClockPrefs.vibrate),
store.flow(ClockPrefs.volumeRampSeconds),
store.flow(ClockPrefs.dismissChallenge),
) { snoozeMinutes, snoozeLimit, vibrate, volumeRampSeconds, dismissChallenge ->
ClockDefaults(
snoozeMinutes = snoozeMinutes,
snoozeLimit = snoozeLimit,
vibrate = vibrate,
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge,
)
},
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.timerDurationMillis),
store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, timerDuration, homeZoneId ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
timerDuration = timerDuration,
homeZoneId = homeZoneId,
)
}.distinctUntilChanged()
suspend fun currentDefaults(): ClockDefaults = defaults.first()
suspend fun setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes)
suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit)
suspend fun setVibrate(enabled: Boolean) = store.set(ClockPrefs.vibrate, enabled)
suspend fun setVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.volumeRampSeconds, seconds)
suspend fun setAlarmRingtoneUri(uri: String?) = store.set(ClockPrefs.alarmRingtoneUri, uri)
suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri)
suspend fun setDismissChallenge(challenge: DismissChallenge) =
store.set(ClockPrefs.dismissChallenge, challenge)
suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration)
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* The stopwatch's running state — a single record, in DataStore rather than
* Room (docs/PLAN.md §5). Every value here is monotonic: no key holds a
* wall-clock instant, because a stopwatch that spans a reboot has lost time
* nobody can reconstruct and guessing at it would be a lie.
*
* The four keys are read and written as **one record**, not as four independent
* `Pref`s: the state and its anchor only mean anything together, so a reader
* must never see "RUNNING with no anchor" because a multi-key write was caught
* half-done.
*/
object StopwatchPrefs {
private val state = stringPreferencesKey("stopwatch_state")
private val startedAtElapsed = longPreferencesKey("stopwatch_started_elapsed_millis")
private val accumulated = longPreferencesKey("stopwatch_accumulated_millis")
private val lastLapCumulative = longPreferencesKey("stopwatch_last_lap_cumulative_millis")
/** One consistent snapshot to one record. Never throws: a bad value degrades. */
fun read(prefs: Preferences): StopwatchRun = StopwatchRun(
state = prefs[state].toEnum(StopwatchState.IDLE),
accumulated = prefs.elapsed(accumulated),
startedAtElapsedRealtime = prefs[startedAtElapsed]?.milliseconds,
lastLapCumulative = prefs.elapsed(lastLapCumulative),
)
/**
* The whole record into one [MutablePreferences], so [read] sees all of it
* or none of it. An absent start anchor removes its key rather than storing
* a sentinel for it.
*/
fun write(prefs: MutablePreferences, run: StopwatchRun) {
prefs[state] = run.state.name
prefs[accumulated] = run.accumulated.clampedMillis()
prefs[lastLapCumulative] = run.lastLapCumulative.clampedMillis()
val startedAt = run.startedAtElapsedRealtime
if (startedAt == null) prefs.remove(startedAtElapsed) else prefs[startedAtElapsed] = startedAt.inWholeMilliseconds
}
/** A stored elapsed value, clamped: a negative one is corruption, not a duration. */
private fun Preferences.elapsed(key: Preferences.Key<Long>): Duration =
(this[key] ?: 0L).coerceAtLeast(0L).milliseconds
private fun Duration.clampedMillis(): Long = coerceAtLeast(Duration.ZERO).inWholeMilliseconds
}
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import de.jeanlucmakiola.clockula.data.prefs.StopwatchPrefs
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** The DataStore half of the stopwatch. Nothing else touches [StopwatchPrefs]. */
@Singleton
class StopwatchStateStore @Inject constructor(private val store: PrefStore) {
/**
* Distinct-until-changed; built once, so a collector does not restart it.
* Read from one snapshot rather than composed from four key flows, so an
* emission is always a whole record.
*/
val run: Flow<StopwatchRun> = store.data.map(StopwatchPrefs::read).distinctUntilChanged()
suspend fun current(): StopwatchRun = run.first()
/** One transaction for all four keys: a torn record is never persisted or observed. */
suspend fun set(run: StopwatchRun) = store.edit { StopwatchPrefs.write(it, run) }
}
@@ -0,0 +1,154 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.seconds
/**
* The clock defaults every alarm and timer inherits. A value the user could
* never choose must not come back out of a hand-edited or restored file, so
* both writes and reads clamp.
*/
class ClockPrefsTest {
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
)
@Test
fun `a fresh install reads the documented defaults`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
assertThat(prefs.defaults.first()).isEqualTo(ClockDefaults())
}
@Test
fun `every default round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setSnoozeMinutes(5)
prefs.setSnoozeLimit(1)
prefs.setVibrate(false)
prefs.setVolumeRampSeconds(0)
prefs.setAlarmRingtoneUri("content://a")
prefs.setTimerRingtoneUri("content://t")
prefs.setDismissChallenge(DismissChallenge.HOLD)
prefs.setTimerDuration(90.seconds)
prefs.setHomeZoneId("Europe/Berlin")
assertThat(prefs.defaults.first()).isEqualTo(
ClockDefaults(
snoozeMinutes = 5,
snoozeLimit = 1,
vibrate = false,
volumeRampSeconds = 0,
alarmRingtoneUri = "content://a",
timerRingtoneUri = "content://t",
dismissChallenge = DismissChallenge.HOLD,
timerDuration = 90.seconds,
homeZoneId = "Europe/Berlin",
),
)
}
@Test
fun `the defaults do not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.defaults.test {
assertThat(awaitItem()).isEqualTo(ClockDefaults())
prefs.setSnoozeMinutes(ClockDefaults().snoozeMinutes)
expectNoEvents()
cancel()
}
}
@Test
fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setSnoozeMinutes(0)
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1)
prefs.setSnoozeMinutes(999)
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(60)
}
@Test
fun `a corrupt stored snooze value clamps on read`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences().apply { this[intPreferencesKey("default_snooze_minutes")] = -5 }
}
assertThat(prefs.defaults.first().snoozeMinutes).isEqualTo(1)
}
@Test
fun `a corrupt dismiss challenge degrades to none`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences()
.apply { this[stringPreferencesKey("default_dismiss_challenge")] = "TELEPORT" }
}
assertThat(prefs.defaults.first().dismissChallenge).isEqualTo(DismissChallenge.NONE)
}
@Test
fun `a home zone the device no longer knows reads back as absent`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences().apply { this[stringPreferencesKey("home_zone_id")] = "Mars/Phobos" }
}
assertThat(prefs.defaults.first().homeZoneId).isNull()
prefs.setHomeZoneId(" Europe/Berlin ")
assertThat(prefs.defaults.first().homeZoneId).isEqualTo("Europe/Berlin")
prefs.setHomeZoneId(null)
assertThat(prefs.defaults.first().homeZoneId).isNull()
}
@Test
fun `writing a clock default does not disturb the appearance flows`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.themeMode.test {
assertThat(awaitItem()).isEqualTo(ThemeMode.SYSTEM)
prefs.setSnoozeMinutes(5)
expectNoEvents()
cancel()
}
}
}
@@ -0,0 +1,137 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchStateStore
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The stopwatch's run record. It is monotonic time and nothing else — no key
* here may hold a wall-clock instant.
*/
class StopwatchPrefsTest {
private fun TestScope.newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
)
@Test
fun `the stored run record holds no wall-clock value`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val store = StopwatchStateStore(PrefStore(dataStore))
store.set(
StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = Duration.ZERO,
startedAtElapsedRealtime = 1_000.seconds,
),
)
val snapshot = dataStore.data.first().asMap()
assertThat(snapshot.values.filterIsInstance<Long>().filter { it >= 1_000_000_000_000L }).isEmpty()
assertThat(snapshot.keys.map { it.name }).containsExactly(
"stopwatch_state",
"stopwatch_started_elapsed_millis",
"stopwatch_accumulated_millis",
"stopwatch_last_lap_cumulative_millis",
)
}
@Test
fun `a fresh install is the default run`(@TempDir tempDir: Path) = runTest {
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
assertThat(store.current()).isEqualTo(StopwatchRun())
}
@Test
fun `a full record round-trips`(@TempDir tempDir: Path) = runTest {
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
val run = StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = 42.seconds,
startedAtElapsedRealtime = null,
lastLapCumulative = 30.seconds,
)
store.set(run)
assertThat(store.current()).isEqualTo(run)
}
@Test
fun `an unknown stored state degrades to idle`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val store = StopwatchStateStore(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences().apply { this[stringPreferencesKey("stopwatch_state")] = "SPINNING" }
}
assertThat(store.current().state).isEqualTo(StopwatchState.IDLE)
}
@Test
fun `a whole record lands in a single emission`(@TempDir tempDir: Path) = runTest {
val store = StopwatchStateStore(PrefStore(newDataStore(tempDir)))
val seen = mutableListOf<StopwatchRun>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { store.run.toList(seen) }
store.set(
StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = 42.seconds,
startedAtElapsedRealtime = 1_000.seconds,
lastLapCumulative = 30.seconds,
),
)
// Four keys, one transaction: a reader never sees RUNNING without its
// anchor, or the new state beside the old accumulated total.
assertThat(seen).containsExactly(
StopwatchRun(),
StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = 42.seconds,
startedAtElapsedRealtime = 1_000.seconds,
lastLapCumulative = 30.seconds,
),
).inOrder()
}
@Test
fun `clearing the start anchor removes its key`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val store = StopwatchStateStore(PrefStore(dataStore))
store.set(
StopwatchRun(state = StopwatchState.RUNNING, startedAtElapsedRealtime = 1_000.seconds),
)
store.set(StopwatchRun(state = StopwatchState.PAUSED, startedAtElapsedRealtime = null))
assertThat(store.current().startedAtElapsedRealtime).isNull()
assertThat(dataStore.data.first().asMap().keys.map { it.name })
.doesNotContain("stopwatch_started_elapsed_millis")
}
}