refactor(settings): remove the unused default timer duration

Settings > Timers offered a "Default duration", but nothing that creates
a timer ever read it: the keypad starts empty. The row, its preference,
ClockDefaults.timerDuration and the backup field are gone, and the
screen's hint and the hub's subtitle now only speak of the sound.

Backups no longer write default_timer_duration_millis. Older files that
carry it still import, since the codec ignores unknown keys.

Closes #5
This commit is contained in:
makiolaj committed 2026-10-05 19:28:31 +02:00
1 parent face2edb8d
commit cac5b2dfca
18 files changed
+55 -101

No files matched your search

@@ -76,7 +76,6 @@ class BackupEngine @Inject constructor(
settings.setAlarmRingtoneUri(backup.defaults.alarmRingtoneUri)
settings.setTimerRingtoneUri(backup.defaults.timerRingtoneUri)
settings.setDismissChallenge(backup.defaults.dismissChallenge)
settings.setTimerDuration(backup.defaults.timerDuration)
settings.setHomeZoneId(backup.defaults.homeZoneId)
settings.setTimerPresets(backup.timerPresets)
}
@@ -103,7 +103,6 @@ object BackupCodec {
alarmRingtoneUri = defaults.alarmRingtoneUri,
timerRingtoneUri = defaults.timerRingtoneUri,
dismissChallenge = defaults.dismissChallenge.name,
timerDurationMillis = defaults.timerDuration.inWholeMilliseconds,
homeZoneId = defaults.homeZoneId,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
)
@@ -175,7 +174,6 @@ object BackupCodec {
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
timerDuration = settingsDto.timerDurationMillis.milliseconds,
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
),
timerPresets = settingsDto.timerPresets.map { it.milliseconds },
@@ -54,7 +54,11 @@ data class WorldClockDto(
@SerialName("sort_order") val sortOrder: Int = 0,
)
/** Field names mirror `ClockPrefs`'/`SettingsPrefs`' on-disk keys. */
/**
* Field names mirror `ClockPrefs`'/`SettingsPrefs`' on-disk keys. Older files
* may still carry `default_timer_duration_millis`, a setting that no longer
* exists; the codec ignores unknown keys, so they still import.
*/
@Serializable
data class SettingsDto(
@SerialName("theme_mode") val themeMode: String = "SYSTEM",
@@ -66,7 +70,6 @@ data class SettingsDto(
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
@SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE",
@SerialName("default_timer_duration_millis") val timerDurationMillis: Long = 300_000L,
@SerialName("home_zone_id") val homeZoneId: String? = null,
@SerialName("timer_presets") val timerPresets: List<Long> = emptyList(),
)
@@ -6,13 +6,7 @@ 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
@@ -21,9 +15,6 @@ import kotlin.time.Duration.Companion.seconds
*/
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)
@@ -39,14 +30,6 @@ object ClockPrefs {
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)
@@ -62,13 +62,11 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
},
store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.timerDurationMillis),
store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, timerDuration, homeZoneId ->
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
timerDuration = timerDuration,
homeZoneId = homeZoneId,
)
}.distinctUntilChanged()
@@ -90,8 +88,6 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
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)
// --- timer presets (M6) ---
@@ -1,8 +1,5 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen.
@@ -15,6 +12,5 @@ data class ClockDefaults(
val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val timerDuration: Duration = 5.minutes,
val homeZoneId: String? = null,
)
@@ -12,7 +12,7 @@ object TimerPresets {
val BUILT_IN: List<Duration> =
listOf(1.minutes, 2.minutes, 3.minutes, 5.minutes, 10.minutes, 15.minutes, 30.minutes, 1.hours)
/** `ClockPrefs`' own clamp, so nothing the user can choose is clamped away on the next read. */
/** The range a timer can run for: what the keypad can start and what a preset can hold. */
val MIN: Duration = 1.seconds
val MAX: Duration = 24.hours
@@ -206,7 +206,7 @@ private fun SettingsHub(
)
GroupedRow(
title = stringResource(R.string.settings_section_timers),
summary = stringResource(R.string.settings_timers_subtitle),
summary = stringResource(R.string.settings_timers_sound_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Timers) },
@@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
import javax.inject.Inject
/**
@@ -139,8 +138,6 @@ class SettingsViewModel @Inject internal constructor(
fun onDismissChallengeChanged(value: DismissChallenge) =
viewModelScope.launch { settings.setDismissChallenge(value) }
fun onTimerDurationChanged(value: Duration) = viewModelScope.launch { settings.setTimerDuration(value) }
// --- the default ringtone pickers ---
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
@@ -3,63 +3,30 @@ package de.jeanlucmakiola.clockula.ui.settings
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration
/** What a new timer starts with: its sound and its length. */
/** What a new timer starts with: its sound. */
@Composable
internal fun TimerDefaultsScreen(
state: SettingsUiState.Loaded,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showDuration by rememberSaveable { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) {
SettingsHint(stringResource(R.string.settings_timer_defaults_hint))
SettingsHint(stringResource(R.string.settings_timer_sound_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.editor_ringtone),
summary = ringtoneSummary(state.defaults.timerRingtoneUri),
position = Position.Top,
position = Position.Alone,
onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) },
)
GroupedRow(
title = stringResource(R.string.settings_timer_duration),
summary = durationMinutesText(state.defaults.timerDuration),
position = Position.Bottom,
onClick = { showDuration = true },
)
}
if (showDuration) {
OptionPicker(
title = stringResource(R.string.settings_timer_duration),
options = TimerPresets.BUILT_IN,
selected = state.defaults.timerDuration,
label = { durationMinutesText(it) },
onSelect = viewModel::onTimerDurationChanged,
onDismiss = { showDuration = false },
)
}
}
@Composable
private fun durationMinutesText(duration: Duration): String {
val minutes = duration.inWholeMinutes.toInt()
return pluralStringResource(R.plurals.settings_duration_minutes, minutes, minutes)
}
@@ -173,9 +173,7 @@ internal fun StartRow(
}
FilledIconButton(
onClick = onStart,
// Nothing outside 1 s…24 h is startable, which is `ClockPrefs`'
// own clamp — so nothing the user can choose is clamped away on
// the next read (M5 D16).
// Nothing outside 1 s…24 h (`TimerPresets.MIN..MAX`) is startable.
enabled = entry.canStart,
modifier = Modifier
.size(StartButtonSize)
@@ -52,7 +52,7 @@ class TimersViewModel @Inject internal constructor(
/** Creates a timer of [duration] and starts it. Re-entrant taps create one timer (D23). */
fun onStartNew(duration: Duration) {
if (creating) return
// Outside `ClockPrefs`' own clamp there is nothing honest to create: the
// Outside `TimerPresets.MIN..MAX` there is nothing honest to create: the
// panel's Start is disabled there, so this is the stale-caller guard.
if (duration !in TimerPresets.MIN..TimerPresets.MAX) return
creating = true
+2 -7
View File
@@ -294,13 +294,8 @@
<string name="settings_alarms_subtitle">Sound, snooze and dismiss for new alarms</string>
<string name="settings_alarm_defaults_hint">New alarms start with these. Each alarm can still change them in its editor.</string>
<string name="settings_section_timers">Timers</string>
<string name="settings_timers_subtitle">Sound and length for new timers</string>
<string name="settings_timer_defaults_hint">New timers start with these.</string>
<string name="settings_timer_duration">Default duration</string>
<plurals name="settings_duration_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<string name="settings_timers_sound_subtitle">Sound for new timers</string>
<string name="settings_timer_sound_hint">New timers ring with this sound. Each timer can still change it in its editor.</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone</string>
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
@@ -58,7 +58,6 @@ class BackupCodecTest {
alarmRingtoneUri = "content://media/1",
timerRingtoneUri = "content://media/2",
dismissChallenge = DismissChallenge.HOLD,
timerDuration = 10.minutes,
homeZoneId = "America/New_York",
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
@@ -113,6 +112,32 @@ class BackupCodecTest {
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
}
@Test
fun `an older file with the retired default timer duration still imports`() {
val json = """
{
"format": "clockula-backup",
"version": 1,
"exportedAt": 0,
"alarms": [],
"timers": [],
"worldClocks": [],
"settings": { "default_snooze_minutes": 7, "default_timer_duration_millis": 600000 }
}
""".trimIndent()
val decoded = BackupCodec.decode(json)
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
val defaults = (decoded as BackupResult.Success).content.settings.defaults
assertThat(defaults).isEqualTo(ClockDefaults(snoozeMinutes = 7))
}
@Test
fun `the retired default timer duration is no longer written`() {
assertThat(BackupCodec.encode(sampleContent())).doesNotContain("default_timer_duration_millis")
}
@Test
fun `wrong format rejects the whole file`() {
val decoded = BackupCodec.decode(minimalDocument(format = "something-else"))
@@ -20,7 +20,6 @@ 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
@@ -53,7 +52,6 @@ class ClockPrefsTest {
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(
@@ -65,7 +63,6 @@ class ClockPrefsTest {
alarmRingtoneUri = "content://a",
timerRingtoneUri = "content://t",
dismissChallenge = DismissChallenge.HOLD,
timerDuration = 90.seconds,
homeZoneId = "Europe/Berlin",
),
)
@@ -21,7 +21,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
/**
* The real [SettingsViewModel] over the real [de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs]
@@ -120,7 +119,6 @@ class SettingsViewModelTest {
viewModel.onSnoozeLimitChanged(2)
viewModel.onVolumeRampChanged(30)
viewModel.onDismissChallengeChanged(DismissChallenge.MATH)
viewModel.onTimerDurationChanged(20.minutes)
advanceUntilIdle()
val defaults = (viewModel.state.value as SettingsUiState.Loaded).defaults
@@ -129,7 +127,6 @@ class SettingsViewModelTest {
assertThat(defaults.snoozeLimit).isEqualTo(2)
assertThat(defaults.volumeRampSeconds).isEqualTo(30)
assertThat(defaults.dismissChallenge).isEqualTo(DismissChallenge.MATH)
assertThat(defaults.timerDuration).isEqualTo(20.minutes)
}
@Test