fix(timers): settle timer vibration in one transaction and announce its row as a switch

The settle read the store, then wrote it in a second step; a write in between could leave it deciding on a stale snapshot. It is one edit now, which also skips the write once the value exists.

The Vibrate row carries the switch role and its state, since the switch itself is display-only. The changelog says what an updated install gets, not just a fresh one.

Refs #22
This commit is contained in:
makiolaj committed 2026-10-21 19:15:39 +02:00
1 parent 84d31fe7f8
commit cc2099c40a
4 files changed
+31 -9

No files matched your search

+5 -3
View File
@@ -38,9 +38,11 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
timer turns the screen on and opens over the lock screen with Stop and
+1 min. Timers keep counting through a reboot and aren't affected by changes
to the clock.
- Timers have their own vibration switch, off by default, and an optional
gradual volume, both apart from the alarms'. A kitchen timer can just beep
while alarms still vibrate; a silent timer still vibrates.
- Timers have their own vibration switch and an optional gradual volume, both
apart from the alarms'. A kitchen timer can just beep while alarms still
vibrate; a silent timer still vibrates. New installs start with timer
vibration off; an updated install keeps vibrating its timers if it did
before.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
@@ -105,7 +105,9 @@ class ClockulaApp : Application() {
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// Unsettled is safe; the next launch tries again.
// Unsettled reads as the old behaviour. The next launch tries again,
// and by then the store is no longer empty, so even a fresh install
// settles on the alarm default: vibrating, the safe side to err on.
}
}
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
@@ -135,10 +137,16 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
* side's fallback keeps the old behaviour, so a failed write costs nothing.
*/
suspend fun settleTimerVibrate(freshInstall: Boolean) {
val untouched = store.data.first().asMap().isEmpty()
val alarmVibrate = store.get(ClockPrefs.vibrate)
store.update(ClockPrefs.timerVibrate) { stored ->
stored ?: if (untouched && freshInstall) false else alarmVibrate
// One transaction, so nothing written meanwhile can make the snapshot
// it decides on stale. Raw keys for the reads: the kit reads a Pref
// only from a store, not from a transaction's preferences.
store.edit { prefs ->
if (stringPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit
prefs[ClockPrefs.timerVibrate] = if (prefs.asMap().isEmpty() && freshInstall) {
false
} else {
prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default
}
}
}
@@ -10,6 +10,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker
@@ -49,6 +54,11 @@ internal fun TimerDefaultsScreen(
// what the ring does with Silent and vibration both off: vibrates anyway.
GroupedRow(
title = stringResource(R.string.editor_vibrate),
// The switch below is display-only, so the row carries its role and state.
modifier = Modifier.semantics {
role = Role.Switch
toggleableState = ToggleableState(defaults.timerVibrate)
},
summary = if (defaults.timerVibrate) {
vibrateText(on = true)
} else {
@@ -57,7 +67,7 @@ internal fun TimerDefaultsScreen(
position = Position.Middle,
trailing = {
// Display only: the row is the one control, so a screen reader
// stops on it once rather than on the row and then the switch.
// stops once, on a row announced as a switch.
Switch(checked = defaults.timerVibrate, onCheckedChange = null)
},
onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) },