feat(timers): give timers their own vibration and gradual volume

Timers vibrated by the alarm vibration default and never ramped. Both are
now the timers' own app-wide settings, next to the default timer sound in
Settings > Timers: a Vibrate switch and the same Gradual volume picker
alarms use. Timer.resolveSettings reads them instead of the alarm default
and the old TimerRing.RAMP_SECONDS constant.

A fresh install starts with timer vibration off, as Google Clock does, and
no ramp. An existing install keeps what its timers did: a one-shot DataStore
migration writes the new key from the alarm vibration default before the
first read, so nobody's kitchen timer silently stops buzzing on update. A
fresh install is told apart by an empty store (anything that has run once
has written its boot id); the migration writes the default there too, so it
never runs twice.

Backups carry both new fields. A file without them restores the alarm
vibration default as the timers' (what that install's timers did) and a
flat ring.

Silent plus vibration off still signals: Silent gives the player no
sources, and its onSilenced callback starts the vibrator whatever the
setting says. The settings row says so.

Fixes #22
This commit is contained in:
makiolaj committed 2026-10-21 18:49:45 +02:00
1 parent 3f431a7f32
commit fbc3a192bf
21 files changed
+375 -38

No files matched your search

+3
View File
@@ -38,6 +38,9 @@ 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 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 +1 min. Timers keep counting through a reboot and aren't affected by changes
to the clock. 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.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause - **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed. in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the - **World clock.** An analog face for your own time, plus cities from the
@@ -125,6 +125,8 @@ object BackupCodec {
volumeRampSeconds = defaults.volumeRampSeconds, volumeRampSeconds = defaults.volumeRampSeconds,
alarmRingtoneUri = defaults.alarmRingtoneUri, alarmRingtoneUri = defaults.alarmRingtoneUri,
timerRingtoneUri = defaults.timerRingtoneUri, timerRingtoneUri = defaults.timerRingtoneUri,
timerVibrate = defaults.timerVibrate,
timerVolumeRampSeconds = defaults.timerVolumeRampSeconds,
dismissChallenge = defaults.dismissChallenge.name, dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId, homeZoneId = defaults.homeZoneId,
timerPresets = timerPresets.map { it.inWholeMilliseconds }, timerPresets = timerPresets.map { it.inWholeMilliseconds },
@@ -231,6 +233,8 @@ object BackupCodec {
volumeRampSeconds = settingsDto.volumeRampSeconds, volumeRampSeconds = settingsDto.volumeRampSeconds,
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() }, alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() }, timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate,
timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds,
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE), dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise), homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
), ),
@@ -69,6 +69,12 @@ data class SettingsDto(
@SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15, @SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15,
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null, @SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null, @SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
/**
* Absent in a file from before timers had their own vibration; such a file
* restores [vibrate] here, which is what its timers vibrated by.
*/
@SerialName("default_timer_vibrate") val timerVibrate: Boolean? = null,
@SerialName("default_timer_volume_ramp_seconds") val timerVolumeRampSeconds: Int = 0,
@SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE", @SerialName("default_dismiss_challenge") val dismissChallenge: String = "NONE",
@SerialName("home_zone_id") val homeZoneId: String? = null, @SerialName("home_zone_id") val homeZoneId: String? = null,
/** /**
@@ -12,6 +12,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.prefs.TimerVibrateMigration
import de.jeanlucmakiola.floret.di.IoDispatcher import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.prefs.PrefStore import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -62,12 +63,15 @@ object DataModule {
* theme catches it. Replacing an unreadable file with empty preferences * theme catches it. Replacing an unreadable file with empty preferences
* costs the user their two appearance choices and keeps the app startable; * costs the user their two appearance choices and keeps the app startable;
* the alternative is an app that can only be fixed by clearing its data. * the alternative is an app that can only be fixed by clearing its data.
*
* Migrations run before the first read, so nothing ever sees a key they fill.
*/ */
internal fun createDataStore( internal fun createDataStore(
scope: CoroutineScope, scope: CoroutineScope,
produceFile: () -> File, produceFile: () -> File,
): DataStore<Preferences> = PreferenceDataStoreFactory.create( ): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
migrations = listOf(TimerVibrateMigration),
scope = scope, scope = scope,
produceFile = produceFile, produceFile = produceFile,
) )
@@ -28,6 +28,16 @@ object ClockPrefs {
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent()
/**
* Off on a fresh install. An install from before the setting existed has
* it written once by [TimerVibrateMigration], from [vibrate] — which is
* what its timers vibrated by until then.
*/
val timerVibrate: Pref<Boolean> = booleanPref("default_timer_vibrate", false)
val timerVolumeRampSeconds: Pref<Int> =
intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS)
val dismissChallenge: Pref<DismissChallenge> = val dismissChallenge: Pref<DismissChallenge> =
enumPref("default_dismiss_challenge", DismissChallenge.NONE) enumPref("default_dismiss_challenge", DismissChallenge.NONE)
@@ -62,13 +62,21 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
dismissChallenge = dismissChallenge, dismissChallenge = dismissChallenge,
) )
}, },
combine(
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.timerVibrate),
store.flow(ClockPrefs.timerVolumeRampSeconds),
) { timerRingtoneUri, timerVibrate, timerVolumeRampSeconds ->
Triple(timerRingtoneUri, timerVibrate, timerVolumeRampSeconds)
},
store.flow(ClockPrefs.alarmRingtoneUri), store.flow(ClockPrefs.alarmRingtoneUri),
store.flow(ClockPrefs.timerRingtoneUri),
store.flow(ClockPrefs.homeZoneId), store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId -> ) { partial, (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), alarmRingtoneUri, homeZoneId ->
partial.copy( partial.copy(
alarmRingtoneUri = alarmRingtoneUri, alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri, timerRingtoneUri = timerRingtoneUri,
timerVibrate = timerVibrate,
timerVolumeRampSeconds = timerVolumeRampSeconds,
homeZoneId = homeZoneId, homeZoneId = homeZoneId,
) )
}.distinctUntilChanged() }.distinctUntilChanged()
@@ -90,6 +98,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds
prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri
prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri prefs[ClockPrefs.timerRingtoneUri] = backup.defaults.timerRingtoneUri
prefs[ClockPrefs.timerVibrate] = backup.defaults.timerVibrate
prefs[ClockPrefs.timerVolumeRampSeconds] = backup.defaults.timerVolumeRampSeconds
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[TimerPrefs.presets] = backup.timerPresets prefs[TimerPrefs.presets] = backup.timerPresets
@@ -108,6 +118,10 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri) suspend fun setTimerRingtoneUri(uri: String?) = store.set(ClockPrefs.timerRingtoneUri, uri)
suspend fun setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled)
suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds)
suspend fun setDismissChallenge(challenge: DismissChallenge) = suspend fun setDismissChallenge(challenge: DismissChallenge) =
store.set(ClockPrefs.dismissChallenge, challenge) store.set(ClockPrefs.dismissChallenge, challenge)
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import de.jeanlucmakiola.floret.prefs.set
/**
* Gives an install from before #22 a timer-vibrate setting that keeps its
* timers doing what they did: until then a timer vibrated exactly when the
* alarm default did, so that is the value written. A fresh install — an empty
* store, since anything that has run once has at least written its boot id —
* gets the setting's own default, off.
*
* The value is always written, the default included, so this runs once: left
* absent on a fresh install, the next launch would see a non-empty store and
* copy the alarm default after all.
*/
internal object TimerVibrateMigration : DataMigration<Preferences> {
private val timerVibrateKey = booleanPreferencesKey(ClockPrefs.timerVibrate.name)
private val alarmVibrateKey = booleanPreferencesKey(ClockPrefs.vibrate.name)
override suspend fun shouldMigrate(currentData: Preferences): Boolean = timerVibrateKey !in currentData
override suspend fun migrate(currentData: Preferences): Preferences {
val inherited = if (currentData.asMap().isEmpty()) {
ClockPrefs.timerVibrate.default
} else {
currentData[alarmVibrateKey] ?: ClockPrefs.vibrate.default
}
return currentData.toMutablePreferences().apply { this[ClockPrefs.timerVibrate] = inherited }
}
override suspend fun cleanUp() = Unit
}
@@ -3,6 +3,11 @@ package de.jeanlucmakiola.clockula.domain
/** /**
* The app-wide defaults an alarm or timer inherits when it has no override. * The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen. * Backed by DataStore, edited in M10's settings screen.
*
* [vibrate] and [volumeRampSeconds] are the alarms'; timers have their own
* [timerVibrate] and [timerVolumeRampSeconds], because a kitchen timer that
* should just beep is not an alarm that should wake someone. Their defaults
* are a fresh install's: no vibration, no ramp.
*/ */
data class ClockDefaults( data class ClockDefaults(
val snoozeMinutes: Int = 10, val snoozeMinutes: Int = 10,
@@ -11,6 +16,8 @@ data class ClockDefaults(
val volumeRampSeconds: Int = 15, val volumeRampSeconds: Int = 15,
val alarmRingtoneUri: String? = null, val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null, val timerRingtoneUri: String? = null,
val timerVibrate: Boolean = false,
val timerVolumeRampSeconds: Int = 0,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val homeZoneId: String? = null, val homeZoneId: String? = null,
) )
@@ -6,7 +6,8 @@ import kotlin.time.Duration.Companion.minutes
/** /**
* The timer ring's constants. Values rather than settings: v1 does not offer * The timer ring's constants. Values rather than settings: v1 does not offer
* them to the user (docs/PLAN.md §5, slice plan D11). * them to the user (docs/PLAN.md §5, slice plan D11). The ramp was one of them
* until it became `ClockDefaults.timerVolumeRampSeconds` (#22).
*/ */
object TimerRing { object TimerRing {
/** /**
@@ -19,9 +20,6 @@ object TimerRing {
/** What "+1 min" grants (D12). */ /** What "+1 min" grants (D12). */
val ADD_TIME: Duration = 1.minutes val ADD_TIME: Duration = 1.minutes
/** A timer's ring does not ramp: it is set by someone awake (D11). */
const val RAMP_SECONDS: Int = 0
/** The floor under every `delay` the service takes, so a rounding error cannot spin it. */ /** The floor under every `delay` the service takes, so a rounding error cannot spin it. */
val WAKE_FLOOR: Duration = 100.milliseconds val WAKE_FLOOR: Duration = 100.milliseconds
} }
@@ -12,11 +12,11 @@ data class TimerSettings(
/** /**
* The ringtone is the timer's own when it has one, the app default otherwise; * The ringtone is the timer's own when it has one, the app default otherwise;
* vibration is app-wide, and the ramp is always zero — a timer's ring does not * vibration and the ramp are the timers' own app-wide settings, never the
* ramp, so the ramp is not a setting to inherit (D11, D27). * alarms' (D27, #22). Neither has a per-timer override.
*/ */
fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings( fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings(
ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri, ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri,
vibrate = defaults.vibrate, vibrate = defaults.timerVibrate,
rampSeconds = TimerRing.RAMP_SECONDS, rampSeconds = defaults.timerVolumeRampSeconds,
) )
@@ -222,7 +222,7 @@ private fun SettingsHub(
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_section_timers), title = stringResource(R.string.settings_section_timers),
summary = stringResource(R.string.settings_timers_sound_subtitle), summary = stringResource(R.string.settings_timers_subtitle),
position = Position.Middle, position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) }, leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Timers) }, onClick = { onOpenSection(SettingsSection.Timers) },
@@ -142,6 +142,10 @@ class SettingsViewModel @Inject internal constructor(
fun onDismissChallengeChanged(value: DismissChallenge) = fun onDismissChallengeChanged(value: DismissChallenge) =
viewModelScope.launch { settings.setDismissChallenge(value) } viewModelScope.launch { settings.setDismissChallenge(value) }
fun onTimerVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setTimerVibrate(value) }
fun onTimerVolumeRampChanged(value: Int) = viewModelScope.launch { settings.setTimerVolumeRampSeconds(value) }
// --- the default ringtone pickers --- // --- the default ringtone pickers ---
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) { internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
@@ -2,31 +2,77 @@ package de.jeanlucmakiola.clockula.ui.settings
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker
import de.jeanlucmakiola.clockula.ui.alarms.volumeRampText
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
/** What a new timer starts with: its sound. */ /**
* How timers ring: the sound a new timer starts with, and the vibration and
* gradual volume every timer rings with. The latter two are the timers' own,
* not the alarms' (#22). The ringtone picker itself is ViewModel state, drawn
* by the hub.
*/
@Composable @Composable
internal fun TimerDefaultsScreen( internal fun TimerDefaultsScreen(
state: SettingsUiState.Loaded, state: SettingsUiState.Loaded,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val defaults = state.defaults
var showRampPicker by rememberSaveable { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) { CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) {
SettingsHint(stringResource(R.string.settings_timer_sound_hint)) SettingsHint(stringResource(R.string.settings_timer_defaults_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
GroupedRow( GroupedRow(
title = stringResource(R.string.editor_ringtone), title = stringResource(R.string.editor_ringtone),
summary = ringtoneSummary(state.defaults.timerRingtoneUri), summary = ringtoneSummary(defaults.timerRingtoneUri),
position = Position.Alone, position = Position.Top,
onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) }, onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) },
) )
// A switch, not the alarm screen's picker: there is no per-timer
// override for it to pick "app default" against. The summary says what
// the ring does with Silent and vibration both off — vibrates anyway.
GroupedRow(
title = stringResource(R.string.editor_vibrate),
summary = stringResource(R.string.settings_timer_vibrate_hint),
position = Position.Middle,
trailing = {
Switch(
checked = defaults.timerVibrate,
onCheckedChange = viewModel::onTimerVibrateChanged,
)
},
onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) },
)
GroupedRow(
title = stringResource(R.string.editor_volume_ramp),
summary = volumeRampText(defaults.timerVolumeRampSeconds),
position = Position.Bottom,
onClick = { showRampPicker = true },
)
}
if (showRampPicker) {
VolumeRampPicker(
stored = defaults.timerVolumeRampSeconds,
default = defaults.timerVolumeRampSeconds,
includeAppDefault = false,
onSelect = { viewModel.onTimerVolumeRampChanged(it ?: defaults.timerVolumeRampSeconds) },
onDismiss = { showRampPicker = false },
)
} }
} }
+3 -2
View File
@@ -298,8 +298,9 @@
<string name="settings_alarms_subtitle">Sound, snooze and dismiss for new alarms</string> <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_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_section_timers">Timers</string>
<string name="settings_timers_sound_subtitle">Sound for new timers</string> <string name="settings_timers_subtitle">Sound, vibration and gradual volume</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_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
<string name="settings_timer_vibrate_hint">A timer set to silent vibrates either way</string>
<string name="settings_section_clock">Clock</string> <string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone</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> <string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
@@ -58,6 +58,9 @@ class BackupCodecTest {
volumeRampSeconds = 20, volumeRampSeconds = 20,
alarmRingtoneUri = "content://media/1", alarmRingtoneUri = "content://media/1",
timerRingtoneUri = "content://media/2", timerRingtoneUri = "content://media/2",
// Unlike the alarms' own, so a codec that crossed them is caught.
timerVibrate = true,
timerVolumeRampSeconds = 10,
dismissChallenge = DismissChallenge.HOLD, dismissChallenge = DismissChallenge.HOLD,
homeZoneId = "America/New_York", homeZoneId = "America/New_York",
), ),
@@ -131,9 +134,40 @@ class BackupCodecTest {
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java) assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
val defaults = (decoded as BackupResult.Success).content.settings.defaults val defaults = (decoded as BackupResult.Success).content.settings.defaults
assertThat(defaults).isEqualTo(ClockDefaults(snoozeMinutes = 7)) // Timers vibrated by the alarm default then, which defaults to on.
assertThat(defaults).isEqualTo(ClockDefaults(snoozeMinutes = 7, timerVibrate = true))
} }
@Test
fun `a file from before timers had their own vibration restores the alarm default for them`() {
val on = BackupCodec.decode(settingsOnly(""""default_vibrate": true"""))
val off = BackupCodec.decode(settingsOnly(""""default_vibrate": false"""))
assertThat(on.defaults().timerVibrate).isTrue()
assertThat(off.defaults().timerVibrate).isFalse()
}
@Test
fun `a file from before timers could ramp restores an unramped timer ring`() {
val decoded = BackupCodec.decode(settingsOnly(""""default_volume_ramp_seconds": 30"""))
assertThat(decoded.defaults().volumeRampSeconds).isEqualTo(30)
assertThat(decoded.defaults().timerVolumeRampSeconds).isEqualTo(0)
}
@Test
fun `the timers' own vibration wins over the alarm default when the file has both`() {
val decoded = BackupCodec.decode(settingsOnly(""""default_vibrate": true, "default_timer_vibrate": false"""))
assertThat(decoded.defaults().vibrate).isTrue()
assertThat(decoded.defaults().timerVibrate).isFalse()
}
private fun settingsOnly(settings: String): String =
"""{ "format": "clockula-backup", "version": 1, "exportedAt": 0, "settings": { $settings } }"""
private fun BackupResult.defaults(): ClockDefaults = (this as BackupResult.Success).content.settings.defaults
@Test @Test
fun `the retired default timer duration is no longer written`() { fun `the retired default timer duration is no longer written`() {
assertThat(BackupCodec.encode(sampleContent())).doesNotContain("default_timer_duration_millis") assertThat(BackupCodec.encode(sampleContent())).doesNotContain("default_timer_duration_millis")
@@ -74,7 +74,13 @@ class SettingsPrefsTest {
BackupSettings( BackupSettings(
themeMode = ThemeMode.DARK, themeMode = ThemeMode.DARK,
dynamicColor = false, dynamicColor = false,
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "), defaults = ClockDefaults(
snoozeMinutes = 99,
volumeRampSeconds = 99,
alarmRingtoneUri = " ",
timerVibrate = true,
timerVolumeRampSeconds = 99,
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
), ),
) )
@@ -84,6 +90,8 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60) assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60)
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60) assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull() assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
assertThat(prefs.currentDefaults().timerVibrate).isTrue()
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
} }
@@ -143,6 +151,33 @@ class SettingsPrefsTest {
} }
} }
// --- #22: the timers' own vibration and ramp ---
@Test
fun `the timer settings are their own, apart from the alarm ones`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setVibrate(true)
prefs.setVolumeRampSeconds(30)
prefs.setTimerVibrate(false)
prefs.setTimerVolumeRampSeconds(10)
val defaults = prefs.currentDefaults()
assertThat(defaults.vibrate).isTrue()
assertThat(defaults.volumeRampSeconds).isEqualTo(30)
assertThat(defaults.timerVibrate).isFalse()
assertThat(defaults.timerVolumeRampSeconds).isEqualTo(10)
}
@Test
fun `the timer ramp is clamped like the alarm ramp`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setTimerVolumeRampSeconds(-5)
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(0)
}
// --- M4: the one-shot notification permission request --- // --- M4: the one-shot notification permission request ---
@Test @Test
@@ -0,0 +1,110 @@
package de.jeanlucmakiola.clockula.data.prefs
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.di.createDataStore
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.job
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.io.File
import java.nio.file.Path
/**
* #22's upgrade path, through the store `DataModule` builds: an install from
* before timers had their own vibration keeps vibrating its timers exactly
* when it did, and a fresh one starts with it off.
*/
class TimerVibrateMigrationTest {
private fun TestScope.newScope(): CoroutineScope =
CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job())
private fun file(tempDir: Path): File = tempDir.resolve("clockula_prefs.preferences_pb").toFile()
/** The file as a version before #22 left it: no migration, only the keys it knew. */
private suspend fun TestScope.writeOldInstall(tempDir: Path, alarmVibrate: Boolean?) {
val scope = newScope()
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file(tempDir) }).edit { prefs ->
prefs[stringPreferencesKey("last_boot_id")] = "boot"
if (alarmVibrate != null) prefs[booleanPreferencesKey("default_vibrate")] = alarmVibrate
}
// DataStore refuses a second store over a file a live one holds.
scope.coroutineContext.job.cancelAndJoin()
}
private suspend fun TestScope.openPrefs(tempDir: Path, scope: CoroutineScope = newScope()): SettingsPrefs =
SettingsPrefs(PrefStore(createDataStore(scope = scope, produceFile = { file(tempDir) })))
@Test
fun `a fresh install does not vibrate its timers`(@TempDir tempDir: Path) = runTest {
val prefs = openPrefs(tempDir)
assertThat(prefs.currentDefaults().timerVibrate).isFalse()
assertThat(prefs.currentDefaults().vibrate).isTrue()
}
@Test
fun `a fresh install stays off on the next launch, once the store is no longer empty`(
@TempDir tempDir: Path,
) = runTest {
val firstLaunch = newScope()
openPrefs(tempDir, firstLaunch).setSnoozeMinutes(7)
firstLaunch.coroutineContext.job.cancelAndJoin()
val prefs = openPrefs(tempDir)
assertThat(prefs.currentDefaults().timerVibrate).isFalse()
}
@Test
fun `an older install with alarm vibration on keeps vibrating its timers`(@TempDir tempDir: Path) = runTest {
writeOldInstall(tempDir, alarmVibrate = true)
assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue()
}
@Test
fun `an older install that never touched alarm vibration keeps vibrating its timers`(
@TempDir tempDir: Path,
) = runTest {
writeOldInstall(tempDir, alarmVibrate = null)
assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue()
}
@Test
fun `an older install with alarm vibration off keeps its timers still`(@TempDir tempDir: Path) = runTest {
writeOldInstall(tempDir, alarmVibrate = false)
assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isFalse()
}
@Test
fun `once migrated, the two settings move independently`(@TempDir tempDir: Path) = runTest {
writeOldInstall(tempDir, alarmVibrate = true)
val prefs = openPrefs(tempDir)
prefs.setVibrate(false)
assertThat(prefs.currentDefaults().timerVibrate).isTrue()
}
@Test
fun `a choice the user made is never overwritten`(@TempDir tempDir: Path) = runTest {
val firstLaunch = newScope()
openPrefs(tempDir, firstLaunch).setTimerVibrate(true)
firstLaunch.coroutineContext.job.cancelAndJoin()
assertThat(openPrefs(tempDir).currentDefaults().timerVibrate).isTrue()
}
}
@@ -82,20 +82,24 @@ class TimerRingPolicyTest {
assertThat(outcome.sound().settings.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) assertThat(outcome.sound().settings.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
} }
/** §5.4 #6 */ /** §5.4 #6, #22: the timers' own vibration setting, never the alarms'. */
@Test @Test
fun `vibration comes from the app default`() { fun `vibration comes from the timer default, not the alarm default`() {
val outcome = decide(defaults = defaults.copy(vibrate = false)) val off = decide(defaults = defaults.copy(vibrate = true, timerVibrate = false))
val on = decide(defaults = defaults.copy(vibrate = false, timerVibrate = true))
assertThat(outcome.sound().settings.vibrate).isFalse() assertThat(off.sound().settings.vibrate).isFalse()
assertThat(on.sound().settings.vibrate).isTrue()
} }
/** §5.4 #7 */ /** §5.4 #7, #22: the timers' own ramp, never the alarms'. */
@Test @Test
fun `a timer's ring does not ramp`() { fun `the ramp comes from the timer default, not the alarm default`() {
val outcome = decide(defaults = defaults.copy(volumeRampSeconds = 30)) val flat = decide(defaults = defaults.copy(volumeRampSeconds = 30, timerVolumeRampSeconds = 0))
val ramped = decide(defaults = defaults.copy(volumeRampSeconds = 0, timerVolumeRampSeconds = 20))
assertThat(outcome.sound().settings.rampSeconds).isEqualTo(TimerRing.RAMP_SECONDS) assertThat(flat.sound().settings.rampSeconds).isEqualTo(0)
assertThat(ramped.sound().settings.rampSeconds).isEqualTo(20)
} }
/** §5.4 #8 */ /** §5.4 #8 */
+24 -10
View File
@@ -595,7 +595,7 @@ Four slices:
| Slice | Keys | Owner | | Slice | Keys | Owner |
|---|---|---| |---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names | | 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_dismiss_challenge`, `home_zone_id` | `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` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| 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` | | 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 | | System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` | | Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
@@ -1062,7 +1062,7 @@ M10 adds no `<uses-permission>`.
| Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 | | Per-timer vibrate, volume-ramp or dismiss-challenge overrides. `timers` has exactly one nullable settings column, its ringtone; four more would be a schema change for settings the roadmap does not ask for | not planned for v1 |
| A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 | | A "stop all" for several expired timers, and undo after a timer delete. Each expired timer is acknowledged on its own, and the delete confirmation asks before rather than after | not planned for v1 |
| A presets management screen (add/remove the quick-start durations themselves). M10's settings screen edits `default_timer_ringtone_uri`, but the preset list is still only editable from the two-gesture surface on the setup panel | not planned for v1 | | A presets management screen (add/remove the quick-start durations themselves). M10's settings screen edits `default_timer_ringtone_uri`, but the preset list is still only editable from the two-gesture surface on the setup panel | not planned for v1 |
| A timer that ramps its volume, a dismiss challenge or a snooze for a timer | not planned for v1 | | A dismiss challenge or a snooze for a timer. (A timer that ramps its volume landed with #22, as an app-wide timer setting.) | not planned for v1 |
| Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 | | Surfacing `TimerSnapshot.anchorIsStale` in the UI. The boot repair makes it vanishingly rare and the value is already honest; a row does not say "estimated" in v1 | not planned for v1 |
| Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 | | Lap export, sharing, deletion or editing, and any "previous run" history. A run's laps belong to the run that produced them; keeping old runs is a different feature with its own table | not planned for v1 |
| A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 | | A Lap button on the live pill. The pill has two controls by design, and a third would rewrite a contract M4 asserted | not planned for v1 |
@@ -1282,8 +1282,8 @@ loop, the ramp stepper, the looped waveform, `AudioSourcePolicy`,
and are used verbatim by both, so "silent" still means "vibration only" for a and are used verbatim by both, so "silent" still means "vibration only" for a
timer too. What the alarm **keeps to itself** is the full-screen intent, the timer too. What the alarm **keeps to itself** is the full-screen intent, the
ring activity, the dismiss challenge and the snooze; what the timer ring activity, the dismiss challenge and the snooze; what the timer
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by parameterises is its own ringtone, its own vibration and ramp settings (#22;
someone awake, and a gentle start is just a quiet start) and its own the ramp is **zero seconds** unless the user asks for one) and its own
auto-silence constant. §14 has the rest, including which of the two wins the auto-silence constant. §14 has the rest, including which of the two wins the
one audio stream. one audio stream.
@@ -1898,11 +1898,24 @@ finished, and the user coming back must be able to see that and add a minute
to it. Auto-silence is decided **before** suppression, so an alarm ringing over to it. Auto-silence is decided **before** suppression, so an alarm ringing over
a window that has run out cannot hand it a fresh one. a window that has run out cannot hand it a fresh one.
`TimerRing.RAMP_SECONDS` is **0**. The volume ramp exists so an alarm does not A timer's ramp is `ClockDefaults.timerVolumeRampSeconds`, **0** unless the
user picks one in Settings > Timers (#22; it was the constant
`TimerRing.RAMP_SECONDS` before). The volume ramp exists so an alarm does not
jolt a sleeping person awake; a timer is set by someone awake who wants to know jolt a sleeping person awake; a timer is set by someone awake who wants to know
*now*, and for them a gentle start is a quiet start. `VolumeRamp.levelAt(_, 0s)` *now*, and for most of them a gentle start is a quiet start, so the default
already returns `1f`, so "parameterised" means one different argument to the stays flat. `VolumeRamp.levelAt(_, 0s)` already returns `1f`, so
same player. "parameterised" means one different argument to the same player.
Vibration is `ClockDefaults.timerVibrate`, never the alarms' `vibrate`: an
alarm that should buzz the bed and a kitchen timer that should only beep are
different questions. A fresh install has it **off**. An install from before
the setting existed had its timers vibrate exactly when the alarm default did,
so `TimerVibrateMigration` (a DataStore migration, run before the first read)
writes that value once rather than silently changing what its timers do; it
tells a fresh install by an empty store, since anything that has run has
written its boot id. Silent with vibration off is still not silence:
`AudioSourcePolicy` gives Silent no sources, and the player's `onSilenced`
starts the vibrator regardless of the setting.
### The notification: one channel, one id, alert once ### The notification: one channel, one id, alert once
@@ -2060,8 +2073,9 @@ ever read it, so it was removed. A backup file that still carries the key
imports with it ignored.) imports with it ignored.)
A timer has exactly **one** per-setting override, its ringtone, because `timers` A timer has exactly **one** per-setting override, its ringtone, because `timers`
has exactly one nullable settings column. Vibrate and the (zero) ramp come from has exactly one nullable settings column. Vibrate and the ramp come from
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits `ClockDefaults`' timer-only `timerVibrate` and `timerVolumeRampSeconds` (#22),
not the alarm defaults. The picker is M5's, moved to `ui/common/`, so the row inherits
all of its behaviour for free: "App default" naming what it resolves to, Silent all of its behaviour for free: "App default" naming what it resolves to, Silent
as an explicit sentinel that forces vibration, a SAF-picked file that is still as an explicit sentinel that forces vibration, a SAF-picked file that is still
stored when its grant cannot be made persistent, and an unreadable sound stored when its grant cannot be made persistent, and an unreadable sound
+6
View File
@@ -42,6 +42,10 @@ written here.
are clamped at parse time to the same ranges as the defaults they are clamped at parse time to the same ranges as the defaults they
override, and a timer's `duration_millis` to the setup range (1 second to override, and a timer's `duration_millis` to the setup range (1 second to
24 hours). Room has no clamp of its own, so this is where it happens. 24 hours). Room has no clamp of its own, so this is where it happens.
- A missing `default_timer_vibrate` (a file from before timers had their own
vibration setting) restores `default_vibrate` in its place: that is what
the exporting install's timers vibrated by. A missing
`default_timer_volume_ramp_seconds` restores 0, the ring timers had then.
- A missing `timer_presets` keeps the built-in presets. An empty list is a - A missing `timer_presets` keeps the built-in presets. An empty list is a
user who removed them all, and is imported as such. user who removed them all, and is imported as such.
- An enum field with a name this version does not recognise (a `dismiss_ - An enum field with a name this version does not recognise (a `dismiss_
@@ -134,6 +138,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_volume_ramp_seconds": 15, "default_volume_ramp_seconds": 15,
"default_alarm_ringtone_uri": null, "default_alarm_ringtone_uri": null,
"default_timer_ringtone_uri": null, "default_timer_ringtone_uri": null,
"default_timer_vibrate": false,
"default_timer_volume_ramp_seconds": 0,
"default_dismiss_challenge": "NONE", "default_dismiss_challenge": "NONE",
"home_zone_id": null, "home_zone_id": null,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000] "timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
+2 -1
View File
@@ -316,7 +316,8 @@ Elapsed-realtime anchored.
per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has per-timer vibrate, volume-ramp or dismiss-challenge overrides (`timers` has
one nullable settings column and four more would be a schema change), no one nullable settings column and four more would be a schema change), no
"stop all" when several timers are expired, no undo after a delete (traded for "stop all" when several timers are expired, no undo after a delete (traded for
a confirmation, as with alarms), no ramp on a timer's ring, no presets a confirmation, as with alarms), no ramp on a timer's ring (since added as
an app-wide timer setting, #22), no presets
management screen and no editing of `default_timer_duration_millis` or management screen and no editing of `default_timer_duration_millis` or
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale` `default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the