diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..c887e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index c6daf96..8aad668 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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), ), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 118e99e..4e1a91f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -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, /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt index dcbd3c6..707addc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DataModule.kt @@ -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 = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + migrations = listOf(TimerVibrateMigration), scope = scope, produceFile = produceFile, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 0cb43c1..8587aaf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -28,6 +28,16 @@ object ClockPrefs { val timerRingtoneUri: Pref = 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 = booleanPref("default_timer_vibrate", false) + + val timerVolumeRampSeconds: Pref = + intPref("default_timer_volume_ramp_seconds", 0).clampedTo(ClockLimits.VOLUME_RAMP_SECONDS) + val dismissChallenge: Pref = enumPref("default_dismiss_challenge", DismissChallenge.NONE) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..1d13e6e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt new file mode 100644 index 0000000..b31291c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigration.kt @@ -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 { + + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 73499b3..ef8fc73 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt index 7d0d82e..e0c905d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt index 6c3ff0c..91833b2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt index eaeda0d..654054e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -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) }, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 7d90968..1475ac6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt index cc68b9a..b3f25d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -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 }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..c9117ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -298,8 +298,9 @@ Sound, snooze and dismiss for new alarms New alarms start with these. Each alarm can still change them in its editor. Timers - Sound for new timers - New timers ring with this sound. Each timer can still change it in its editor. + Sound, vibration and gradual volume + How timers ring. A timer can still pick its own sound in its editor. + A timer set to silent vibrates either way Clock Home time zone World clocks show how far each city is from your home time zone. diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index db57734..04f7cc2 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..1e0a018 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt new file mode 100644 index 0000000..ba2832e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/TimerVibrateMigrationTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt index d95ecc1..43ae2ab 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt @@ -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 */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..0366fa3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | +| 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` | | 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 ``. | 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 diff --git a/docs/BACKUP.md b/docs/BACKUP.md index e8536de..8126f1e 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..33a8bc7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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