diff --git a/CHANGELOG.md b/CHANGELOG.md index 926a551..23c563a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ 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 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. Timer vibration starts out matching + the alarm vibration setting, so nothing changes until you change it. - **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/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 08a786e..22c64d3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -7,6 +7,7 @@ import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications import de.jeanlucmakiola.clockula.timer.TimerEngine @@ -15,6 +16,7 @@ import de.jeanlucmakiola.clockula.widget.WidgetSync import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.di.ApplicationScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -47,6 +49,9 @@ class ClockulaApp : Application() { @Inject lateinit var widgetSync: WidgetSync + @Inject + lateinit var settings: SettingsPrefs + @Inject @ApplicationScope lateinit var scope: CoroutineScope @@ -89,6 +94,19 @@ class ClockulaApp : Application() { // Never completes on its own — it is the widgets' one refresh trigger // for as long as the process lives (M10, docs/PLAN.md §9). scope.launch { widgetSync.run() } + + scope.launch { settleTimerVibrate() } + } + + /** Off the engines' pass: a preference copy has no business delaying an alarm. */ + private suspend fun settleTimerVibrate() { + try { + settings.settleTimerVibrate() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // Until it lands timers vibrate, the default; the next launch tries again. + } } private suspend fun launchPasses() { 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 d3d01ea..f9a9628 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 @@ -136,6 +136,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, alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes, @@ -289,6 +291,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), // Clamped on apply, by the preference, like every other default. 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 a4d6c69..6b2cede 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 @@ -78,6 +78,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, @SerialName("volume_buttons_while_ringing") val volumeButtons: String = "CONTROL_VOLUME", 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 460a039..04d5ce0 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 @@ -30,6 +30,17 @@ object ClockPrefs { val timerRingtoneUri: Pref = nullableStringPref("default_timer_ringtone_uri").blankAsAbsent() + /** + * The timers' own vibration (#22). Until it existed a timer vibrated + * exactly when [vibrate] said so; `SettingsPrefs.settleTimerVibrate` copies + * that over once, so nobody's timers change on update. The default is + * [vibrate]'s, for the moment before that lands. + */ + val timerVibrate: Pref = booleanPref("default_timer_vibrate", true) + + 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 0d9ddf0..1d949d8 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 @@ -1,6 +1,7 @@ package de.jeanlucmakiola.clockula.data.prefs import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.booleanPreferencesKey import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction @@ -71,13 +72,21 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.flow(ClockPrefs.timerSilenceAfterMinutes), store.flow(ClockPrefs.volumeButtons), ) { alarmMinutes, timerMinutes, volumeButtons -> Triple(alarmMinutes, timerMinutes, volumeButtons) }, + 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, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), alarmRingtoneUri, timerRingtoneUri, homeZoneId -> + ) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), (timerRingtoneUri, timerVibrate, timerVolumeRampSeconds), alarmRingtoneUri, homeZoneId -> partial.copy( alarmRingtoneUri = alarmRingtoneUri, timerRingtoneUri = timerRingtoneUri, + timerVibrate = timerVibrate, + timerVolumeRampSeconds = timerVolumeRampSeconds, homeZoneId = homeZoneId, alarmSilenceAfterMinutes = alarmSilenceAfter, timerSilenceAfterMinutes = timerSilenceAfter, @@ -102,6 +111,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[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes @@ -125,6 +136,25 @@ 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) + + /** + * Gives the timers' vibration a value of its own, once (#22): the alarm + * default, which is what every timer vibrated by until then. From here on + * the two move apart. A no-op once the key exists. + */ + suspend fun settleTimerVibrate() { + store.edit { prefs -> + // Raw keys for the reads: the kit reads a Pref only from a store, not + // from a transaction's preferences. Both are plain boolean prefs. + if (booleanPreferencesKey(ClockPrefs.timerVibrate.name) in prefs) return@edit + prefs[ClockPrefs.timerVibrate] = + prefs[booleanPreferencesKey(ClockPrefs.vibrate.name)] ?: ClockPrefs.vibrate.default + } + } + + 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/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt index 7392f47..12d6a1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -13,6 +13,11 @@ import kotlin.time.Duration * through [alarmSilenceAfter] and [timerSilenceAfter]. [volumeButtons] has no * per-alarm override either (#18); it lives here because it is edited and * backed up with the rest of the alarm settings. + * + * [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. They start as + * timers have always rung: vibrating like the alarms' default, unramped. */ data class ClockDefaults( val snoozeMinutes: Int = 10, @@ -21,6 +26,8 @@ data class ClockDefaults( val volumeRampSeconds: Int = 15, val alarmRingtoneUri: String? = null, val timerRingtoneUri: String? = null, + val timerVibrate: Boolean = true, + val timerVolumeRampSeconds: Int = 0, val dismissChallenge: DismissChallenge = DismissChallenge.NONE, val homeZoneId: String? = null, val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES, 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 b620acc..b85d47a 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 @@ -9,15 +9,23 @@ import kotlin.time.Duration.Companion.minutes * slice plan D11). How long an unacknowledged timer sounds before the noise * stops became a setting in #17, `ClockDefaults.timerSilenceAfter`: its own * rather than the alarm's, because coupling the two would make one number - * answer two questions. + * answer two questions. The ramp was one of them until it became + * `ClockDefaults.timerVolumeRampSeconds` (#22). */ 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 + + /** + * How far into its ramp a ring is, from what is left of its [window] + * (the "Silence after" setting's, opened when the session first sounded). A ring + * restarted mid-session — handed to another expired timer, back after an + * alarm held it, or a recreated service — carries on from there rather than + * ramping again from a whisper. Zero for a session that has not sounded. + */ + fun rampElapsed(silenceIn: Duration?, window: Duration): Duration = + silenceIn?.let { (window - it).coerceAtLeast(Duration.ZERO) } ?: Duration.ZERO } 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..3c63b17 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,12 @@ 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/ring/RingAudioPlayer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt index a631089..a096520 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/RingAudioPlayer.kt @@ -72,19 +72,26 @@ class RingAudioPlayer(private val context: Context) { * * [onSilenced] is called, at most once, when that chain found no sound at * all; never after [stop]. + * + * [rampFrom] picks the ramp up partway, for a ring that is restarted rather + * than new — a timer ring handed from one timer to another, or back after an + * alarm held it — so the volume carries on rather than dropping back to a + * whisper. Past the ramp's end it starts at full volume. */ fun start( scope: CoroutineScope, ringtoneUri: String?, rampSeconds: Int, onSilenced: () -> Unit, + rampFrom: Duration = Duration.ZERO, ) { stop() val ring = generation val rampFor = rampSeconds.coerceAtLeast(0).seconds + val rampStart = rampFrom.coerceAtLeast(Duration.ZERO) scope.launch { - val audible = startMedia(ring, scope, ringtoneUri, rampFor, onSilenced) || - startPlayer(ring, scope, ringtoneUri, rampFor, onSilenced) + val audible = startMedia(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced) || + startPlayer(ring, scope, ringtoneUri, rampFor, rampStart, onSilenced) if (!audible && ring == generation) onSilenced() } } @@ -108,6 +115,7 @@ class RingAudioPlayer(private val context: Context) { scope: CoroutineScope, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, ): Boolean { val ref = MediaSources.decode(ringtoneUri) ?: return false @@ -126,7 +134,7 @@ class RingAudioPlayer(private val context: Context) { false } if (started && ring == generation) { - watchMedia(ring, scope, media, ringtoneUri, rampFor, onSilenced) + watchMedia(ring, scope, media, ringtoneUri, rampFor, rampStart, onSilenced) return true } media.stop() @@ -148,6 +156,7 @@ class RingAudioPlayer(private val context: Context) { scope: CoroutineScope, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, from: Int = 0, ): Boolean { @@ -161,7 +170,7 @@ class RingAudioPlayer(private val context: Context) { return false } requestFocus() - val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor) + val initial = VolumeRamp.levelAt(rampStart, rampFor) val started = runCatching { prepared.setVolume(initial, initial) prepared.start() @@ -180,13 +189,13 @@ class RingAudioPlayer(private val context: Context) { release(failed) abandonFocus() scope.launch { - val next = startPlayer(ring, scope, ringtoneUri, Duration.ZERO, onSilenced, index + 1) + val next = startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced, index + 1) if (!next && ring == generation) onSilenced() } } true } - ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } } + ramp = launchRamp(scope, rampFor, rampStart) { level -> runCatching { player?.setVolume(level, level) } } return true } return false @@ -205,10 +214,11 @@ class RingAudioPlayer(private val context: Context) { media: MediaAppRing, ringtoneUri: String?, rampFor: Duration, + rampStart: Duration, onSilenced: () -> Unit, ) { - media.setLevel(VolumeRamp.levelAt(Duration.ZERO, rampFor)) - ramp = launchRamp(scope, rampFor, media::setLevel) + media.setLevel(VolumeRamp.levelAt(rampStart, rampFor)) + ramp = launchRamp(scope, rampFor, rampStart, media::setLevel) watchdog = scope.launch { var stall = MediaRingPolicy.Stall() while (isActive && ring == generation) { @@ -221,14 +231,19 @@ class RingAudioPlayer(private val context: Context) { ramp = null media.stop() mediaRing = null - if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO, onSilenced) && ring == generation) onSilenced() + if (!startPlayer(ring, scope, ringtoneUri, Duration.ZERO, Duration.ZERO, onSilenced) && ring == generation) onSilenced() } } - /** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] until [rampFor] has passed. */ - private fun launchRamp(scope: CoroutineScope, rampFor: Duration, setLevel: (Float) -> Unit): Job = + /** Steps [setLevel] along [VolumeRamp] every [RingAudio.RAMP_TICK] from [from] until [rampFor] has passed. */ + private fun launchRamp( + scope: CoroutineScope, + rampFor: Duration, + from: Duration, + setLevel: (Float) -> Unit, + ): Job = scope.launch { - var elapsed = Duration.ZERO + var elapsed = from while (isActive && elapsed < rampFor) { delay(RingAudio.RAMP_TICK) elapsed += RingAudio.RAMP_TICK diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index 6c666d1..7ed553a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -17,7 +17,9 @@ import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerMode +import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision +import de.jeanlucmakiola.clockula.domain.timer.TimerSettings import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.timer.AlarmRingStatus @@ -35,6 +37,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch +import kotlin.time.Duration import kotlin.time.Instant import javax.inject.Inject @@ -92,6 +95,9 @@ class TimerService : Service() { /** Which timer the audio is currently sounding for, so an unchanged decision does nothing. */ private var sounding: Long? = null + /** What [sounding] rings with, so a settings change mid-ring is applied rather than ignored. */ + private var soundingSettings: TimerSettings? = null + /** Owned here, scoped to the ring session it was spent in: the rule itself is pure (D17). */ private var alreadyAlerted: Boolean = false @@ -190,7 +196,7 @@ class TimerService : Service() { return } - applyRing(state.ring) + applyRing(state.ring, state.silenceIn) post(notification, sounding = state.ring is TimerRingDecision.Sound) scheduleDelays(state) progressRefresh?.cancel() @@ -225,14 +231,17 @@ class TimerService : Service() { return engine.serviceState(alreadyAlerted) } - private fun applyRing(decision: TimerRingDecision) { + private fun applyRing(decision: TimerRingDecision, silenceIn: Duration?) { when (decision) { is TimerRingDecision.Sound -> { // An unchanged decision does nothing: restarting the player on - // every emission would stutter the ring. - if (sounding == decision.subjectId) return + // every emission would stutter the ring. A changed setting (the + // user switched timer vibration off mid-ring, say) restarts it, + // the ramp picking up where it was. + if (sounding == decision.subjectId && soundingSettings == decision.settings) return releaseRing() sounding = decision.subjectId + soundingSettings = decision.settings acquireWakeLock() // Vibration first, and the sound without waiting for it: a sound // another app plays can take seconds to open, and this runs @@ -244,6 +253,7 @@ class TimerService : Service() { scope = scope, ringtoneUri = decision.settings.ringtoneUri, rampSeconds = decision.settings.rampSeconds, + rampFrom = TimerRing.rampElapsed(silenceIn, window = decision.silenceAt - decision.openedAt), // Silence is not one of the outcomes: with no audio source at // all the timer vibrates even when vibration is switched off. onSilenced = vibrator::start, @@ -334,6 +344,7 @@ class TimerService : Service() { /** The whole ring released, not just the audio: an orphaned looping player cannot be stopped. */ private fun releaseRing() { sounding = null + soundingSettings = null audio.stop() vibrator.stop() wakeLock?.let { if (it.isHeld) runCatching { it.release() } } 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 aa0af56..73b884d 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 @@ -226,7 +226,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 49b19b9..e6f123e 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 @@ -158,6 +158,10 @@ class SettingsViewModel @Inject internal constructor( fun onVolumeButtonsChanged(value: VolumeButtonAction) = viewModelScope.launch { settings.setVolumeButtons(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 a1396ed..671a73f 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,6 +2,7 @@ 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 @@ -9,45 +10,99 @@ 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.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker +import de.jeanlucmakiola.clockula.ui.alarms.vibrateText +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; then how long any timer sounds (#17). */ +/** + * 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). Then how long any timer sounds (#17). 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) } var silenceAfterOpen 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. Off, the summary says + // what the ring does with Silent and vibration both off: vibrates anyway. + GroupedRow( + title = stringResource(R.string.editor_vibrate), + // The switch below is display-only, so the row carries its role and state. + modifier = Modifier.semantics { + role = Role.Switch + toggleableState = ToggleableState(defaults.timerVibrate) + }, + summary = if (defaults.timerVibrate) { + vibrateText(on = true) + } else { + stringResource(R.string.settings_timer_vibrate_off) + }, + position = Position.Middle, + trailing = { + // Display only: the row is the one control, so a screen reader + // stops once, on a row announced as a switch. + Switch(checked = defaults.timerVibrate, onCheckedChange = null) + }, + onClick = { viewModel.onTimerVibrateChanged(!defaults.timerVibrate) }, + ) + GroupedRow( + title = stringResource(R.string.editor_volume_ramp), + summary = volumeRampText(defaults.timerVolumeRampSeconds), + position = Position.Bottom, + onClick = { showRampPicker = true }, + ) Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_silence_after), - summary = silenceAfterText(state.defaults.timerSilenceAfterMinutes), + summary = silenceAfterText(defaults.timerSilenceAfterMinutes), position = Position.Alone, onClick = { silenceAfterOpen = true }, ) SettingsHint(stringResource(R.string.settings_timer_silence_after_hint)) } + if (showRampPicker) { + VolumeRampPicker( + stored = defaults.timerVolumeRampSeconds, + default = defaults.timerVolumeRampSeconds, + includeAppDefault = false, + onSelect = { viewModel.onTimerVolumeRampChanged(it ?: defaults.timerVolumeRampSeconds) }, + onDismiss = { showRampPicker = false }, + ) + } + if (silenceAfterOpen) { SilenceAfterPicker( - selected = state.defaults.timerSilenceAfterMinutes, + selected = defaults.timerSilenceAfterMinutes, onSelect = { viewModel.onTimerSilenceAfterChanged(it) }, onDismiss = { silenceAfterOpen = false }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 479a212..22a826e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,8 +346,9 @@ Dismiss Do nothing 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. + Off. A timer set to Silent still vibrates 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 745f649..6e74a0b 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 @@ -64,6 +64,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", alarmSilenceAfterMinutes = 25, @@ -153,9 +156,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/ClockPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt index f96abf2..afce3f7 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt @@ -53,6 +53,8 @@ class ClockPrefsTest { prefs.setVolumeRampSeconds(0) prefs.setAlarmRingtoneUri("content://a") prefs.setTimerRingtoneUri("content://t") + prefs.setTimerVibrate(false) + prefs.setTimerVolumeRampSeconds(25) prefs.setDismissChallenge(DismissChallenge.HOLD) prefs.setHomeZoneId("Europe/Berlin") prefs.setAlarmSilenceAfterMinutes(25) @@ -67,6 +69,8 @@ class ClockPrefsTest { volumeRampSeconds = 0, alarmRingtoneUri = "content://a", timerRingtoneUri = "content://t", + timerVibrate = false, + timerVolumeRampSeconds = 25, dismissChallenge = DismissChallenge.HOLD, homeZoneId = "Europe/Berlin", alarmSilenceAfterMinutes = 25, 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 98870c1..315a5d7 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 @@ -89,6 +89,8 @@ class SettingsPrefsTest { alarmSilenceAfterMinutes = 25, timerSilenceAfterMinutes = 999, volumeButtons = VolumeButtonAction.NOTHING, + timerVibrate = false, + timerVolumeRampSeconds = 99, ), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), ), @@ -102,6 +104,8 @@ class SettingsPrefsTest { assertThat(prefs.currentDefaults().alarmSilenceAfterMinutes).isEqualTo(25) assertThat(prefs.currentDefaults().timerSilenceAfterMinutes).isEqualTo(SilenceAfter.NEVER) assertThat(prefs.currentDefaults().volumeButtons).isEqualTo(VolumeButtonAction.NOTHING) + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60) assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder() } @@ -203,6 +207,67 @@ class SettingsPrefsTest { assertThat(bedtime.reminderMinutes).isEqualTo(0) } + // --- #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 `settling copies the alarm default, as timers did before`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setVibrate(false) + + prefs.settleTimerVibrate() + // Settled, so the two now move apart. + prefs.setVibrate(true) + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + + @Test + fun `an install that never touched alarm vibration keeps vibrating its timers`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.settleTimerVibrate() + prefs.setVibrate(false) + + assertThat(prefs.currentDefaults().timerVibrate).isTrue() + } + + @Test + fun `settling never overwrites a choice`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + prefs.setTimerVibrate(false) + + prefs.settleTimerVibrate() + + assertThat(prefs.currentDefaults().timerVibrate).isFalse() + } + + @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/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt index 5019802..00559c0 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 @@ -83,20 +83,36 @@ 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) + } + + @Test + fun `a ring restarted mid-session picks its ramp up where the window says it is`() { + val window = 25.minutes + assertThat(TimerRing.rampElapsed(silenceIn = null, window)).isEqualTo(Duration.ZERO) + assertThat(TimerRing.rampElapsed(silenceIn = window, window)).isEqualTo(Duration.ZERO) + assertThat(TimerRing.rampElapsed(silenceIn = window - 12.seconds, window)) + .isEqualTo(12.seconds) + // More left than the window (a clock change) never ramps backwards. + assertThat(TimerRing.rampElapsed(silenceIn = window + 5.seconds, window)) + .isEqualTo(Duration.ZERO) } /** §5.4 #8 */ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bcc477b..7db3c2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -610,7 +610,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`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `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`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `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` | @@ -1081,7 +1081,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 | @@ -1431,8 +1431,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 "Silence after" setting. §14 has the rest, including which of the two wins the one audio stream. @@ -2111,11 +2111,34 @@ 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. Until #22 a timer vibrated exactly when the alarm +default did, so nobody's timers change on update: `SettingsPrefs.settleTimerVibrate` +copies the alarm default into the new key once, from its own coroutine at +startup, concurrent with the engines' pass rather than ahead of it, and from +then on the two move apart. The key's own default is `true`, the alarm +default's, for the moment before that lands (a timer that rings in that moment +on the first launch after the update may vibrate once); a failed copy is tried +again on the next launch. Fresh +installs therefore start with timer vibration on, like alarms; telling a fresh +install from an updated one reliably was more machinery than the default is +worth, and the switch is one tap away. + +A ring restarted mid-session (handed to a second expired timer, back after an +alarm held it, a recreated service) picks its ramp up where the session is, +`TimerRing.rampElapsed` from the window's remaining time, rather than ramping +again from a whisper. 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 @@ -2273,8 +2296,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 2539cd9..5d4b94c 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -48,6 +48,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_ @@ -148,6 +152,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, "default_alarm_silence_after_minutes": 10, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9f73641..e6ccda9 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