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:
commit
48d09f7371
22 files changed
+380
-55
No files matched your search
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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