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
+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.
- **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
@@ -125,6 +125,8 @@ object BackupCodec {
volumeRampSeconds = defaults.volumeRampSeconds,
alarmRingtoneUri = defaults.alarmRingtoneUri,
timerRingtoneUri = defaults.timerRingtoneUri,
timerVibrate = defaults.timerVibrate,
timerVolumeRampSeconds = defaults.timerVolumeRampSeconds,
dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
@@ -231,6 +233,8 @@ object BackupCodec {
volumeRampSeconds = settingsDto.volumeRampSeconds,
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate,
timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds,
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
),
@@ -69,6 +69,12 @@ data class SettingsDto(
@SerialName("default_volume_ramp_seconds") val volumeRampSeconds: Int = 15,
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: 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("home_zone_id") val homeZoneId: String? = null,
/**
@@ -12,6 +12,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.prefs.TimerVibrateMigration
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineDispatcher
@@ -62,12 +63,15 @@ object DataModule {
* theme catches it. Replacing an unreadable file with empty preferences
* 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.
*
* Migrations run before the first read, so nothing ever sees a key they fill.
*/
internal fun createDataStore(
scope: CoroutineScope,
produceFile: () -> File,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
migrations = listOf(TimerVibrateMigration),
scope = scope,
produceFile = produceFile,
)
@@ -28,6 +28,16 @@ object ClockPrefs {
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> =
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
@@ -62,13 +62,21 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
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.timerRingtoneUri),
store.flow(ClockPrefs.homeZoneId),
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
) { partial, (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), alarmRingtoneUri, homeZoneId ->
partial.copy(
alarmRingtoneUri = alarmRingtoneUri,
timerRingtoneUri = timerRingtoneUri,
timerVibrate = timerVibrate,
timerVolumeRampSeconds = timerVolumeRampSeconds,
homeZoneId = homeZoneId,
)
}.distinctUntilChanged()
@@ -90,6 +98,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.volumeRampSeconds] = backup.defaults.volumeRampSeconds
prefs[ClockPrefs.alarmRingtoneUri] = backup.defaults.alarmRingtoneUri
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.homeZoneId] = backup.defaults.homeZoneId
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 setTimerVibrate(enabled: Boolean) = store.set(ClockPrefs.timerVibrate, enabled)
suspend fun setTimerVolumeRampSeconds(seconds: Int) = store.set(ClockPrefs.timerVolumeRampSeconds, seconds)
suspend fun setDismissChallenge(challenge: DismissChallenge) =
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.
* 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(
val snoozeMinutes: Int = 10,
@@ -11,6 +16,8 @@ data class ClockDefaults(
val volumeRampSeconds: Int = 15,
val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null,
val timerVibrate: Boolean = false,
val timerVolumeRampSeconds: Int = 0,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
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
* 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 {
/**
@@ -19,9 +20,6 @@ object TimerRing {
/** What "+1 min" grants (D12). */
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. */
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;
* vibration is app-wide, and the ramp is always zero — a timer's ring does not
* ramp, so the ramp is not a setting to inherit (D11, D27).
* vibration and the ramp are the timers' own app-wide settings, never the
* alarms' (D27, #22). Neither has a per-timer override.
*/
fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings(
ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri,
vibrate = defaults.vibrate,
rampSeconds = TimerRing.RAMP_SECONDS,
vibrate = defaults.timerVibrate,
rampSeconds = defaults.timerVolumeRampSeconds,
)
@@ -222,7 +222,7 @@ private fun SettingsHub(
)
GroupedRow(
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,
leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.Timers) },
@@ -142,6 +142,10 @@ class SettingsViewModel @Inject internal constructor(
fun onDismissChallengeChanged(value: DismissChallenge) =
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 ---
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.height
import androidx.compose.material3.Switch
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.stringResource
import androidx.compose.ui.unit.dp
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.GroupedRow
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
internal fun TimerDefaultsScreen(
state: SettingsUiState.Loaded,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val defaults = state.defaults
var showRampPicker by rememberSaveable { mutableStateOf(false) }
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))
GroupedRow(
title = stringResource(R.string.editor_ringtone),
summary = ringtoneSummary(state.defaults.timerRingtoneUri),
position = Position.Alone,
summary = ringtoneSummary(defaults.timerRingtoneUri),
position = Position.Top,
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_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_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_timers_subtitle">Sound, vibration and gradual volume</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_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,6 +58,9 @@ class BackupCodecTest {
volumeRampSeconds = 20,
alarmRingtoneUri = "content://media/1",
timerRingtoneUri = "content://media/2",
// Unlike the alarms' own, so a codec that crossed them is caught.
timerVibrate = true,
timerVolumeRampSeconds = 10,
dismissChallenge = DismissChallenge.HOLD,
homeZoneId = "America/New_York",
),
@@ -131,9 +134,40 @@ class BackupCodecTest {
assertThat(decoded).isInstanceOf(BackupResult.Success::class.java)
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
fun `the retired default timer duration is no longer written`() {
assertThat(BackupCodec.encode(sampleContent())).doesNotContain("default_timer_duration_millis")
@@ -74,7 +74,13 @@ class SettingsPrefsTest {
BackupSettings(
themeMode = ThemeMode.DARK,
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),
),
)
@@ -84,6 +90,8 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60)
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
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()
}
@@ -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 ---
@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)
}
/** §5.4 #6 */
/** §5.4 #6, #22: the timers' own vibration setting, never the alarms'. */
@Test
fun `vibration comes from the app default`() {
val outcome = decide(defaults = defaults.copy(vibrate = false))
fun `vibration comes from the timer default, not the alarm default`() {
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
fun `a timer's ring does not ramp`() {
val outcome = decide(defaults = defaults.copy(volumeRampSeconds = 30))
fun `the ramp comes from the timer default, not the alarm default`() {
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 */
+24 -10
View File
@@ -595,7 +595,7 @@ Four 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_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` |
| 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` |
@@ -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 |
| 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 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 |
| 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 |
@@ -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
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
parameterises is its own ringtone, a ramp of **zero seconds** (a timer is set by
someone awake, and a gentle start is just a quiet start) and its own
parameterises is its own ringtone, its own vibration and ramp settings (#22;
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
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
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
*now*, and for them a gentle start is a quiet start. `VolumeRamp.levelAt(_, 0s)`
already returns `1f`, so "parameterised" means one different argument to the
same player.
*now*, and for most of them a gentle start is a quiet start, so the default
stays flat. `VolumeRamp.levelAt(_, 0s)` already returns `1f`, so
"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
@@ -2060,8 +2073,9 @@ ever read it, so it was removed. A backup file that still carries the key
imports with it ignored.)
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
`ClockDefaults`. The picker is M5's, moved to `ui/common/`, so the row inherits
has exactly one nullable settings column. Vibrate and the ramp come from
`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
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
+6
View File
@@ -42,6 +42,10 @@ written here.
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
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
user who removed them all, and is imported as such.
- 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_alarm_ringtone_uri": null,
"default_timer_ringtone_uri": null,
"default_timer_vibrate": false,
"default_timer_volume_ramp_seconds": 0,
"default_dismiss_challenge": "NONE",
"home_zone_id": null,
"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
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
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
`default_timer_ringtone_uri` (M10's settings screen). `TimerSnapshot.anchorIsStale`
is still not surfaced in the UI. Of `AlarmClock`'s intent contract only the