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

@@ -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 */