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:
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user