feat(alarms): say how long until an alarm rings when you set it
After a user action in the app sets an alarm, a snackbar says how long until it rings: "Alarm set for 7 hours and 12 minutes from now". The row already said "in 7h 12m", but nothing drew the eye to it, so an AM/PM or wrong-day slip went unnoticed until the morning. It is raised by switching an alarm on in the list, and in the editor by picking a time (or keeping a new alarm's default one), arming or disarming the skip, and changing the repeat days. Day taps wait a second to settle, so Mon-Fri is one snackbar rather than five; a newer countdown always replaces the one on screen. Switching the announced alarm back off takes it back. A switched-off, ringing or snoozed alarm says nothing, and the AlarmClock intents never reach it, since those already get the status-bar icon. AlarmSetCountdown decides what it says, purely, with the row's rounding; the engine gains upcomingFor(), a read-only resolve of one alarm. Fixes #19
This commit is contained in:
@@ -19,6 +19,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
can play it vibrates.
|
||||
- An alarm list that shows when each alarm will ring and marks the next one.
|
||||
Changes apply as you make them, with no Save button.
|
||||
- Setting an alarm, switching it on or changing its time or repeat days says
|
||||
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
|
||||
now", so an AM/PM or wrong-day slip shows at once.
|
||||
- Repeat days that start on your locale's first day of the week, and a "skip
|
||||
next alarm" switch so you can sleep in once without turning the alarm off.
|
||||
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
|
||||
|
||||
@@ -204,6 +204,17 @@ class AlarmEngine @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One alarm's [upcoming] entry, resolved against the wall clock now. Read-only
|
||||
* like [upcoming], and for the same reason it takes no lock: it writes
|
||||
* nothing. Null for an id no alarm has.
|
||||
*/
|
||||
suspend fun upcomingFor(alarmId: Long): UpcomingAlarm? {
|
||||
val alarm = alarms.find(alarmId) ?: return null
|
||||
val resolution = AlarmResolver.resolve(alarm, states.state(alarmId), wallClock.now(), zones.current())
|
||||
return UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||
}
|
||||
|
||||
private suspend fun rescheduleLocked(): ScheduledFire? {
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* What "Alarm set for 7 hours and 12 minutes from now" says once the user has
|
||||
* set an alarm, or null when it should say nothing (#19).
|
||||
*
|
||||
* Only a natural occurrence is announced. A switched-off alarm will not ring at
|
||||
* all; a ringing or snoozed one is answering the snooze, not the change the
|
||||
* user just made, and "set for 9 minutes from now" would read as a mistake.
|
||||
*/
|
||||
object AlarmSetCountdown {
|
||||
/** Pure. The rounding is [NextFireFormat]'s, so the snackbar and the row agree. */
|
||||
fun of(upcoming: UpcomingAlarm?, now: Instant): NextFireLabel? {
|
||||
if (upcoming == null || !upcoming.alarm.enabled) return null
|
||||
if (upcoming.source != FireSource.OCCURRENCE) return null
|
||||
val nextFire = upcoming.nextFire ?: return null
|
||||
return NextFireFormat.labelFor(nextFire, now)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -59,6 +62,8 @@ fun AlarmEditorScreen(
|
||||
val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle()
|
||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
|
||||
|
||||
// Registered unconditionally, and deliberately **not** inside the
|
||||
// `picker?.let` below: the picker's open flag is ViewModel state that does
|
||||
@@ -84,6 +89,10 @@ fun AlarmEditorScreen(
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
predictiveBack = true,
|
||||
// Lifted over the live pill, which floats above every screen.
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHost, Modifier.padding(bottom = LocalLivePillInset.current))
|
||||
},
|
||||
actions = {
|
||||
if (state is AlarmEditorUiState.Editing) {
|
||||
IconButton(onClick = { confirmingDelete = true }) {
|
||||
@@ -144,6 +153,9 @@ private fun AlarmEditorContent(
|
||||
// about losing a half-entered time).
|
||||
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
|
||||
var pickingTime by rememberSaveable { mutableStateOf(openTimePicker) }
|
||||
// True until the picker a new alarm opens on is first closed: cancelling
|
||||
// that one still leaves a freshly set alarm to announce (#19).
|
||||
var pickingNewAlarm by rememberSaveable { mutableStateOf(openTimePicker) }
|
||||
|
||||
TimeCard(time = editing.time, onClick = { pickingTime = true })
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -225,8 +237,13 @@ private fun AlarmEditorContent(
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onTimePicked(hour, minute)
|
||||
pickingTime = false
|
||||
pickingNewAlarm = false
|
||||
},
|
||||
onDismiss = {
|
||||
if (pickingNewAlarm) viewModel.onNewAlarmTimeKept()
|
||||
pickingTime = false
|
||||
pickingNewAlarm = false
|
||||
},
|
||||
onDismiss = { pickingTime = false },
|
||||
)
|
||||
}
|
||||
when (openPicker) {
|
||||
|
||||
@@ -14,10 +14,14 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -26,6 +30,8 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.DayOfWeek
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* The editor writes as the user goes: no Save, no dirty state, no discard
|
||||
@@ -41,6 +47,7 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
private val engine: AlarmEngine,
|
||||
private val ringtones: RingtoneCatalog,
|
||||
private val preview: RingtonePreviewer,
|
||||
wall: WallClock,
|
||||
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
|
||||
// annotation on a constructor `val` to the property instead.
|
||||
@param:ApplicationScope private val appScope: CoroutineScope,
|
||||
@@ -74,6 +81,18 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
/** What the previewer is sounding, so the next preview knows to stop it. */
|
||||
private var previewing: String? = null
|
||||
|
||||
private val announcer = AlarmSetAnnouncer(engine, wall)
|
||||
|
||||
/** The announcement waiting out [ANNOUNCE_SETTLE], so the next change can replace it. */
|
||||
private var announcing: Job? = null
|
||||
|
||||
/**
|
||||
* One-shot: "Alarm set for … from now" after a change that moves when the
|
||||
* alarm rings (#19). Nothing for a switched-off alarm, and nothing for the
|
||||
* label or the sound: those move no fire time.
|
||||
*/
|
||||
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
|
||||
|
||||
/** Memoised catalog answers, keyed on URI — see [metaOf]. */
|
||||
private val ringtoneMeta = mutableMapOf<String, RingtoneMeta>()
|
||||
|
||||
@@ -99,9 +118,17 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
|
||||
/** Clamped, never throws. */
|
||||
fun onTimePicked(hour: Int, minute: Int) =
|
||||
edit(reschedule = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
|
||||
edit(reschedule = true, announce = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
|
||||
|
||||
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true) { alarm ->
|
||||
/**
|
||||
* The picker a new alarm opens on was closed without a time. The alarm
|
||||
* exists and is enabled at its default hour all the same — this is the
|
||||
* moment it was set, so it is announced (#19).
|
||||
*/
|
||||
fun onNewAlarmTimeKept() = announce()
|
||||
|
||||
/** Announced once the taps settle: setting Mon–Fri is one snackbar, not five (#19). */
|
||||
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true, announce = true, settle = ANNOUNCE_SETTLE) { alarm ->
|
||||
val days = if (day in alarm.repeatDays) alarm.repeatDays - day else alarm.repeatDays + day
|
||||
alarm.copy(
|
||||
repeatDays = days,
|
||||
@@ -124,6 +151,9 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
viewModelScope.launch {
|
||||
alarms.setSkipNextOccurrence(alarmId, skip)
|
||||
engine.reschedule()
|
||||
// Skipping moves the next ring a day or more out, which is exactly
|
||||
// the kind of jump worth saying out loud.
|
||||
announce()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +265,31 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
/**
|
||||
* Every editor write. [reschedule] is on only for a change that can move a
|
||||
* fire time — so fiddling with sound settings costs no AlarmManager traffic
|
||||
* (M5 D12).
|
||||
* (M5 D12). [announce] is on for the same changes, after [settle].
|
||||
*/
|
||||
private fun edit(reschedule: Boolean = false, transform: (Alarm) -> Alarm) {
|
||||
private fun edit(
|
||||
reschedule: Boolean = false,
|
||||
announce: Boolean = false,
|
||||
settle: Duration = Duration.ZERO,
|
||||
transform: (Alarm) -> Alarm,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
alarms.edit(alarmId, transform)
|
||||
if (reschedule) engine.reschedule()
|
||||
if (announce) announce(settle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces any announcement still settling, so a burst of changes ends in
|
||||
* one snackbar about where they landed. Read from storage when it fires,
|
||||
* not from the change that asked, so it is never about a superseded value.
|
||||
*/
|
||||
private fun announce(settle: Duration = Duration.ZERO) {
|
||||
announcing?.cancel()
|
||||
announcing = viewModelScope.launch {
|
||||
delay(settle)
|
||||
announcer.announce(alarmId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,3 +362,6 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
val accessMayLapse: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
/** How long the repeat days must stay untouched before the editor says when the alarm rings. */
|
||||
private val ANNOUNCE_SETTLE: Duration = 1.seconds
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmSetCountdown
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
/** A one-shot for the alarm screens' "Alarm set for … from now" snackbar (#19). */
|
||||
sealed interface AlarmSetSnackbar {
|
||||
data class Show(val countdown: NextFireLabel) : AlarmSetSnackbar
|
||||
|
||||
/** Takes back a "set for" that stopped being true: the alarm was switched off. */
|
||||
data object Hide : AlarmSetSnackbar
|
||||
}
|
||||
|
||||
/**
|
||||
* Says how long until an alarm rings, right after a user action in the app set
|
||||
* it — the moment an AM/PM or wrong-day slip is cheapest to notice (#19).
|
||||
*
|
||||
* Only the list and the editor own one, so an alarm set through the
|
||||
* `AlarmClock` intents never announces itself: those already get the
|
||||
* status-bar icon. Not thread-safe; both owners call it from `viewModelScope`.
|
||||
*/
|
||||
internal class AlarmSetAnnouncer(
|
||||
private val engine: AlarmEngine,
|
||||
private val wall: WallClock,
|
||||
) {
|
||||
// Dropped, not buffered, with nobody collecting: a countdown that surfaced
|
||||
// minutes later, when the screen came back, would be stale — and about an
|
||||
// action the user has long since moved on from.
|
||||
private val pending = MutableSharedFlow<AlarmSetSnackbar>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
val snackbars: Flow<AlarmSetSnackbar> = pending.asSharedFlow()
|
||||
|
||||
/** The alarm the last [AlarmSetSnackbar.Show] was about, so only it can be taken back. */
|
||||
private var announced: Long? = null
|
||||
|
||||
/** Call once the write is stored. Says nothing for an alarm that will not ring. */
|
||||
suspend fun announce(alarmId: Long) {
|
||||
val countdown = AlarmSetCountdown.of(engine.upcomingFor(alarmId), wall.now())
|
||||
when {
|
||||
countdown != null -> {
|
||||
announced = alarmId
|
||||
pending.tryEmit(AlarmSetSnackbar.Show(countdown))
|
||||
}
|
||||
announced == alarmId -> {
|
||||
announced = null
|
||||
pending.tryEmit(AlarmSetSnackbar.Hide)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows [snackbars] on [host]. Latest wins: a new countdown replaces the one on
|
||||
* screen rather than queueing behind it, so a burst of changes is one snackbar
|
||||
* that ends up saying the right thing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlarmSetSnackbarEffect(snackbars: Flow<AlarmSetSnackbar>, host: SnackbarHostState) {
|
||||
val resources = LocalResources.current
|
||||
LaunchedEffect(snackbars, host, resources) {
|
||||
snackbars.collectLatest { snackbar ->
|
||||
when (snackbar) {
|
||||
is AlarmSetSnackbar.Show ->
|
||||
alarmSetMessage(resources, snackbar.countdown)?.let { host.showSnackbar(it) }
|
||||
// The previous Show was cancelled by collectLatest, which already
|
||||
// dismissed it; this covers one shown before a recomposition.
|
||||
AlarmSetSnackbar.Hide -> host.currentSnackbarData?.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full sentence: proper plurals, and the unit pairs the row's countdown
|
||||
* uses — minutes, hours and minutes, or days and hours. A zero second unit is
|
||||
* left out, as the row leaves it out: "7 hours", not "7 hours and 0 minutes".
|
||||
*/
|
||||
internal fun alarmSetMessage(resources: Resources, countdown: NextFireLabel): String? {
|
||||
fun days(n: Int) = resources.getQuantityString(R.plurals.alarm_set_days, n, n)
|
||||
fun hours(n: Int) = resources.getQuantityString(R.plurals.alarm_set_hours, n, n)
|
||||
fun minutes(n: Int) = resources.getQuantityString(R.plurals.alarm_set_minutes, n, n)
|
||||
fun pair(first: String, second: String?) =
|
||||
if (second == null) first else resources.getString(R.string.alarm_set_units_pair, first, second)
|
||||
|
||||
val span = when (countdown) {
|
||||
NextFireLabel.NotScheduled -> return null
|
||||
NextFireLabel.Imminent -> return resources.getString(R.string.alarm_set_imminent)
|
||||
is NextFireLabel.InMinutes -> minutes(countdown.minutes)
|
||||
is NextFireLabel.InHoursMinutes ->
|
||||
pair(hours(countdown.hours), countdown.minutes.takeIf { it > 0 }?.let(::minutes))
|
||||
is NextFireLabel.InDaysHours ->
|
||||
pair(days(countdown.days), countdown.hours.takeIf { it > 0 }?.let(::hours))
|
||||
}
|
||||
return resources.getString(R.string.alarm_set_for, span)
|
||||
}
|
||||
@@ -15,10 +15,13 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -59,12 +62,14 @@ fun AlarmsScreen(
|
||||
val context = LocalContext.current
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addAlarm = stringResource(R.string.alarms_add)
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
// The FAB creates the alarm and the editor edits a persisted row, so
|
||||
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
|
||||
viewModel.openEditor.collectLatest(onCreateAlarm)
|
||||
}
|
||||
AlarmSetSnackbarEffect(viewModel.snackbars, snackbarHost)
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.tab_alarms),
|
||||
@@ -78,6 +83,8 @@ fun AlarmsScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
// The scaffold stacks it above the FAB, which already clears the pill.
|
||||
snackbarHost = { SnackbarHost(snackbarHost) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = viewModel::onAddAlarm,
|
||||
|
||||
@@ -44,6 +44,11 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
// into two navigations (M5 D22).
|
||||
private val newAlarms = Channel<Long>(Channel.CONFLATED)
|
||||
|
||||
private val announcer = AlarmSetAnnouncer(engine, wall)
|
||||
|
||||
/** One-shot: "Alarm set for … from now" after the row's switch turns an alarm on (#19). */
|
||||
val snackbars: Flow<AlarmSetSnackbar> = announcer.snackbars
|
||||
|
||||
/** One-shot: the id of a freshly created alarm, for the caller to navigate to. */
|
||||
val openEditor: Flow<Long> = newAlarms.receiveAsFlow().onEach { creating = false }
|
||||
|
||||
@@ -62,7 +67,8 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
val time = AlarmDefaults.nextWholeHour(wall.now(), zones.current())
|
||||
val id = alarms.create(AlarmDraft(time = time))
|
||||
// Created enabled, so it schedules at once — which is what tapping
|
||||
// "+" asked for (M5 D22).
|
||||
// "+" asked for (M5 D22). Not announced here: the editor opens on
|
||||
// its time picker, and announces whichever time the user keeps.
|
||||
engine.reschedule()
|
||||
newAlarms.send(id)
|
||||
}
|
||||
@@ -82,6 +88,9 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
if (!enabled) stopRingIfRinging(alarmId)
|
||||
alarms.setEnabled(alarmId, enabled)
|
||||
engine.reschedule()
|
||||
// Switching on says when it will ring; switching off takes back a
|
||||
// "set for" still on screen about this alarm (#19).
|
||||
announcer.announce(alarmId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,22 @@
|
||||
<string name="alarm_next_in_days_hours">in %1$dd %2$dh</string>
|
||||
<string name="alarm_next_ringing">Ringing now</string>
|
||||
<string name="alarm_next_snoozed">Snoozed \u00b7 %1$s</string>
|
||||
<!-- #19: the snackbar after the user sets an alarm. %1$s is alarm_set_days/hours/minutes, or two of them joined by alarm_set_units_pair. -->
|
||||
<string name="alarm_set_for">Alarm set for %1$s from now</string>
|
||||
<string name="alarm_set_imminent">Alarm set for less than 1 minute from now</string>
|
||||
<string name="alarm_set_units_pair">%1$s and %2$s</string>
|
||||
<plurals name="alarm_set_days">
|
||||
<item quantity="one">%1$d day</item>
|
||||
<item quantity="other">%1$d days</item>
|
||||
</plurals>
|
||||
<plurals name="alarm_set_hours">
|
||||
<item quantity="one">%1$d hour</item>
|
||||
<item quantity="other">%1$d hours</item>
|
||||
</plurals>
|
||||
<plurals name="alarm_set_minutes">
|
||||
<item quantity="one">%1$d minute</item>
|
||||
<item quantity="other">%1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="alarm_skipping_next">Skipping next</string>
|
||||
<string name="alarm_repeat_once">Once</string>
|
||||
<string name="alarm_repeat_every_day">Every day</string>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* "Alarm set for … from now" speaks only for an alarm that will ring at a
|
||||
* natural occurrence, and rounds the way the row's countdown does (#19).
|
||||
*/
|
||||
class AlarmSetCountdownTest {
|
||||
|
||||
private fun upcoming(
|
||||
nextFire: Instant?,
|
||||
source: FireSource = FireSource.OCCURRENCE,
|
||||
enabled: Boolean = true,
|
||||
) = UpcomingAlarm(alarmAt(hour = 7, enabled = enabled), nextFire, source)
|
||||
|
||||
@Test
|
||||
fun `an alarm due in hours and minutes says both`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(T0 + 7.hours + 12.minutes), now = T0)
|
||||
|
||||
assertThat(countdown).isEqualTo(NextFireLabel.InHoursMinutes(7, 12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under a minute away is imminent`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(T0 + 40.seconds), now = T0)
|
||||
|
||||
assertThat(countdown).isEqualTo(NextFireLabel.Imminent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minutes away rounds up, as the row does`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(T0 + 4.minutes + 1.seconds), now = T0)
|
||||
|
||||
assertThat(countdown).isEqualTo(NextFireLabel.InMinutes(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `more than a day away says days and hours`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(T0 + 2.days + 3.hours), now = T0)
|
||||
|
||||
assertThat(countdown).isEqualTo(NextFireLabel.InDaysHours(2, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switched-off alarm says nothing`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(nextFire = null, FireSource.NONE, enabled = false), now = T0)
|
||||
|
||||
assertThat(countdown).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snoozed alarm says nothing about its snooze`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(T0 + 9.minutes, FireSource.SNOOZE), now = T0)
|
||||
|
||||
assertThat(countdown).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing alarm says nothing`() {
|
||||
val countdown = AlarmSetCountdown.of(upcoming(nextFire = null, FireSource.RESUMED_RING), now = T0)
|
||||
|
||||
assertThat(countdown).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown alarm says nothing`() {
|
||||
assertThat(AlarmSetCountdown.of(upcoming = null, now = T0)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
||||
@@ -23,6 +25,7 @@ import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
@@ -59,6 +62,7 @@ class AlarmEditorViewModelTest {
|
||||
engine = harness.engine,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
wall = harness.wallClock,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
@@ -252,6 +256,132 @@ class AlarmEditorViewModelTest {
|
||||
assertThat(viewModel.editing().canSkipNext).isTrue()
|
||||
}
|
||||
|
||||
// --- "Alarm set for … from now" (#19) ---
|
||||
|
||||
@Test
|
||||
fun `picking a time says how long until it rings`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 45)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a time picked earlier than now says it rings tomorrow`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
// The AM/PM slip the snackbar is there to catch: 4:30 is behind
|
||||
// us, so it rings tomorrow, 23½ hours away.
|
||||
viewModel.onTimePicked(4, 30)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(23, 30)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeping a new alarm's default time still says when it rings`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 6)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onNewAlarmTimeKept()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(1, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a burst of repeat-day taps is announced once, after it settles`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onDayToggled(DayOfWeek.MONDAY)
|
||||
advanceTimeBy(500)
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceTimeBy(500)
|
||||
viewModel.onDayToggled(DayOfWeek.WEDNESDAY)
|
||||
advanceTimeBy(500)
|
||||
expectNoEvents()
|
||||
advanceUntilIdle()
|
||||
|
||||
// 2026-06-10 is a Wednesday: 7:00 today, two hours out.
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 0)))
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `arming a skip says it rings at the occurrence after it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InDaysHours(1, 2)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switched-off alarm's new time says nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change that moves no fire time says nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onLabelTyped("Gym")
|
||||
viewModel.onSnoozeMinutesChanged(5)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
// --- label ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -190,6 +190,72 @@ class AlarmsViewModelTest {
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching an alarm on says how long until it rings`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, minute = 12, enabled = false)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Show(NextFireLabel.InHoursMinutes(2, 12)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching the announced alarm back off takes the announcement back`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
skipItems(1)
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(AlarmSetSnackbar.Hide)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off an alarm nobody was told about says nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding an alarm leaves the announcement to the editor`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.snackbars.test {
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off the ringing alarm silences it first`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
|
||||
+10
-2
@@ -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 sentinel), `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, and `AlarmSetCountdown`, what the "Alarm set for … from now" snackbar says (#19). 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` |
|
||||
@@ -133,7 +133,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `ui/theme/`, `ui/crash/` | the M0/M1 theme and the crash-report surface |
|
||||
| `ui/shell/` | the navigation policy (`ShellNavigation`), the adaptive shell, the live pill and its source/ViewModel, the notification-permission ask. `EmptyTabScreen` is gone: its KDoc said it existed only until each of M5–M8 replaced its own tab, and the world clock was its last caller |
|
||||
| `ui/common/` | `rememberLocalTimeFormatter`, the app's one 12/24-hour formatter, over a `java.time.LocalTime`, with `rememberAlarmTimeFormatter` delegating to it (M8 D17); and, since M6, the ringtone picker (`RingtonePickerState`, `RingtonePickerScreen`), which both editors need |
|
||||
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog`, the "which alarm should I dismiss?" question |
|
||||
| `ui/alarms/` | the real Alarms tab (M5): the routes, the list's pure row builder and its source, the two ViewModels, the list, the editor, the M3 time-picker host, the repeat-day selector and the override pickers (the ringtone picker moved to `ui/common/` in M6), and M9's `DismissChooser`/`DismissAlarmDialog`, the "which alarm should I dismiss?" question; and `AlarmSetAnnouncer`/`AlarmSetSnackbar`, the "Alarm set for … from now" snackbar both ViewModels raise after a user action (#19) |
|
||||
| `ui/timers/` | the real Timers tab (M6): the routes, the pure row builder and its source, the two ViewModels, the list, the row, the setup panel and its keypad, the editor |
|
||||
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
|
||||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
@@ -1116,6 +1116,14 @@ its own writes. `reschedule()` is called explicitly: from `ClockulaApp`, from
|
||||
the receivers, and from the alarm editor. `upcoming()` runs the same resolver in
|
||||
preview mode and *discards* the state it returns; a read must not write, and a
|
||||
test asserts the store recorded nothing across a full collection.
|
||||
`upcomingFor(alarmId)` is the same read for one alarm, on demand: the list and
|
||||
the editor ask it right after a user's change to say how long until that alarm
|
||||
rings. `AlarmSetCountdown` (pure, in `domain/alarm/`) decides what that says —
|
||||
only a natural occurrence of an enabled alarm, rounded as the row's countdown
|
||||
is, never a snooze or a ring. The `AlarmClock` intents never reach it, since
|
||||
they already get the status-bar icon (#19). The editor announces a time pick
|
||||
at once, but waits for repeat-day taps to settle for a second, so Mon–Fri is
|
||||
one snackbar rather than five.
|
||||
|
||||
`upcoming(refresh: Flow<Unit>)` takes its **re-read cadence as a parameter**.
|
||||
The resolver needs a `now`, and `upcoming` reads one inside a `combine` of the
|
||||
|
||||
Reference in New Issue
Block a user