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