Merge branch 'feat/18-volume-buttons' into release/v1.0.0
# Conflicts: # app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.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/ui/settings/AlarmDefaultsScreen.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefsTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt # docs/ARCHITECTURE.md # docs/BACKUP.md
This commit is contained in:
commit
d81cd04b60
22 files changed
+592
-22
No files matched your search
@@ -37,6 +37,11 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
- A ring screen over the lock screen with snooze and dismiss. The dismiss
|
||||
challenge always gives you a way out, works with a screen reader, and Back
|
||||
can't silence an alarm.
|
||||
- Choose what the volume buttons do while an alarm rings: change the volume
|
||||
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
|
||||
challenge by snoozing instead, and a snooze press with no snoozes left
|
||||
changes the volume rather than ending the alarm. It works while the ring
|
||||
screen is showing.
|
||||
- **Timers.** Run several at once, each with its own label, length and sound,
|
||||
set from a keypad with saved presets. Pause, resume, reset and +1 min work
|
||||
from the timer, from the live pill and from the notification. A finished
|
||||
|
||||
@@ -120,9 +120,18 @@ class AlarmEngine @Inject constructor(
|
||||
if (!rearmIfInsideWindowLocked(state)) closeCycleLocked(state.alarmId, state)
|
||||
}
|
||||
|
||||
/** [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15). */
|
||||
suspend fun snooze(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome =
|
||||
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
|
||||
/**
|
||||
* [minutesOverride] is a one-off, already clamped; it is never persisted anywhere (M9 D15).
|
||||
*
|
||||
* A snooze past the limit dismisses, unless [dismissIfRefused] is false:
|
||||
* then nothing changes and the alarm keeps ringing. A volume key passes
|
||||
* false (#18), since the press is blind and must not end the alarm.
|
||||
*/
|
||||
suspend fun snooze(
|
||||
alarmId: Long,
|
||||
minutesOverride: Int? = null,
|
||||
dismissIfRefused: Boolean = true,
|
||||
): SnoozeOutcome = lock.withLock { snoozeLocked(alarmId, minutesOverride, dismissIfRefused) }
|
||||
|
||||
/**
|
||||
* The `AlarmClock` contract's dismissal, and the chooser dialog's (M9 D14).
|
||||
@@ -221,12 +230,14 @@ class AlarmEngine @Inject constructor(
|
||||
val since = state.ringingSince ?: return@withLock null
|
||||
val alarm = alarms.find(state.alarmId) ?: return@withLock null
|
||||
|
||||
val resolved: AlarmSettings = alarm.resolveSettings(settings.currentDefaults())
|
||||
val defaults = settings.currentDefaults()
|
||||
val resolved: AlarmSettings = alarm.resolveSettings(defaults)
|
||||
RingSession(
|
||||
alarm = alarm,
|
||||
settings = resolved,
|
||||
startedAt = since,
|
||||
canSnooze = state.snoozeCount < resolved.snoozeLimit,
|
||||
volumeButtons = defaults.volumeButtons,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -417,7 +428,11 @@ class AlarmEngine @Inject constructor(
|
||||
return FireOutcome.Ringing(alarmId)
|
||||
}
|
||||
|
||||
private suspend fun snoozeLocked(alarmId: Long, minutesOverride: Int? = null): SnoozeOutcome {
|
||||
private suspend fun snoozeLocked(
|
||||
alarmId: Long,
|
||||
minutesOverride: Int?,
|
||||
dismissIfRefused: Boolean,
|
||||
): SnoozeOutcome {
|
||||
val state = states.state(alarmId)
|
||||
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
|
||||
val alarm = alarms.find(alarmId) ?: return SnoozeOutcome.NotRinging
|
||||
@@ -425,7 +440,11 @@ class AlarmEngine @Inject constructor(
|
||||
val resolved = alarm.resolveSettings(settings.currentDefaults())
|
||||
// A limit of 0 means snoozing is off, not unlimited — and a refused
|
||||
// snooze dismisses, so the dismiss path can never strand the user.
|
||||
// The one exception is a caller that asked to keep ringing (a blind
|
||||
// volume-key press, #18): it is told so and must keep the alarm's own
|
||||
// way out on screen.
|
||||
if (state.snoozeCount >= resolved.snoozeLimit) {
|
||||
if (!dismissIfRefused) return SnoozeOutcome.Refused(alarmId)
|
||||
closeCycleLocked(alarmId, state)
|
||||
return SnoozeOutcome.LimitReached(alarmId)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
@@ -132,6 +133,7 @@ object BackupCodec {
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
|
||||
volumeButtons = defaults.volumeButtons.name,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
)
|
||||
@@ -277,6 +279,7 @@ object BackupCodec {
|
||||
// Clamped on apply, by the preference, like every other default.
|
||||
alarmSilenceAfterMinutes = settingsDto.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = settingsDto.timerSilenceAfterMinutes,
|
||||
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
|
||||
@@ -73,6 +73,7 @@ data class SettingsDto(
|
||||
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: String? = null,
|
||||
@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",
|
||||
/**
|
||||
* Minutes, or 0 for "Never". Absent in a file from before the setting
|
||||
* existed, which is v1's fixed ten minutes.
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
@@ -46,6 +47,10 @@ object ClockPrefs {
|
||||
intPref("default_timer_silence_after_minutes", SilenceAfter.DEFAULT_MINUTES)
|
||||
.map(decode = SilenceAfter::sanitise, encode = SilenceAfter::sanitise)
|
||||
|
||||
/** No `default_` prefix: no alarm overrides it. */
|
||||
val volumeButtons: Pref<VolumeButtonAction> =
|
||||
enumPref("volume_buttons_while_ringing", VolumeButtonAction.CONTROL_VOLUME)
|
||||
|
||||
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
|
||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
@@ -68,17 +69,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
combine(
|
||||
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
|
||||
store.flow(ClockPrefs.timerSilenceAfterMinutes),
|
||||
) { alarmMinutes, timerMinutes -> alarmMinutes to timerMinutes },
|
||||
store.flow(ClockPrefs.volumeButtons),
|
||||
) { alarmMinutes, timerMinutes, volumeButtons -> Triple(alarmMinutes, timerMinutes, volumeButtons) },
|
||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
||||
store.flow(ClockPrefs.timerRingtoneUri),
|
||||
store.flow(ClockPrefs.homeZoneId),
|
||||
) { partial, silenceAfter, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
||||
) { partial, (alarmSilenceAfter, timerSilenceAfter, volumeButtons), alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
||||
partial.copy(
|
||||
alarmRingtoneUri = alarmRingtoneUri,
|
||||
timerRingtoneUri = timerRingtoneUri,
|
||||
homeZoneId = homeZoneId,
|
||||
alarmSilenceAfterMinutes = silenceAfter.first,
|
||||
timerSilenceAfterMinutes = silenceAfter.second,
|
||||
alarmSilenceAfterMinutes = alarmSilenceAfter,
|
||||
timerSilenceAfterMinutes = timerSilenceAfter,
|
||||
volumeButtons = volumeButtons,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@@ -103,6 +106,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
|
||||
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
|
||||
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
@@ -130,6 +134,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
suspend fun setTimerSilenceAfterMinutes(minutes: Int) =
|
||||
store.set(ClockPrefs.timerSilenceAfterMinutes, minutes)
|
||||
|
||||
suspend fun setVolumeButtons(action: VolumeButtonAction) = store.set(ClockPrefs.volumeButtons, action)
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
|
||||
// --- timer presets (M6) ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.domain
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -9,7 +10,9 @@ import kotlin.time.Duration
|
||||
*
|
||||
* The two silence-after values are app-wide only: no alarm or timer overrides
|
||||
* them (#17). They are stored as [SilenceAfter] minutes; read them as a window
|
||||
* through [alarmSilenceAfter] and [timerSilenceAfter].
|
||||
* 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.
|
||||
*/
|
||||
data class ClockDefaults(
|
||||
val snoozeMinutes: Int = 10,
|
||||
@@ -22,6 +25,7 @@ data class ClockDefaults(
|
||||
val homeZoneId: String? = null,
|
||||
val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
|
||||
) {
|
||||
/** How long a ringing alarm rings before it is silenced as missed. */
|
||||
val alarmSilenceAfter: Duration get() = SilenceAfter.window(alarmSilenceAfterMinutes)
|
||||
|
||||
@@ -23,6 +23,8 @@ data class RingSession(
|
||||
val settings: AlarmSettings,
|
||||
val startedAt: Instant,
|
||||
val canSnooze: Boolean,
|
||||
/** The app-wide volume-button setting, read with the rest of the session. */
|
||||
val volumeButtons: VolumeButtonAction = VolumeButtonAction.CONTROL_VOLUME,
|
||||
)
|
||||
|
||||
sealed interface FireOutcome {
|
||||
@@ -56,5 +58,9 @@ sealed interface SnoozeOutcome {
|
||||
|
||||
/** The limit was already reached; the alarm was dismissed instead. */
|
||||
data class LimitReached(val alarmId: Long) : SnoozeOutcome
|
||||
|
||||
/** The limit was already reached and the caller asked for the alarm to keep ringing. */
|
||||
data class Refused(val alarmId: Long) : SnoozeOutcome
|
||||
|
||||
data object NotRinging : SnoozeOutcome
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
|
||||
/**
|
||||
* What the user asked the volume buttons to do while an alarm rings (#18).
|
||||
* App-wide, not per alarm: it is a habit of the hand, not a property of one
|
||||
* alarm. [CONTROL_VOLUME] is the default and is what Clockula did before the
|
||||
* setting existed.
|
||||
*/
|
||||
enum class VolumeButtonAction { CONTROL_VOLUME, SNOOZE, DISMISS, NOTHING }
|
||||
|
||||
/** What one press actually does, once the ring's own rules have had their say. */
|
||||
enum class VolumeKeyEffect {
|
||||
/** Not ours: the system changes the volume as it would anyway. */
|
||||
PASS_THROUGH,
|
||||
|
||||
/** Swallowed, so the volume stays where it is and the alarm keeps ringing ("Do nothing" only). */
|
||||
IGNORE,
|
||||
SNOOZE,
|
||||
DISMISS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the setting into an effect for one ringing alarm. The press is blind
|
||||
* (the point is not having to look at the screen), so it must never end the
|
||||
* alarm more finally than the ring screen itself would:
|
||||
*
|
||||
* - **Dismiss** dismisses only when the alarm has no dismiss challenge. A
|
||||
* button that skipped the sum or the hold would make the challenge
|
||||
* pointless, so with one set it snoozes instead, and the challenge waits for
|
||||
* the next ring.
|
||||
* - **Snooze** with no snoozes left (and Dismiss behind a challenge with none
|
||||
* left) falls back to changing the volume. The engine answers a refused
|
||||
* snooze from the Snooze button by dismissing, which is right for a button
|
||||
* the user can see is gone, and wrong for a press made with eyes closed: the
|
||||
* alarm would end for good without the user knowing. Swallowing the key
|
||||
* instead would take away the one thing it could still do. The caller also
|
||||
* asks the engine to keep ringing (`AlarmEngine.snooze`'s
|
||||
* `dismissIfRefused`), so a budget that ran out after this was decided does
|
||||
* not end the alarm either.
|
||||
*/
|
||||
object VolumeButtonPolicy {
|
||||
|
||||
fun effect(
|
||||
action: VolumeButtonAction,
|
||||
canSnooze: Boolean,
|
||||
challenge: DismissChallenge,
|
||||
): VolumeKeyEffect = when (action) {
|
||||
VolumeButtonAction.CONTROL_VOLUME -> VolumeKeyEffect.PASS_THROUGH
|
||||
VolumeButtonAction.NOTHING -> VolumeKeyEffect.IGNORE
|
||||
VolumeButtonAction.SNOOZE -> snoozeOrVolume(canSnooze)
|
||||
VolumeButtonAction.DISMISS ->
|
||||
if (challenge == DismissChallenge.NONE) VolumeKeyEffect.DISMISS else snoozeOrVolume(canSnooze)
|
||||
}
|
||||
|
||||
fun effect(session: RingSession): VolumeKeyEffect =
|
||||
effect(session.volumeButtons, session.canSnooze, session.settings.dismissChallenge)
|
||||
|
||||
private fun snoozeOrVolume(canSnooze: Boolean): VolumeKeyEffect =
|
||||
if (canSnooze) VolumeKeyEffect.SNOOZE else VolumeKeyEffect.PASS_THROUGH
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.ui.ring
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,39 @@ class AlarmRingActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The volume keys whose press this screen claimed on the way down. */
|
||||
private val claimedKeys = mutableSetOf<Int>()
|
||||
|
||||
/**
|
||||
* The volume keys, when the user chose for them to snooze, dismiss or do
|
||||
* nothing (#18). Both halves of the press are consumed so the volume does
|
||||
* not move, and the action runs once, on release, however long the key is
|
||||
* held. Whether a press is ours is decided on the way down and kept for the
|
||||
* release, so a press that already moved the volume (the session was still
|
||||
* loading) does not also snooze. "Change the volume" falls through to the
|
||||
* system untouched.
|
||||
*/
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode !in VOLUME_KEYS) return super.onKeyDown(keyCode, event)
|
||||
if (event.repeatCount == 0) {
|
||||
val ours = viewModel.volumeKeyEffect() != VolumeKeyEffect.PASS_THROUGH
|
||||
if (ours) claimedKeys += keyCode else claimedKeys -= keyCode
|
||||
}
|
||||
return if (keyCode in claimedKeys) true else super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
/** A release that lands in another window must not leave its key claimed here. */
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (!hasFocus) claimedKeys.clear()
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!claimedKeys.remove(keyCode)) return super.onKeyUp(keyCode, event)
|
||||
if (!event.isCanceled) viewModel.onVolumeKey()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown over the lock screen with the display turned on: an alarm the user
|
||||
* has to unlock to silence is not an alarm. `minSdk` is 29, so the
|
||||
@@ -85,4 +120,12 @@ class AlarmRingActivity : ComponentActivity() {
|
||||
setTurnScreenOn(true)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val VOLUME_KEYS = setOf(
|
||||
KeyEvent.KEYCODE_VOLUME_UP,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN,
|
||||
KeyEvent.KEYCODE_VOLUME_MUTE,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.MathProblem
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.MathProblems
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -40,6 +43,10 @@ class AlarmRingViewModel @Inject constructor(
|
||||
private var typed: String = ""
|
||||
private var showedWrong: Boolean = false
|
||||
|
||||
// A snooze or dismissal is on its way; a second press must not start another.
|
||||
private var snoozing: Boolean = false
|
||||
private var dismissing: Boolean = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = engine.ringSession()
|
||||
@@ -121,18 +128,73 @@ class AlarmRingViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/** Never gated by the challenge. */
|
||||
fun onSnooze() {
|
||||
val ringing = session ?: return
|
||||
viewModelScope.launch {
|
||||
// A refused snooze dismisses inside the engine rather than leaving
|
||||
// the alarm sounding, so the screen follows it either way.
|
||||
engine.snooze(ringing.alarm.id)
|
||||
_state.value = RingUiState.Finished
|
||||
fun onSnooze() = snooze(dismissIfRefused = true)
|
||||
|
||||
/**
|
||||
* What a volume key does right now. [VolumeKeyEffect.PASS_THROUGH] until the
|
||||
* session is read, once the ring is over, and when the coordinator says
|
||||
* another alarm has taken over the ring, so the screen never swallows a key
|
||||
* it cannot act on and never acts on an alarm that is no longer ringing.
|
||||
*/
|
||||
fun volumeKeyEffect(): VolumeKeyEffect {
|
||||
val ringing = session ?: return VolumeKeyEffect.PASS_THROUGH
|
||||
if (_state.value !is RingUiState.Ringing) return VolumeKeyEffect.PASS_THROUGH
|
||||
// Null is not "another alarm": the coordinator is seeded null in a fresh process.
|
||||
val current = ring.ringing.value
|
||||
if (current != null && current != ringing.alarm.id) return VolumeKeyEffect.PASS_THROUGH
|
||||
return VolumeButtonPolicy.effect(ringing)
|
||||
}
|
||||
|
||||
/** A volume key was pressed and released: the setting's action, through the ring's own rules (#18). */
|
||||
fun onVolumeKey() {
|
||||
// A press while the screen is already on its way out (a tapped Snooze
|
||||
// still being written, say) must not turn that into something else.
|
||||
if (snoozing || dismissing) return
|
||||
when (volumeKeyEffect()) {
|
||||
// A blind press must not be what ends the alarm, and the budget can
|
||||
// run out between the session read and the press.
|
||||
VolumeKeyEffect.SNOOZE -> snooze(dismissIfRefused = false)
|
||||
// The policy only dismisses an alarm without a challenge, whose gate
|
||||
// is open from the start; checked anyway, so a key can never get
|
||||
// round a challenge.
|
||||
VolumeKeyEffect.DISMISS -> if (gate.open) dismiss()
|
||||
VolumeKeyEffect.PASS_THROUGH, VolumeKeyEffect.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused snooze dismisses inside the engine rather than leaving the
|
||||
* alarm sounding, so the screen follows it either way — unless the caller
|
||||
* asked the engine to keep ringing, and the screen then shows that
|
||||
* snoozing is gone.
|
||||
*/
|
||||
private fun snooze(dismissIfRefused: Boolean) {
|
||||
val ringing = session ?: return
|
||||
if (snoozing || dismissing) return
|
||||
snoozing = true
|
||||
viewModelScope.launch {
|
||||
val outcome = try {
|
||||
engine.snooze(ringing.alarm.id, dismissIfRefused = dismissIfRefused)
|
||||
} finally {
|
||||
snoozing = false
|
||||
}
|
||||
if (outcome is SnoozeOutcome.Refused) {
|
||||
session = ringing.copy(canSnooze = false)
|
||||
render()
|
||||
} else {
|
||||
_state.value = RingUiState.Finished
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not held back by a snooze in flight: a solved challenge is a dismissal
|
||||
* the user earned, and the engine runs the two one after the other.
|
||||
*/
|
||||
private fun dismiss() {
|
||||
val ringing = session ?: return
|
||||
if (dismissing) return
|
||||
dismissing = true
|
||||
viewModelScope.launch {
|
||||
engine.dismiss(ringing.alarm.id)
|
||||
_state.value = RingUiState.Finished
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLengthPicker
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLimitPicker
|
||||
@@ -23,10 +24,11 @@ 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.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** Which of the alarm-default pickers is open. Mirrors [de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen]'s shape. */
|
||||
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE, SILENCE_AFTER }
|
||||
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE, SILENCE_AFTER, VOLUME_BUTTONS }
|
||||
|
||||
/**
|
||||
* What every new alarm starts with: how it sounds, how it snoozes, and how it
|
||||
@@ -97,6 +99,14 @@ internal fun AlarmDefaultsScreen(
|
||||
onClick = { openPicker = OpenAlarmPicker.SILENCE_AFTER },
|
||||
)
|
||||
SettingsHint(stringResource(R.string.settings_alarm_silence_after_hint))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_volume_buttons),
|
||||
summary = volumeButtonsText(defaults.volumeButtons),
|
||||
position = Position.Alone,
|
||||
onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS },
|
||||
)
|
||||
SettingsHint(stringResource(R.string.settings_volume_buttons_hint))
|
||||
}
|
||||
|
||||
when (openPicker) {
|
||||
@@ -141,5 +151,23 @@ internal fun AlarmDefaultsScreen(
|
||||
onSelect = { viewModel.onAlarmSilenceAfterChanged(it) },
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenAlarmPicker.VOLUME_BUTTONS -> OptionPicker(
|
||||
title = stringResource(R.string.settings_volume_buttons),
|
||||
options = VolumeButtonAction.entries,
|
||||
selected = defaults.volumeButtons,
|
||||
label = { volumeButtonsText(it) },
|
||||
onSelect = viewModel::onVolumeButtonsChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun volumeButtonsText(action: VolumeButtonAction): String = stringResource(
|
||||
when (action) {
|
||||
VolumeButtonAction.CONTROL_VOLUME -> R.string.volume_buttons_control_volume
|
||||
VolumeButtonAction.SNOOZE -> R.string.volume_buttons_snooze
|
||||
VolumeButtonAction.DISMISS -> R.string.volume_buttons_dismiss
|
||||
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing
|
||||
},
|
||||
)
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.Zones
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
|
||||
import de.jeanlucmakiola.clockula.domain.selfcheck.SelfCheck
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
|
||||
@@ -154,6 +155,9 @@ class SettingsViewModel @Inject internal constructor(
|
||||
fun onTimerSilenceAfterChanged(minutes: Int) =
|
||||
viewModelScope.launch { timerEngine.setTimerSilenceAfter(minutes) }
|
||||
|
||||
fun onVolumeButtonsChanged(value: VolumeButtonAction) =
|
||||
viewModelScope.launch { settings.setVolumeButtons(value) }
|
||||
|
||||
// --- the default ringtone pickers ---
|
||||
|
||||
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
|
||||
|
||||
@@ -312,6 +312,13 @@
|
||||
<string name="settings_section_alarms">Alarms</string>
|
||||
<string name="settings_alarms_subtitle">Sound, snooze and dismiss for new alarms</string>
|
||||
<string name="settings_alarm_defaults_hint">New alarms start with these. Each alarm can still change them in its editor.</string>
|
||||
<!-- #18: what the volume buttons do while an alarm rings -->
|
||||
<string name="settings_volume_buttons">Volume buttons while ringing</string>
|
||||
<string name="settings_volume_buttons_hint">Applies to every alarm while its ring screen is showing. With a dismiss challenge set, Dismiss snoozes instead, so the challenge still has to be done. When a snooze is called for but none are left, the buttons change the volume instead and the alarm keeps ringing.</string>
|
||||
<string name="volume_buttons_control_volume">Change the volume</string>
|
||||
<string name="volume_buttons_snooze">Snooze</string>
|
||||
<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>
|
||||
|
||||
@@ -517,6 +517,20 @@ class AlarmEngineTest {
|
||||
assertThat(scheduler.next?.fireAt).isEqualTo(sevenTomorrow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused snooze can keep the alarm ringing instead of dismissing it`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 0)
|
||||
wallClock.instant = sevenToday
|
||||
val engine = engine(dir)
|
||||
engine.onFire(alarmId = 1L, scheduledFor = sevenToday)
|
||||
|
||||
val outcome = engine.snooze(1L, dismissIfRefused = false)
|
||||
|
||||
assertThat(outcome).isEqualTo(SnoozeOutcome.Refused(1L))
|
||||
assertThat(ring.ringing.value).isEqualTo(1L)
|
||||
assertThat(states.state(1L).ringingSince).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snooze limit of zero refuses the very first snooze`(@TempDir dir: Path) = runTest {
|
||||
givenAlarm(repeatDays = RepeatDays.EVERY_DAY, snoozeLimit = 0)
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
@@ -65,6 +66,7 @@ class BackupCodecTest {
|
||||
homeZoneId = "America/New_York",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = SilenceAfter.NEVER,
|
||||
volumeButtons = VolumeButtonAction.SNOOZE,
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
bedtime = BedtimeSettings(
|
||||
@@ -252,6 +254,24 @@ class BackupCodecTest {
|
||||
assertThat(content.alarms.single().dismissChallenge).isEqualTo(DismissChallenge.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file without the volume-button setting keeps the volume keys as they were`() {
|
||||
val result = BackupCodec.decode(minimalDocument())
|
||||
val defaults = (result as BackupResult.Success).content.settings.defaults
|
||||
assertThat(defaults.volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised volume-button setting degrades rather than rejecting`() {
|
||||
val json = minimalDocument().replace(
|
||||
"\"settings\": {}",
|
||||
"\"settings\": {\"volume_buttons_while_ringing\": \"SOME_FUTURE_ACTION\"}",
|
||||
)
|
||||
val result = BackupCodec.decode(json)
|
||||
val defaults = (result as BackupResult.Success).content.settings.defaults
|
||||
assertThat(defaults.volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-range alarm overrides are clamped to what a picker offers`() {
|
||||
val json = minimalDocument().replace(
|
||||
|
||||
@@ -9,6 +9,7 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
@@ -56,6 +57,7 @@ class ClockPrefsTest {
|
||||
prefs.setHomeZoneId("Europe/Berlin")
|
||||
prefs.setAlarmSilenceAfterMinutes(25)
|
||||
prefs.setTimerSilenceAfterMinutes(SilenceAfter.NEVER)
|
||||
prefs.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
|
||||
assertThat(prefs.defaults.first()).isEqualTo(
|
||||
ClockDefaults(
|
||||
@@ -69,6 +71,7 @@ class ClockPrefsTest {
|
||||
homeZoneId = "Europe/Berlin",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = SilenceAfter.NEVER,
|
||||
volumeButtons = VolumeButtonAction.DISMISS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -123,6 +126,19 @@ class ClockPrefsTest {
|
||||
assertThat(prefs.defaults.first().dismissChallenge).isEqualTo(DismissChallenge.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corrupt volume-button setting degrades to changing the volume`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(PrefStore(dataStore))
|
||||
|
||||
dataStore.updateData { stored ->
|
||||
stored.toMutablePreferences()
|
||||
.apply { this[stringPreferencesKey("volume_buttons_while_ringing")] = "LAUNCH_ROCKET" }
|
||||
}
|
||||
|
||||
assertThat(prefs.defaults.first().volumeButtons).isEqualTo(VolumeButtonAction.CONTROL_VOLUME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home zone the device no longer knows reads back as absent`(@TempDir tempDir: Path) = runTest {
|
||||
val dataStore = newDataStore(tempDir)
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
@@ -87,6 +88,7 @@ class SettingsPrefsTest {
|
||||
alarmRingtoneUri = " ",
|
||||
alarmSilenceAfterMinutes = 25,
|
||||
timerSilenceAfterMinutes = 999,
|
||||
volumeButtons = VolumeButtonAction.NOTHING,
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
),
|
||||
@@ -99,6 +101,7 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
|
||||
assertThat(prefs.currentDefaults().alarmSilenceAfterMinutes).isEqualTo(25)
|
||||
assertThat(prefs.currentDefaults().timerSilenceAfterMinutes).isEqualTo(SilenceAfter.NEVER)
|
||||
assertThat(prefs.currentDefaults().volumeButtons).isEqualTo(VolumeButtonAction.NOTHING)
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmSettings
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A volume key is pressed blind, so what is asserted over the whole table is
|
||||
* that it never dismisses past a challenge and never asks for a snooze that
|
||||
* would be refused (and so turned into a dismissal).
|
||||
*/
|
||||
class VolumeButtonPolicyTest {
|
||||
|
||||
private val everyCase: List<Triple<VolumeButtonAction, Boolean, DismissChallenge>> =
|
||||
VolumeButtonAction.entries.flatMap { action ->
|
||||
listOf(true, false).flatMap { canSnooze ->
|
||||
DismissChallenge.entries.map { challenge -> Triple(action, canSnooze, challenge) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change the volume leaves every key to the system`() {
|
||||
for (canSnooze in listOf(true, false)) {
|
||||
for (challenge in DismissChallenge.entries) {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.CONTROL_VOLUME, canSnooze, challenge))
|
||||
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `do nothing swallows the key whatever the alarm`() {
|
||||
for (canSnooze in listOf(true, false)) {
|
||||
for (challenge in DismissChallenge.entries) {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.NOTHING, canSnooze, challenge))
|
||||
.isEqualTo(VolumeKeyEffect.IGNORE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze snoozes while snoozes are left`() {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = true, DismissChallenge.MATH))
|
||||
.isEqualTo(VolumeKeyEffect.SNOOZE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze with none left goes back to changing the volume`() {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.SNOOZE, canSnooze = false, DismissChallenge.NONE))
|
||||
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss dismisses an alarm without a challenge`() {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.NONE))
|
||||
.isEqualTo(VolumeKeyEffect.DISMISS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss snoozes an alarm with a challenge instead`() {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = true, DismissChallenge.HOLD))
|
||||
.isEqualTo(VolumeKeyEffect.SNOOZE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss goes back to changing the volume when a challenge stands and no snoozes are left`() {
|
||||
assertThat(VolumeButtonPolicy.effect(VolumeButtonAction.DISMISS, canSnooze = false, DismissChallenge.MATH))
|
||||
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a key never dismisses past a challenge`() {
|
||||
for ((action, canSnooze, challenge) in everyCase) {
|
||||
if (challenge == DismissChallenge.NONE) continue
|
||||
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
|
||||
.isNotEqualTo(VolumeKeyEffect.DISMISS)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a key never snoozes an alarm with no snoozes left`() {
|
||||
for ((action, canSnooze, challenge) in everyCase) {
|
||||
if (canSnooze) continue
|
||||
assertThat(VolumeButtonPolicy.effect(action, canSnooze, challenge))
|
||||
.isNotEqualTo(VolumeKeyEffect.SNOOZE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session form reads the setting, the snooze budget and the challenge`() {
|
||||
val session = RingSession(
|
||||
alarm = Alarm(
|
||||
id = 1L,
|
||||
time = TimeOfDay(7, 0),
|
||||
label = "",
|
||||
enabled = true,
|
||||
repeatDays = RepeatDays.NONE,
|
||||
skipNextOccurrence = false,
|
||||
ringtoneUri = null,
|
||||
vibrate = null,
|
||||
snoozeMinutes = null,
|
||||
snoozeLimit = null,
|
||||
volumeRampSeconds = null,
|
||||
dismissChallenge = null,
|
||||
createdAt = Instant.fromEpochMilliseconds(0L),
|
||||
updatedAt = Instant.fromEpochMilliseconds(0L),
|
||||
deleteAfterUse = false,
|
||||
),
|
||||
settings = AlarmSettings(
|
||||
ringtoneUri = null,
|
||||
vibrate = true,
|
||||
snoozeMinutes = 10,
|
||||
snoozeLimit = 3,
|
||||
volumeRampSeconds = 15,
|
||||
dismissChallenge = DismissChallenge.MATH,
|
||||
),
|
||||
startedAt = Instant.fromEpochMilliseconds(0L),
|
||||
canSnooze = true,
|
||||
volumeButtons = VolumeButtonAction.DISMISS,
|
||||
)
|
||||
|
||||
assertThat(VolumeButtonPolicy.effect(session)).isEqualTo(VolumeKeyEffect.SNOOZE)
|
||||
assertThat(VolumeButtonPolicy.effect(session.copy(canSnooze = false)))
|
||||
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
assertThat(VolumeButtonPolicy.effect(session.copy(volumeButtons = VolumeButtonAction.CONTROL_VOLUME)))
|
||||
.isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.ChallengeGate
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.VolumeKeyEffect
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
|
||||
import de.jeanlucmakiola.clockula.testing.FakeAlarmNotifier
|
||||
@@ -66,6 +68,8 @@ class AlarmRingViewModelTest {
|
||||
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
|
||||
private lateinit var settings: SettingsPrefs
|
||||
|
||||
private fun TestScope.engine(tempDir: Path): AlarmEngine = AlarmEngine(
|
||||
alarms = alarms,
|
||||
states = states,
|
||||
@@ -76,7 +80,7 @@ class AlarmRingViewModelTest {
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
),
|
||||
),
|
||||
),
|
||||
).also { settings = it },
|
||||
scheduler = scheduler,
|
||||
ring = ring,
|
||||
notifier = notifier,
|
||||
@@ -479,6 +483,126 @@ class AlarmRingViewModelTest {
|
||||
assertThat(states.state(1L).ringingSince).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `volume keys change the volume by default`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
givenRinging(engine)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a volume key snoozes when asked to`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
|
||||
givenRinging(engine)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVolumeKey()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.state(1L).snoozedUntil).isNotNull()
|
||||
assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a volume key dismisses an alarm without a challenge`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
givenRinging(engine, challenge = DismissChallenge.NONE)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVolumeKey()
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = states.state(1L)
|
||||
assertThat(listOf(state.ringingSince, state.snoozedUntil, viewModel.state.value))
|
||||
.containsExactly(null, null, RingUiState.Finished)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a volume key set to dismiss snoozes past a challenge instead`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
givenRinging(engine, challenge = DismissChallenge.MATH)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVolumeKey()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.state(1L).snoozedUntil).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a volume key never dismisses past a challenge with no snoozes left`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
givenRinging(engine, challenge = DismissChallenge.HOLD, snoozeLimit = 0)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVolumeKey()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.state(1L).ringingSince).isNotNull()
|
||||
assertThat(viewModel.state.value).isInstanceOf(RingUiState.Ringing::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a volume key snooze past a limit lowered mid-ring keeps the alarm ringing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.SNOOZE)
|
||||
givenRinging(engine)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.ringing().canSnooze)
|
||||
|
||||
settings.setSnoozeLimit(0)
|
||||
viewModel.onVolumeKey()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.state(1L).ringingSince).isNotNull()
|
||||
assertThat(viewModel.ringing().canSnooze).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `volume keys are left alone once another alarm has taken over the ring`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
givenRinging(engine)
|
||||
val viewModel = viewModel(engine)
|
||||
advanceUntilIdle()
|
||||
|
||||
ring.startRinging(2L)
|
||||
|
||||
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `volume keys are left alone until the session has loaded`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val engine = engine(tempDir)
|
||||
settings.setVolumeButtons(VolumeButtonAction.DISMISS)
|
||||
givenRinging(engine)
|
||||
advanceUntilIdle()
|
||||
val viewModel = viewModel(engine)
|
||||
check(viewModel.state.value == RingUiState.Loading)
|
||||
|
||||
assertThat(viewModel.volumeKeyEffect()).isEqualTo(VolumeKeyEffect.PASS_THROUGH)
|
||||
}
|
||||
|
||||
/** The prompt is `"47 + 38"`; the answer is what it says. */
|
||||
private fun answerTo(prompt: String): String =
|
||||
prompt.split("+").sumOf { it.trim().toInt() }.toString()
|
||||
|
||||
@@ -86,7 +86,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| Package | Holds |
|
||||
|---|---|
|
||||
| `domain/` | `Alarm.kt`, `Timer.kt`, `WorldClock.kt`, `Stopwatch.kt`, `ClockDefaults.kt`, `Ringtone.kt` (the silent and bundled-tone sentinels), `RepeatSummary.kt`, `AlarmDefaults.kt`. Plain Kotlin, no Android |
|
||||
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate. The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
|
||||
| `domain/alarm/` | the alarm engine's pure half: occurrences, the resolver, the ring state, the presentation and scheduling policies, the challenge gate, `VolumeButtonPolicy` (#18). The *shared* ring vocabulary moved out to `domain/ring/` in M6 |
|
||||
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
||||
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
|
||||
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
||||
@@ -599,7 +599,7 @@ Four slices:
|
||||
| Slice | Keys | Owner |
|
||||
|---|---|---|
|
||||
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
|
||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_dismiss_challenge`, `home_zone_id` | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_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` |
|
||||
@@ -1065,6 +1065,7 @@ M10 adds no `<uses-permission>`.
|
||||
| Carrying `EXTRA_MESSAGE` into the timer setup panel when no length was given. There is no row to carry it on and the panel has no label field | not planned for v1 |
|
||||
| Verifying a ringtone URI at the intent boundary. M5 locked "an unreadable sound is reported, never rewritten"; an unmounted card comes back, and the boundary opens no `ContentResolver` | not planned for v1 |
|
||||
| An unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
|
||||
| The volume-button setting (#18) while the alarm rings only as a heads-up over another app, or with the screen turned off (the system keeps volume keys from a non-interactive device's windows). A background app sees no key events; the one route, a remote-playback `MediaSession` whose `VolumeProvider` takes the keys, is refused by `MediaSessionRecord.canHandleVolumeKey` on Android 13+ for an app with no MediaRouter2 routing session, loses to whichever app is in front or played last, and shows a cast-volume slider and a phantom media player while it works. AOSP DeskClock and BlackyHawky's Clock handle the keys in their alarm activity only, as `AlarmRingActivity` does | not planned |
|
||||
| Reordering timers by hand. `sort_order` and `TimerRepository.reorder` exist and stay caller-less: the list is in storage order, M5 gave the same answer for alarms, and a drag surface would also mean abandoning the `LazyColumn` | not planned for v1 |
|
||||
| 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 |
|
||||
|
||||
+5
-1
@@ -138,6 +138,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"home_zone_id": null,
|
||||
"default_alarm_silence_after_minutes": 10,
|
||||
"default_timer_silence_after_minutes": 10,
|
||||
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
"enabled": false,
|
||||
@@ -166,7 +167,10 @@ the importing device creates its own (`docs/ARCHITECTURE.md` §18).
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
other unknown field.
|
||||
other unknown field. `volume_buttons_while_ringing` is one of
|
||||
`CONTROL_VOLUME`/`SNOOZE`/`DISMISS`/`NOTHING`; a file without it (any backup made
|
||||
before the setting existed) or with a name this version does not know restores
|
||||
`CONTROL_VOLUME`, the behaviour that file's device had.
|
||||
|
||||
`default_alarm_silence_after_minutes` and `default_timer_silence_after_minutes`
|
||||
are how long a ringing alarm or a finished timer keeps going before it stops on
|
||||
|
||||
Reference in new issue
Block a user