Merge branch 'feat/22-timer-vibrate-ramp' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
This commit is contained in:
makiolaj committed 2026-10-26 20:54:01 +01:00
commit 48d09f7371
22 files changed
+380 -55

No files matched your search

+4
View File
@@ -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
@@ -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() {
@@ -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.
@@ -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",
@@ -30,6 +30,17 @@ object ClockPrefs {
val timerRingtoneUri: Pref<String?> = 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<Boolean> = booleanPref("default_timer_vibrate", true)
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)
@@ -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)
@@ -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,
@@ -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
}
@@ -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,
)
@@ -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
@@ -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() } }
@@ -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) },
@@ -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) {
@@ -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 },
)
+3 -2
View File
@@ -346,8 +346,9 @@
<string name="volume_buttons_dismiss">Dismiss</string>
<string name="volume_buttons_nothing">Do nothing</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_off">Off. A timer set to Silent still vibrates</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>
@@ -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")
@@ -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,
@@ -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
@@ -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 */
+34 -10
View File
@@ -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<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`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `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` |
@@ -1081,7 +1081,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 |
@@ -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
+6
View File
@@ -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,
+2 -1
View File
@@ -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