feat(alarms): swipe to delete, with Undo
An alarm card swipes away towards the end edge, or deletes from its "Delete" accessibility action, and the list's snackbar offers Undo. The editor's Delete drops its confirmation for the same Undo, routed through a singleton so the list can answer it after the editor pops. Timer cards get the same Undo for their corner delete. AlarmEngine.delete stops a ringing alarm and clears a snoozed one's notification under the engine lock; restore puts the row back under its own id with its ring state and reschedules. A ringing alarm comes back dismissed, never ringing; a finished timer comes back reset. The swipe is hand-rolled so the end-to-start drag stays with the tab pager. Fixes #20
This commit is contained in:
@@ -19,6 +19,10 @@ 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.
|
||||
- Swipe an alarm away to delete it, or use its Delete action with a screen
|
||||
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
|
||||
Undo brings it back with all its settings, and a snoozed alarm comes back
|
||||
snoozed.
|
||||
- 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
|
||||
|
||||
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.clockula.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso
|
||||
@@ -205,12 +207,31 @@ class AlarmsInstrumentedTest {
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// No confirmation: the list underneath offers the Undo instead.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_undo)).assertIsDisplayed()
|
||||
assertThat(stored(id)).isNull()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun theRowsDeleteActionRemovesItAndUndoBringsItBack() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work")
|
||||
.performCustomAccessibilityActionWithLabel(string(R.string.action_delete))
|
||||
composeTestRule.waitForIdle()
|
||||
assertThat(stored(id)).isNull()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.action_undo)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
assertThat(stored(id)).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmSettings
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmDeletion
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolution
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DismissOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
@@ -130,6 +132,47 @@ class AlarmEngine @Inject constructor(
|
||||
suspend fun dismiss(alarmId: Long): Unit =
|
||||
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
|
||||
|
||||
/**
|
||||
* The user's delete, from the list's swipe or the editor. Returns what an
|
||||
* Undo would put back ([AlarmDeletion.snapshot]), or null when there is
|
||||
* nothing to put back.
|
||||
*
|
||||
* On the engine, under its lock, for the same reason as [dismissUpcoming]:
|
||||
* a ringing alarm's service and auto-silence backstop are global slots
|
||||
* only the engine may touch, and a snoozed alarm's notification would
|
||||
* otherwise outlive the row it belongs to. The ring state goes with the row
|
||||
* (the foreign key cascades).
|
||||
*/
|
||||
suspend fun delete(alarmId: Long): DeletedAlarm? = lock.withLock {
|
||||
val alarm = alarms.find(alarmId) ?: return@withLock null
|
||||
val state = states.state(alarmId)
|
||||
if (state.ringingSince != null) {
|
||||
ring.stopRinging(alarmId)
|
||||
scheduler.cancelAutoSilence()
|
||||
}
|
||||
notifier.clearSnoozed(alarmId)
|
||||
alarms.delete(alarmId)
|
||||
rescheduleLocked()
|
||||
AlarmDeletion.snapshot(alarm, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Undo: the row back under its own id, its ring state with it, and the
|
||||
* slot re-resolved, which is what re-registers it with AlarmManager. False
|
||||
* when the id has been taken since (a backup restore in between).
|
||||
*
|
||||
* A snooze comes back with its notification, but only if the resolver
|
||||
* still holds it pending once the slot is re-resolved: one that lapsed
|
||||
* while the alarm was gone is closed there, like any other.
|
||||
*/
|
||||
suspend fun restore(deleted: DeletedAlarm): Boolean = lock.withLock {
|
||||
if (!alarms.restore(deleted.alarm)) return@withLock false
|
||||
if (deleted.state != AlarmRingState.initial(deleted.alarm.id)) states.save(deleted.state)
|
||||
rescheduleLocked()
|
||||
states.state(deleted.alarm.id).snoozedUntil?.let { until -> notifier.showSnoozed(deleted.alarm, until) }
|
||||
true
|
||||
}
|
||||
|
||||
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
|
||||
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
|
||||
rebootRepair.repairIfRebooted()
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.alarms
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
@@ -24,6 +25,10 @@ abstract class AlarmDao {
|
||||
@Insert
|
||||
abstract suspend fun insert(alarm: AlarmEntity): Long
|
||||
|
||||
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertIfAbsent(alarm: AlarmEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(alarm: AlarmEntity)
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ interface AlarmRepository {
|
||||
suspend fun create(draft: AlarmDraft): Long
|
||||
suspend fun update(alarm: Alarm)
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
/**
|
||||
* Puts a deleted alarm back under its own id, timestamps and all — the
|
||||
* Undo after a delete. False when that id is taken (a backup restore
|
||||
* landed in between), in which case nothing is written. `alarms.id` is
|
||||
* `AUTOINCREMENT`, so a new alarm can never have claimed it.
|
||||
*/
|
||||
suspend fun restore(alarm: Alarm): Boolean
|
||||
suspend fun setEnabled(id: Long, enabled: Boolean)
|
||||
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ class AlarmRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun delete(id: Long) = dao.deleteById(id)
|
||||
|
||||
override suspend fun restore(alarm: Alarm): Boolean =
|
||||
dao.insertIfAbsent(AlarmMapper.toEntity(alarm)) != -1L
|
||||
|
||||
override suspend fun setEnabled(id: Long, enabled: Boolean) =
|
||||
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.timers
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
@@ -27,6 +28,10 @@ abstract class TimerDao {
|
||||
@Insert
|
||||
abstract suspend fun insert(timer: TimerEntity): Long
|
||||
|
||||
/** Inserts with the row's own id; -1 when that id is taken, which writes nothing. */
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertIfAbsent(timer: TimerEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(timer: TimerEntity)
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ interface TimerRepository {
|
||||
suspend fun rename(id: Long, label: String)
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
/**
|
||||
* Puts a deleted timer back under its own id, anchors and place in the
|
||||
* list — the Undo after a delete. False when that id is taken (a backup
|
||||
* restore landed in between), in which case nothing is written.
|
||||
*/
|
||||
suspend fun restore(timer: Timer): Boolean
|
||||
|
||||
/** Starts an IDLE/EXPIRED timer from its configured duration, resumes a PAUSED one. No-op when RUNNING. */
|
||||
suspend fun start(id: Long)
|
||||
suspend fun pause(id: Long)
|
||||
|
||||
@@ -43,6 +43,9 @@ class TimerRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun delete(id: Long) = dao.deleteById(id)
|
||||
|
||||
override suspend fun restore(timer: Timer): Boolean =
|
||||
dao.insertIfAbsent(TimerMapper.toEntity(timer)) != -1L
|
||||
|
||||
override suspend fun start(id: Long) {
|
||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||
val wall = wallClock.now()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
|
||||
/** What an Undo puts back: the alarm's row and its ring state, as of the delete. */
|
||||
data class DeletedAlarm(val alarm: Alarm, val state: AlarmRingState)
|
||||
|
||||
/**
|
||||
* What a deleted alarm comes back as. Pure, so the "no stale ring" rule is
|
||||
* decided once, here, and the engine only carries it out.
|
||||
*/
|
||||
object AlarmDeletion {
|
||||
|
||||
/**
|
||||
* The snapshot an Undo restores, or null when there is nothing to restore.
|
||||
*
|
||||
* Everything the user set comes back as it was (label, sound, challenge,
|
||||
* skip-next), and so does the ring state: a snoozed alarm is snoozed again,
|
||||
* and the handled-occurrence watermark still stops the occurrence just
|
||||
* closed from ringing twice.
|
||||
*
|
||||
* A *ringing* alarm is the exception. Deleting it stopped the ring, which is
|
||||
* a dismissal in all but name, so it comes back as a dismissal would have
|
||||
* left it: cycle closed, a one-shot switched off, never ringing again. A
|
||||
* transient one-shot (M9 D10) would have been deleted by that dismissal
|
||||
* anyway, so there is nothing to undo.
|
||||
*/
|
||||
fun snapshot(alarm: Alarm, state: AlarmRingState): DeletedAlarm? {
|
||||
if (!state.isRinging) return DeletedAlarm(alarm, state)
|
||||
|
||||
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
||||
if (alarm.repeatDays.isRepeating) return DeletedAlarm(alarm, closed)
|
||||
if (alarm.deleteAfterUse) return null
|
||||
return DeletedAlarm(alarm.copy(enabled = false), closed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.domain.timer
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.snapshotAt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** What a deleted timer comes back as when the user taps Undo. Pure. */
|
||||
object TimerDeletion {
|
||||
|
||||
/**
|
||||
* The row an Undo restores, or null when there is nothing to restore.
|
||||
*
|
||||
* A running or paused timer comes back exactly as it was. Its anchors are
|
||||
* on the monotonic clock, so a running one has kept counting down while it
|
||||
* was gone, and one whose end passed in the meantime rings on restore, as
|
||||
* it would have done had it never been deleted.
|
||||
*
|
||||
* A timer that had already *finished* is the exception. Deleting it
|
||||
* silenced it, which is a dismissal, so it comes back reset rather than
|
||||
* ringing a second time. A transient one (M9 D10) would have been deleted
|
||||
* by that dismissal anyway, so there is nothing to undo.
|
||||
*/
|
||||
fun snapshot(timer: Timer, elapsedRealtime: Duration, wallClock: Instant): Timer? {
|
||||
if (!timer.snapshotAt(elapsedRealtime, wallClock).hasExpired) return timer
|
||||
if (timer.deleteAfterUse) return null
|
||||
return timer.copy(
|
||||
state = TimerState.IDLE,
|
||||
remaining = timer.duration,
|
||||
startedAtElapsedRealtime = null,
|
||||
endsAtElapsedRealtime = null,
|
||||
endsAtWallClock = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package de.jeanlucmakiola.clockula.timer
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerDeletion
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerExpiry
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationPolicy
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
|
||||
@@ -181,10 +183,26 @@ class TimerEngine @Inject constructor(
|
||||
* Deletes, then resyncs — so a *ringing* timer cannot be deleted into a
|
||||
* stuck ring (D2). It is on the engine and not on a ViewModel-plus-resync
|
||||
* path deliberately: the wrong call should be impossible to write.
|
||||
*
|
||||
* Returns what an Undo would put back ([TimerDeletion.snapshot]), or null
|
||||
* when there is nothing to put back.
|
||||
*/
|
||||
suspend fun delete(timerId: Long): Unit = lock.withLock {
|
||||
suspend fun delete(timerId: Long): Timer? = lock.withLock {
|
||||
val timer = timers.find(timerId)
|
||||
timers.delete(timerId)
|
||||
resyncLocked()
|
||||
timer?.let { TimerDeletion.snapshot(it, elapsed.elapsedRealtime(), wall.now()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Undo: the row back under its own id, then the same resync every verb
|
||||
* ends with, which hands the expiry slot and the service the restored timer.
|
||||
* False when the id has been taken since (a backup restore in between).
|
||||
*/
|
||||
suspend fun restore(timer: Timer): Boolean = lock.withLock {
|
||||
if (!timers.restore(timer)) return@withLock false
|
||||
resyncLocked()
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -57,8 +55,6 @@ fun AlarmEditorScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
||||
val previewPlaying by viewModel.previewPlaying.collectAsStateWithLifecycle()
|
||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
|
||||
// Registered unconditionally, and deliberately **not** inside the
|
||||
// `picker?.let` below: the picker's open flag is ViewModel state that does
|
||||
@@ -86,7 +82,8 @@ fun AlarmEditorScreen(
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
if (state is AlarmEditorUiState.Editing) {
|
||||
IconButton(onClick = { confirmingDelete = true }) {
|
||||
// No confirmation: the list underneath offers an Undo instead.
|
||||
IconButton(onClick = viewModel::onDelete) {
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
contentDescription = stringResource(R.string.editor_delete),
|
||||
@@ -103,18 +100,6 @@ fun AlarmEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val editing = state as? AlarmEditorUiState.Editing
|
||||
if (confirmingDelete && editing != null) {
|
||||
DeleteConfirmation(
|
||||
time = formatTime(editing.time),
|
||||
onConfirm = {
|
||||
confirmingDelete = false
|
||||
viewModel.onDelete()
|
||||
},
|
||||
onDismiss = { confirmingDelete = false },
|
||||
)
|
||||
}
|
||||
|
||||
picker?.let { pickerState ->
|
||||
val editing = state as? AlarmEditorUiState.Editing
|
||||
RingtonePickerScreen(
|
||||
@@ -325,23 +310,3 @@ private fun SoundRow(
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation that names the alarm, deliberately instead of an undo chip:
|
||||
* an accidentally deleted alarm is discovered at 07:00, not now, so the cheap
|
||||
* moment to ask is before (M5 D29).
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteConfirmation(time: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.editor_delete_title)) },
|
||||
text = { Text(stringResource(R.string.editor_delete_message, time)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
private val engine: AlarmEngine,
|
||||
private val ringtones: RingtoneCatalog,
|
||||
private val preview: RingtonePreviewer,
|
||||
private val undo: AlarmUndo,
|
||||
// `@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,
|
||||
@@ -206,12 +207,18 @@ class AlarmEditorViewModel @Inject internal constructor(
|
||||
viewModelScope.launch { preview.play(sound) }
|
||||
}
|
||||
|
||||
/** Dismisses a ring first (M5 D11), deletes, and the state becomes [AlarmEditorUiState.Gone]. */
|
||||
/**
|
||||
* Deletes through the engine, which silences a ring first (M5 D11), and the
|
||||
* state becomes [AlarmEditorUiState.Gone]. No confirmation: the editor pops
|
||||
* and the list underneath offers the Undo, through [AlarmUndo] because this
|
||||
* ViewModel is about to be cleared.
|
||||
*/
|
||||
fun onDelete() {
|
||||
viewModelScope.launch {
|
||||
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
|
||||
alarms.delete(alarmId)
|
||||
engine.reschedule()
|
||||
// On the application scope: a back press right after the tap clears
|
||||
// this ViewModel, and a delete cut off half-way would leave the slot
|
||||
// registered for an alarm that is no longer there.
|
||||
appScope.launch {
|
||||
engine.delete(alarmId)?.let(undo::offer)
|
||||
_state.value = AlarmEditorUiState.Gone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -15,7 +16,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -29,6 +32,7 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatSummary
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.ui.common.SwipeToDelete
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
@@ -41,17 +45,23 @@ import java.util.Locale
|
||||
/**
|
||||
* A standalone alarm card. The time and switch share the first line, leaving
|
||||
* room below for the label and schedule without squeezing either beside the time.
|
||||
*
|
||||
* Swiping it towards the end edge deletes it, and so does the card's "Delete"
|
||||
* custom action, which is the same verb for a screen reader or switch access,
|
||||
* where a swipe is not to be had. The list's snackbar offers the Undo.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlarmRow(
|
||||
row: AlarmRowState,
|
||||
onClick: () -> Unit,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
val time = formatTime(row.time)
|
||||
val toggleDescription = stringResource(R.string.alarm_row_toggle, time)
|
||||
val deleteAction = stringResource(R.string.action_delete)
|
||||
val textColor = if (row.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
@@ -62,61 +72,77 @@ internal fun AlarmRow(
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
}
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
SwipeToDelete(
|
||||
onDelete = onDelete,
|
||||
shape = RoundedCornerShape(SwipeBackgroundCorner),
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
onClick = onClick,
|
||||
color = if (row.enabled) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(deleteAction) {
|
||||
onDelete()
|
||||
true
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
color = if (row.enabled) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Keep the switch live and fully visible even when the alarm is off.
|
||||
Switch(
|
||||
checked = row.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
// "Alarm at 07:00" — a bare switch announces neither which row
|
||||
// it belongs to nor what it does.
|
||||
modifier = Modifier.semantics { contentDescription = toggleDescription },
|
||||
)
|
||||
}
|
||||
if (row.label.isNotBlank()) {
|
||||
Text(
|
||||
text = row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = textColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// Keep the switch live and fully visible even when the alarm is off.
|
||||
Switch(
|
||||
checked = row.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
// "Alarm at 07:00" — a bare switch announces neither which row
|
||||
// it belongs to nor what it does.
|
||||
modifier = Modifier.semantics { contentDescription = toggleDescription },
|
||||
text = rowSummary(row),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = supportingColor,
|
||||
)
|
||||
}
|
||||
if (row.label.isNotBlank()) {
|
||||
Text(
|
||||
text = row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = textColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = rowSummary(row),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = supportingColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The card's resting corner, so the red behind a swipe has the card's outline. */
|
||||
private val SwipeBackgroundCorner = 22.dp
|
||||
|
||||
@Composable
|
||||
private fun rowSummary(row: AlarmRowState): AnnotatedString {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The one deleted alarm an Undo can still bring back.
|
||||
*
|
||||
* A singleton rather than ViewModel state, because the two places that delete
|
||||
* are not the place that offers the Undo. The editor is popped the moment its
|
||||
* alarm is gone, so its ViewModel is cleared before any snackbar could be
|
||||
* answered; the list underneath shows the snackbar instead, and reads the
|
||||
* deletion from here. Held until the snackbar is answered or times out, so a
|
||||
* rotation or a trip to another tab re-shows it rather than losing it.
|
||||
*
|
||||
* Memory only, deliberately: an Undo that survived process death would be
|
||||
* offered with no snackbar anyone remembers.
|
||||
*/
|
||||
@Singleton
|
||||
class AlarmUndo @Inject constructor() {
|
||||
|
||||
private val slot = MutableStateFlow<DeletedAlarm?>(null)
|
||||
|
||||
/** Null when there is nothing to undo. */
|
||||
val pending: StateFlow<DeletedAlarm?> = slot.asStateFlow()
|
||||
|
||||
/** A newer deletion replaces an older one: only the latest snackbar is on screen. */
|
||||
fun offer(deleted: DeletedAlarm) {
|
||||
slot.value = deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims [deleted] for its one answer. False when it has already been
|
||||
* answered or replaced, so a second tap cannot restore it twice and a
|
||||
* stale snackbar cannot drop a newer deletion.
|
||||
*/
|
||||
fun take(deleted: DeletedAlarm): Boolean = slot.compareAndSet(deleted, null)
|
||||
}
|
||||
@@ -15,10 +15,15 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
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
|
||||
@@ -26,6 +31,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
@@ -59,6 +65,11 @@ fun AlarmsScreen(
|
||||
val context = LocalContext.current
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addAlarm = stringResource(R.string.alarms_add)
|
||||
val pendingUndo by viewModel.pendingUndo.collectAsStateWithLifecycle()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
val undoMessage = pendingUndo?.let { stringResource(R.string.alarm_deleted, formatTime(it.alarm.time)) }
|
||||
val undoLabel = stringResource(R.string.action_undo)
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
// The FAB creates the alarm and the editor edits a persisted row, so
|
||||
@@ -66,10 +77,28 @@ fun AlarmsScreen(
|
||||
viewModel.openEditor.collectLatest(onCreateAlarm)
|
||||
}
|
||||
|
||||
// Keyed on the deletion, so a newer one replaces the snackbar on screen
|
||||
// (and the older delete stands). The deletion lives in a singleton, not
|
||||
// here: one made in the editor arrives after the editor has gone, and one
|
||||
// whose snackbar a rotation or another tab interrupted is shown again.
|
||||
LaunchedEffect(pendingUndo) {
|
||||
val deleted = pendingUndo ?: return@LaunchedEffect
|
||||
val result = snackbar.showSnackbar(
|
||||
message = undoMessage.orEmpty(),
|
||||
actionLabel = undoLabel,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.onUndoDelete(deleted)
|
||||
SnackbarResult.Dismissed -> viewModel.onUndoExpired(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.tab_alarms),
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
@@ -116,6 +145,8 @@ fun AlarmsScreen(
|
||||
}
|
||||
},
|
||||
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
|
||||
// No confirmation: the snackbar's Undo is the safety net.
|
||||
onDelete = { viewModel.onDelete(row.id) },
|
||||
// An alarm whose time changed slides to its new place
|
||||
// instead of teleporting.
|
||||
modifier = animateItemMotion(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.DeletedAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -20,7 +21,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The alarm list: what it shows, the "+" that creates a row, and the row's switch. */
|
||||
/** The alarm list: what it shows, the "+" that creates a row, the row's switch and swipe, and the Undo. */
|
||||
@HiltViewModel
|
||||
class AlarmsViewModel @Inject internal constructor(
|
||||
private val alarms: AlarmRepository,
|
||||
@@ -28,6 +29,7 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
private val wall: WallClock,
|
||||
private val zones: ZoneProvider,
|
||||
private val source: AlarmListSource,
|
||||
private val undo: AlarmUndo,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<AlarmsUiState> = source.rows()
|
||||
@@ -76,6 +78,29 @@ class AlarmsViewModel @Inject internal constructor(
|
||||
viewModelScope.launch { engine.dismissUpcoming(alarmId) }
|
||||
}
|
||||
|
||||
/** The deletion the list's snackbar offers to undo, from the swipe or the editor. Null when none. */
|
||||
val pendingUndo: StateFlow<DeletedAlarm?> = undo.pending
|
||||
|
||||
/**
|
||||
* The swipe's delete. No confirmation: the snackbar's Undo is the safety
|
||||
* net, and it catches the same slip a dialog would without taxing every
|
||||
* delete the user meant.
|
||||
*/
|
||||
fun onDelete(alarmId: Long) {
|
||||
viewModelScope.launch { engine.delete(alarmId)?.let(undo::offer) }
|
||||
}
|
||||
|
||||
/** The snackbar's Undo. Claimed first, so a double tap restores once. */
|
||||
fun onUndoDelete(deleted: DeletedAlarm) {
|
||||
if (!undo.take(deleted)) return
|
||||
viewModelScope.launch { engine.restore(deleted) }
|
||||
}
|
||||
|
||||
/** The snackbar timed out or was swiped away: the delete stands. */
|
||||
fun onUndoExpired(deleted: DeletedAlarm) {
|
||||
undo.take(deleted)
|
||||
}
|
||||
|
||||
/** Dismisses a ring first when switching off (M5 D11), then re-resolves. */
|
||||
fun onToggleEnabled(alarmId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package de.jeanlucmakiola.clockula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A card the user can swipe away towards the **end** edge to delete.
|
||||
*
|
||||
* Hand-rolled rather than M3's `SwipeToDismissBox`, because the tabs are pages
|
||||
* of a `HorizontalPager` and the box claims a horizontal drag in *both*
|
||||
* directions, even one it has no anchor for. Here only the start-to-end drag
|
||||
* is claimed. The end-to-start one is left unconsumed for the pager, so a
|
||||
* swipe across a card still changes tab. Start-to-end is also the pager's
|
||||
* "previous tab" direction, which on the first tab goes nowhere, so the two
|
||||
* gestures never compete for the same drag.
|
||||
*
|
||||
* The gesture is never the only way: the caller still owes a screen reader a
|
||||
* "Delete" custom action on the card itself.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun SwipeToDelete(
|
||||
onDelete: () -> Unit,
|
||||
shape: Shape,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val currentOnDelete by rememberUpdatedState(onDelete)
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val settleSpec = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
|
||||
// +1 when "towards the end" is rightwards, -1 when it is leftwards.
|
||||
val direction = if (LocalLayoutDirection.current == LayoutDirection.Ltr) 1f else -1f
|
||||
val flingVelocity = with(LocalDensity.current) { FlingVelocity.toPx() }
|
||||
|
||||
// How far the card has travelled towards the end edge: 0 at rest, never negative.
|
||||
val travel = remember { Animatable(0f) }
|
||||
var width by remember { mutableIntStateOf(0) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onSizeChanged { width = it.width }
|
||||
.pointerInput(direction, flingVelocity) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val velocity = VelocityTracker()
|
||||
velocity.addPointerInputChange(down)
|
||||
val start = awaitHorizontalTouchSlopOrCancellation(down.id) { change, overSlop ->
|
||||
// Unconsumed, the detection keeps waiting and the pager
|
||||
// gets its chance at the same drag.
|
||||
if (overSlop * direction > 0f) change.consume()
|
||||
} ?: return@awaitEachGesture
|
||||
|
||||
// Accumulated here, not read back from [travel]: a snap is
|
||||
// launched, so the next delta can arrive before it lands.
|
||||
var position = travel.value
|
||||
var armed = position >= width * DeleteFraction
|
||||
horizontalDrag(start.id) { change ->
|
||||
velocity.addPointerInputChange(change)
|
||||
position = (position + change.positionChange().x * direction)
|
||||
.coerceIn(0f, width.toFloat())
|
||||
val nowArmed = position >= width * DeleteFraction
|
||||
// One tick as the release would start to delete, so the
|
||||
// threshold is felt rather than guessed at.
|
||||
if (nowArmed && !armed) haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
|
||||
armed = nowArmed
|
||||
val target = position
|
||||
scope.launch { travel.snapTo(target) }
|
||||
change.consume()
|
||||
}
|
||||
|
||||
val flung = velocity.calculateVelocity().x * direction >= flingVelocity
|
||||
scope.launch {
|
||||
if (width > 0 && (armed || (flung && position > 0f))) {
|
||||
travel.animateTo(width.toFloat(), settleSpec)
|
||||
currentOnDelete()
|
||||
} else {
|
||||
travel.animateTo(0f, settleSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (travel.value > 0f) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
// The card's own custom action is what a screen reader
|
||||
// hears; this glyph only names what the swipe is doing.
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.graphicsLayer { translationX = travel.value * direction }) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Past this share of the card's width, letting go deletes. */
|
||||
private const val DeleteFraction = 0.4f
|
||||
|
||||
/** A fling at least this fast towards the end deletes, however short the drag. */
|
||||
private val FlingVelocity = 800.dp
|
||||
@@ -67,10 +67,9 @@ import kotlin.time.Duration.Companion.minutes
|
||||
* never from enlarging text", and timer progress is one of the three places §8
|
||||
* sanctions a showpiece (D29).
|
||||
*
|
||||
* The top-right delete button asks nothing first: re-creating a timer costs
|
||||
* one tap on the keypad and a Start, which is faster than reading a
|
||||
* confirmation dialog would be, so there is nothing a dialog would protect
|
||||
* that undoing the mistake doesn't already cover.
|
||||
* The top-right delete button asks nothing first: the list's snackbar offers
|
||||
* an Undo that puts the timer back still counting, which covers the slip a
|
||||
* dialog would without slowing every delete the user meant.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
|
||||
@@ -12,8 +12,14 @@ import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -50,6 +56,25 @@ fun TimersScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addTimer = stringResource(R.string.timers_set_title)
|
||||
val pendingUndo by viewModel.pendingUndo.collectAsStateWithLifecycle()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val undoMessage = stringResource(R.string.timer_deleted)
|
||||
val undoLabel = stringResource(R.string.action_undo)
|
||||
|
||||
// Keyed on the deletion, so a newer one replaces the snackbar on screen and
|
||||
// the older delete stands.
|
||||
LaunchedEffect(pendingUndo) {
|
||||
val deleted = pendingUndo ?: return@LaunchedEffect
|
||||
val result = snackbar.showSnackbar(
|
||||
message = undoMessage,
|
||||
actionLabel = undoLabel,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.onUndoDelete(deleted)
|
||||
SnackbarResult.Dismissed -> viewModel.onUndoExpired(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
val loaded = state as? TimersUiState.Loaded
|
||||
val rows = loaded?.rows
|
||||
@@ -58,6 +83,7 @@ fun TimersScreen(
|
||||
title = stringResource(R.string.tab_timers),
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
@@ -110,10 +136,10 @@ fun TimersScreen(
|
||||
onReset = { viewModel.onReset(row.id) },
|
||||
onAddMinute = { viewModel.onAddMinute(row.id) },
|
||||
onSubtractMinute = { viewModel.onSubtractMinute(row.id) },
|
||||
// No confirmation: re-creating a timer costs one tap and a
|
||||
// Start, faster than reading a dialog would be (D2's delete
|
||||
// still routes through the engine, so a ringing timer is
|
||||
// stopped cleanly rather than deleted into a stuck ring).
|
||||
// No confirmation: the snackbar's Undo puts the timer back,
|
||||
// still counting (D2's delete still routes through the
|
||||
// engine, so a ringing timer is stopped cleanly rather than
|
||||
// deleted into a stuck ring).
|
||||
onDelete = { viewModel.onDelete(row.id) },
|
||||
// A timer that finishes stays where it is (D22); the motion
|
||||
// is for a row arriving or leaving.
|
||||
|
||||
@@ -5,11 +5,14 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -78,8 +81,33 @@ class TimersViewModel @Inject internal constructor(
|
||||
|
||||
fun onSubtractMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId, -1.minutes) }
|
||||
|
||||
/** Through the engine, so a *ringing* timer cannot be deleted into a stuck ring (D2). */
|
||||
fun onDelete(timerId: Long) = launchOnEngine { engine.delete(timerId) }
|
||||
private val undoSlot = MutableStateFlow<Timer?>(null)
|
||||
|
||||
/**
|
||||
* The deleted timer the snackbar offers to bring back. Null when none. Held
|
||||
* here, not in the screen, so a rotation or a trip to another tab re-shows
|
||||
* the snackbar rather than losing the Undo; a newer delete replaces it.
|
||||
*/
|
||||
val pendingUndo: StateFlow<Timer?> = undoSlot.asStateFlow()
|
||||
|
||||
/**
|
||||
* Through the engine, so a *ringing* timer cannot be deleted into a stuck
|
||||
* ring (D2). Still no confirmation; the snackbar's Undo now covers the slip.
|
||||
*/
|
||||
fun onDelete(timerId: Long) = launchOnEngine {
|
||||
engine.delete(timerId)?.let { undoSlot.value = it }
|
||||
}
|
||||
|
||||
/** The snackbar's Undo. Claimed first, so a double tap restores once. */
|
||||
fun onUndoDelete(timer: Timer) {
|
||||
if (!undoSlot.compareAndSet(timer, null)) return
|
||||
launchOnEngine { engine.restore(timer) }
|
||||
}
|
||||
|
||||
/** The snackbar timed out or was swiped away: the delete stands. */
|
||||
fun onUndoExpired(timer: Timer) {
|
||||
undoSlot.compareAndSet(timer, null)
|
||||
}
|
||||
|
||||
fun onSavePreset(duration: Duration) {
|
||||
viewModelScope.launch { settings.addTimerPreset(duration) }
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<string name="timer_editor_sound">Sound</string>
|
||||
<string name="timer_editor_delete">Delete timer</string>
|
||||
<string name="timer_editor_delete_confirm">Delete this timer?</string>
|
||||
<string name="timer_deleted">Timer deleted</string>
|
||||
|
||||
<!-- M6: the timer notification. One channel, one id, however many timers. -->
|
||||
<string name="channel_timers_name">Timers</string>
|
||||
@@ -135,9 +136,9 @@
|
||||
<string name="editor_snooze_limit">Snooze limit</string>
|
||||
<string name="editor_dismiss_challenge">To dismiss</string>
|
||||
<string name="editor_delete">Delete alarm</string>
|
||||
<string name="editor_delete_title">Delete this alarm?</string>
|
||||
<string name="editor_delete_message">The %1$s alarm will be removed.</string>
|
||||
<string name="alarm_deleted">%1$s alarm deleted</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_undo">Undo</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="action_ok">OK</string>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.clockula.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.testing.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.clockula.testing.ringState
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The engine's delete and its Undo: the ring and the snooze notification go
|
||||
* with the row, and what comes back is re-registered and never rings stale.
|
||||
*/
|
||||
class AlarmEngineDeleteTest {
|
||||
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
|
||||
@Test
|
||||
fun `deleting an alarm that is not there returns nothing to undo`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
|
||||
assertThat(harness.engine.delete(1L)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting a snoozed alarm clears its notification and frees the slot`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.engine.snooze(1L)
|
||||
|
||||
harness.engine.delete(1L)
|
||||
|
||||
assertThat(listOf(harness.notifier.cleared.last(), harness.scheduler.next, harness.stateDao.stored))
|
||||
.isEqualTo(listOf(1L, null, emptyList<Any>()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing the delete of a snoozed alarm brings the snooze and its notification back`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.engine.snooze(1L)
|
||||
val until = sevenToday + 10.minutes
|
||||
val deleted = checkNotNull(harness.engine.delete(1L))
|
||||
harness.notifier.shown.clear()
|
||||
|
||||
val restored = harness.engine.restore(deleted)
|
||||
|
||||
assertThat(listOf(restored, harness.scheduler.next?.fireAt, harness.notifier.shown))
|
||||
.isEqualTo(listOf(true, until, listOf(1L to until)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snooze that lapsed while the alarm was gone comes back closed, with no notification`(@TempDir dir: Path) =
|
||||
runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.engine.snooze(1L)
|
||||
val deleted = checkNotNull(harness.engine.delete(1L))
|
||||
harness.notifier.shown.clear()
|
||||
harness.wallClock.instant = sevenToday + 3.minutes * 60
|
||||
|
||||
harness.engine.restore(deleted)
|
||||
|
||||
assertThat(harness.notifier.shown to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(emptyList<Any>() to sevenTomorrow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the ringing alarm stops the ring and its backstop`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
|
||||
harness.engine.delete(1L)
|
||||
|
||||
assertThat(listOf(harness.ring.events.last(), harness.ring.ringing.value, harness.scheduler.autoSilence))
|
||||
.isEqualTo(listOf(RingEvent.Stopped(1L), null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing one-shot comes back switched off and silent`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val deleted = checkNotNull(harness.engine.delete(1L))
|
||||
|
||||
harness.engine.restore(deleted)
|
||||
|
||||
val alarm = checkNotNull(harness.alarms.find(1L))
|
||||
assertThat(listOf(alarm.enabled, harness.ring.ringing.value, harness.scheduler.next))
|
||||
.isEqualTo(listOf(false, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing transient alarm leaves nothing to undo`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, deleteAfterUse = true)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
|
||||
assertThat(harness.engine.delete(1L) to harness.alarmDao.stored).isEqualTo(null to emptyList<Any>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo writes nothing when the id has been taken in the meantime`(@TempDir dir: Path) = runTest {
|
||||
val harness = alarmEngineHarness(dir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val deleted = checkNotNull(harness.engine.delete(1L))
|
||||
val imported = alarmAt(id = 1L, hour = 9, label = "From a backup")
|
||||
harness.alarmDao.insert(AlarmMapper.toEntity(imported))
|
||||
harness.states.save(ringState(alarmId = 1L, handledOccurrence = sevenToday))
|
||||
|
||||
val restored = harness.engine.restore(deleted)
|
||||
|
||||
assertThat(listOf(restored, harness.alarms.find(1L), harness.states.state(1L).handledOccurrence))
|
||||
.isEqualTo(listOf(false, imported, sevenToday))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.clockula.testing.ringState
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** What an Undo puts back, and the one rule that matters: never a stale ring. */
|
||||
class AlarmDeletionTest {
|
||||
|
||||
private val seven = offsetInstant("2026-06-10T07:00+02:00")
|
||||
private val tenPast = offsetInstant("2026-06-10T07:10+02:00")
|
||||
|
||||
@Test
|
||||
fun `an idle alarm comes back exactly as it was`() {
|
||||
val alarm = alarmAt(hour = 7, label = "Gym", repeatDays = RepeatDays.WEEKDAYS, skipNextOccurrence = true)
|
||||
val state = ringState(handledOccurrence = seven)
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, state)).isEqualTo(DeletedAlarm(alarm, state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snoozed alarm comes back snoozed`() {
|
||||
val alarm = alarmAt(hour = 7)
|
||||
val state = ringState(snoozedUntil = tenPast, snoozeCount = 1, handledOccurrence = seven)
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, state)).isEqualTo(DeletedAlarm(alarm, state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing repeating alarm comes back with its cycle closed and its watermark kept`() {
|
||||
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val state = ringState(ringingSince = seven, snoozeCount = 2, handledOccurrence = seven)
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, state))
|
||||
.isEqualTo(DeletedAlarm(alarm, ringState(handledOccurrence = seven)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing one-shot comes back switched off, as a dismissal would leave it`() {
|
||||
val alarm = alarmAt(hour = 7)
|
||||
val state = ringState(ringingSince = seven, handledOccurrence = seven)
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, state))
|
||||
.isEqualTo(DeletedAlarm(alarm.copy(enabled = false), ringState(handledOccurrence = seven)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing transient one-shot has nothing to come back as`() {
|
||||
val alarm = alarmAt(hour = 7, deleteAfterUse = true)
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, ringState(ringingSince = seven))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient alarm that is not ringing can still be undone`() {
|
||||
val alarm = alarmAt(hour = 7, deleteAfterUse = true)
|
||||
val state = ringState()
|
||||
|
||||
assertThat(AlarmDeletion.snapshot(alarm, state)).isEqualTo(DeletedAlarm(alarm, state))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.clockula.domain.timer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.pausedTimer
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** What an Undo puts back: everything as it was, except a finished timer's ring. */
|
||||
class TimerDeletionTest {
|
||||
|
||||
private val now = 1_000.seconds
|
||||
|
||||
@Test
|
||||
fun `a running timer comes back with its anchors, still counting`() {
|
||||
val timer = runningTimerWith(left = 2.minutes, elapsedNow = now, wallNow = T0, label = "Tea", sortOrder = 3)
|
||||
|
||||
assertThat(TimerDeletion.snapshot(timer, now, T0)).isEqualTo(timer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paused and idle timers come back as they were`() {
|
||||
val paused = pausedTimer(remaining = 90.seconds)
|
||||
val idle = idleTimer(duration = 10.minutes)
|
||||
|
||||
assertThat(listOf(TimerDeletion.snapshot(paused, now, T0), TimerDeletion.snapshot(idle, now, T0)))
|
||||
.containsExactly(paused, idle)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished timer comes back reset, not ringing`() {
|
||||
val timer = expiredTimer(duration = 5.minutes, label = "Eggs")
|
||||
|
||||
assertThat(TimerDeletion.snapshot(timer, now, T0)).isEqualTo(idleTimer(duration = 5.minutes, label = "Eggs"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running timer already past its end counts as finished`() {
|
||||
val timer = runningTimerWith(left = 1.minutes, elapsedNow = now - 2.minutes, wallNow = T0)
|
||||
|
||||
assertThat(TimerDeletion.snapshot(timer, now, T0)).isEqualTo(idleTimer())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished transient timer has nothing to come back as`() {
|
||||
assertThat(TimerDeletion.snapshot(expiredTimer(deleteAfterUse = true), now, T0)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ class RecordingTimerRepository : TimerRepository {
|
||||
override suspend fun create(draft: TimerDraft): Long = TODO("not used by the reboot gate")
|
||||
override suspend fun rename(id: Long, label: String): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun delete(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun restore(timer: Timer): Boolean = TODO("not used by the reboot gate")
|
||||
override suspend fun start(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun pause(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
override suspend fun reset(id: Long): Unit = TODO("not used by the reboot gate")
|
||||
|
||||
@@ -60,6 +60,9 @@ open class FakeAlarmDao(
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun insertIfAbsent(alarm: AlarmEntity): Long =
|
||||
if (rows.value.any { it.id == alarm.id }) -1L else insert(alarm)
|
||||
|
||||
override suspend fun update(alarm: AlarmEntity) {
|
||||
rows.update { rows -> rows.map { if (it.id == alarm.id) alarm else it } }
|
||||
}
|
||||
@@ -109,6 +112,9 @@ open class FakeTimerDao(initial: List<TimerEntity> = emptyList()) : TimerDao() {
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun insertIfAbsent(timer: TimerEntity): Long =
|
||||
if (rows.value.any { it.id == timer.id }) -1L else insert(timer)
|
||||
|
||||
override suspend fun update(timer: TimerEntity) {
|
||||
rows.update { rows -> rows.map { if (it.id == timer.id) timer else it } }
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ class FakeTimerRepository(
|
||||
rows.update { all -> all.filterNot { it.id == id } }
|
||||
}
|
||||
|
||||
override suspend fun restore(timer: Timer): Boolean {
|
||||
log += "restore(${timer.id})"
|
||||
if (rows.value.any { it.id == timer.id }) return false
|
||||
rows.update { it + timer }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun start(id: Long) {
|
||||
log += "start($id)"
|
||||
val now = elapsed.elapsedRealtime()
|
||||
|
||||
@@ -51,7 +51,7 @@ class AlarmEditorViewModelTest {
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
|
||||
private fun TestScope.editor(harness: AlarmEngineHarness, alarmId: Long = 1L) =
|
||||
private fun TestScope.editor(harness: AlarmEngineHarness, alarmId: Long = 1L, undo: AlarmUndo = AlarmUndo()) =
|
||||
AlarmEditorViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to alarmId)),
|
||||
alarms = harness.alarms,
|
||||
@@ -59,6 +59,7 @@ class AlarmEditorViewModelTest {
|
||||
engine = harness.engine,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
undo = undo,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
@@ -655,6 +656,27 @@ class AlarmEditorViewModelTest {
|
||||
.isEqualTo(RingEvent.Stopped(1L) to emptyList<Any>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting hands the alarm to the list's undo, and the undo puts it back`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val undo = AlarmUndo()
|
||||
val viewModel = editor(harness, undo = undo)
|
||||
advanceUntilIdle()
|
||||
val before = harness.alarmDao.stored
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
val deleted = checkNotNull(undo.pending.value)
|
||||
// The editor's ViewModel is gone by now; the list's restores.
|
||||
harness.engine.restore(deleted)
|
||||
|
||||
assertThat(harness.alarmDao.stored to harness.scheduler.next?.alarmId)
|
||||
.isEqualTo(before to 1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the last alarm clears the slot`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
|
||||
@@ -46,12 +46,13 @@ class AlarmsViewModelTest {
|
||||
* `collectAsStateWithLifecycle`; without one there is no subscriber and
|
||||
* nothing upstream would run.
|
||||
*/
|
||||
private fun TestScope.viewModel(harness: AlarmEngineHarness) = AlarmsViewModel(
|
||||
private fun TestScope.viewModel(harness: AlarmEngineHarness, undo: AlarmUndo = AlarmUndo()) = AlarmsViewModel(
|
||||
alarms = harness.alarms,
|
||||
engine = harness.engine,
|
||||
wall = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
|
||||
undo = undo,
|
||||
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
|
||||
|
||||
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
|
||||
@@ -346,6 +347,104 @@ class AlarmsViewModelTest {
|
||||
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
// --- swipe to delete, and the Undo ---
|
||||
|
||||
@Test
|
||||
fun `a swiped alarm is deleted at once and offered back`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.rows().map { it.id } to viewModel.pendingUndo.value?.alarm?.id)
|
||||
.isEqualTo(listOf(2L) to 1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo restores the alarm with its id and settings and re-registers it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, label = "Gym", repeatDays = RepeatDays.WEEKDAYS, skipNextOccurrence = true)
|
||||
harness.engine.reschedule()
|
||||
val before = harness.alarmDao.stored
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
check(harness.scheduler.next == null)
|
||||
|
||||
viewModel.onUndoDelete(checkNotNull(viewModel.pendingUndo.value))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(Triple(harness.alarmDao.stored, harness.scheduler.next?.alarmId, viewModel.pendingUndo.value))
|
||||
.isEqualTo(Triple(before, 1L, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second tap on undo restores once`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
val deleted = checkNotNull(viewModel.pendingUndo.value)
|
||||
|
||||
viewModel.onUndoDelete(deleted)
|
||||
advanceUntilIdle()
|
||||
harness.alarms.delete(1L)
|
||||
viewModel.onUndoDelete(deleted)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed-out snackbar lets the delete stand`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
val deleted = checkNotNull(viewModel.pendingUndo.value)
|
||||
|
||||
viewModel.onUndoExpired(deleted)
|
||||
viewModel.onUndoDelete(deleted)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored to viewModel.pendingUndo.value).isEqualTo(emptyList<Any>() to null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swiping away the ringing alarm silences it, and undo does not ring it again`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
viewModel.onUndoDelete(checkNotNull(viewModel.pendingUndo.value))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.ring.events.last(),
|
||||
harness.ring.ringing.value,
|
||||
harness.scheduler.autoSilence,
|
||||
harness.scheduler.next?.fireAt,
|
||||
),
|
||||
).isEqualTo(listOf(RingEvent.Stopped(1L), null, null, offsetInstant("2026-06-11T07:00+02:00")))
|
||||
}
|
||||
|
||||
// --- M9: the AlarmClock contract's dismissal, and the chooser dialog's ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -158,6 +158,43 @@ class TimersViewModelTest {
|
||||
assertThat(harness.ringState.current()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo puts a deleted running timer back, still counting and registered`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val timer = harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
|
||||
harness.engine.resync()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
check(harness.scheduler.expiryAt == null)
|
||||
|
||||
viewModel.onUndoDelete(checkNotNull(viewModel.pendingUndo.value))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(listOf(harness.stored(1L), harness.scheduler.expiryAt, harness.service.active, viewModel.pendingUndo.value))
|
||||
.isEqualTo(listOf(timer, uptime + 4.minutes, true, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo brings a finished timer back reset, not ringing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.given(expiredTimer(id = 1L))
|
||||
harness.ringState.set(T0 - 30.seconds)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onUndoDelete(checkNotNull(viewModel.pendingUndo.value))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(listOf(harness.stored(1L)?.state, harness.ringState.current()))
|
||||
.isEqualTo(listOf(TimerState.IDLE, null))
|
||||
}
|
||||
|
||||
/** §5.12 #8 */
|
||||
@Test
|
||||
fun `the stored presets are part of the loaded state`(@TempDir tempDir: Path) =
|
||||
|
||||
+63
-8
@@ -1060,7 +1060,7 @@ M10 adds no `<uses-permission>`.
|
||||
| A user-configurable auto-silence duration, an unlimited-snooze option, per-alarm auto-silence | not planned for v1 |
|
||||
| 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 |
|
||||
| A "stop all" for several expired timers. Each expired timer is acknowledged on its own | 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 |
|
||||
| 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 |
|
||||
@@ -1688,7 +1688,8 @@ the backstop disarmed by its own guard**. Both ViewModels therefore call
|
||||
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
|
||||
```
|
||||
|
||||
before `setEnabled(id, false)` and before `delete(id)`. It reads storage under
|
||||
before `setEnabled(id, false)`. Deleting goes further and is an engine verb of
|
||||
its own (`AlarmEngine.delete`, below), which stops the ring itself. The check reads storage under
|
||||
the engine's own mutex, so it cannot race the fire it is asking about, and it is
|
||||
deliberately **conditional**: dismissing unconditionally would also drop a
|
||||
merely *snoozed* alarm's pending snooze, which is wrong for a label edit.
|
||||
@@ -1712,10 +1713,52 @@ a **repeating** alarm. The resolver answers a one-shot-with-skip by disabling th
|
||||
alarm, and a switch labelled "skip" that silently switched the alarm off would
|
||||
be a lie; the user who wants that has the enable switch.
|
||||
|
||||
Delete lives in the editor behind a confirmation that names the alarm, and there
|
||||
is deliberately no undo chip: the delete happens on the editor, so an undo chip
|
||||
would have to live on the list, and an accidentally deleted alarm is discovered
|
||||
at 07:00 rather than now; the cheap moment to ask is before.
|
||||
### Delete, and the Undo
|
||||
|
||||
An alarm is deleted by swiping its card towards the end edge, by the card's
|
||||
**Delete** custom accessibility action (the same verb for TalkBack and switch
|
||||
access, where there is no swipe), or from the editor's top bar. None of them
|
||||
asks first. Each is followed by a snackbar on the list ("07:00 alarm deleted",
|
||||
**Undo**), as in Google Clock. v1 asked before instead, on the argument that a
|
||||
mistaken delete is found at 07:00 rather than now. But a dialog catches no more
|
||||
than the snackbar does: both only catch the slip noticed in the moment, and a
|
||||
confirmation the user taps through out of habit catches less. The Undo does it
|
||||
without taxing every delete the user meant, so the editor's dialog went too
|
||||
rather than leaving two delete paths with different safety nets.
|
||||
|
||||
`AlarmEngine.delete(id)` does the work under the engine's lock: it stops a
|
||||
ringing alarm's service and auto-silence backstop (the global slots of §11),
|
||||
clears a snoozed alarm's notification, deletes the row (the ring state
|
||||
cascades) and re-resolves the slot. It returns a `DeletedAlarm`, the row and
|
||||
its ring state as `AlarmDeletion.snapshot` decided, and `AlarmEngine.restore`
|
||||
puts that back **under the same id** (`alarms.id` is `AUTOINCREMENT`, so no new
|
||||
alarm can have taken it) and re-resolves, which is what re-registers it with
|
||||
AlarmManager. The snapshot keeps every setting and the ring state as it was, so a
|
||||
snoozed alarm comes back snoozed, notification included, and the handled-occurrence
|
||||
watermark still stops a just-closed occurrence ringing twice. A **ringing**
|
||||
alarm is the exception: deleting it was a dismissal in all but name, so it comes
|
||||
back as a dismissal would have left it (cycle closed, a one-shot switched off)
|
||||
and never rings again, and a ringing transient alarm, which that dismissal would
|
||||
have deleted anyway, offers no Undo at all. A snooze that lapsed while the alarm
|
||||
was gone is closed by the resolver on restore, and gets no notification. An id
|
||||
that a backup restore claimed in the meantime makes the restore write nothing.
|
||||
|
||||
The pending deletion lives in `AlarmUndo`, a singleton, not in either
|
||||
ViewModel. The editor is popped the moment its alarm is gone, so its ViewModel
|
||||
is cleared before any snackbar could be answered; the list underneath reads the
|
||||
deletion from the singleton and shows the snackbar. It is cleared only when the
|
||||
snackbar is answered or times out, or a newer deletion replaces it, so a
|
||||
rotation or a trip to another tab re-shows the snackbar rather than losing the
|
||||
Undo. It is memory only: an Undo that outlived process death would be offered
|
||||
with no snackbar anyone remembers.
|
||||
|
||||
The swipe is hand-rolled (`ui/common/SwipeToDelete`) rather than M3's
|
||||
`SwipeToDismissBox`, because the tabs are pages of a `HorizontalPager` and the
|
||||
box claims a horizontal drag in both directions, even one it has no anchor for.
|
||||
Only the start-to-end drag is claimed; the end-to-start one is left unconsumed,
|
||||
so a swipe across a card still changes tab. Start-to-end is also the pager's
|
||||
"previous tab" direction, which from Alarms, the first tab, goes nowhere, so the
|
||||
two gestures never want the same drag.
|
||||
|
||||
---
|
||||
|
||||
@@ -1751,6 +1794,17 @@ already met once, and the cheapest way not to have it again is to make the wrong
|
||||
call impossible to write. **Configuration** edits (`rename`, `setDuration`,
|
||||
`setRingtoneUri`) stay on `TimerRepository` and the editor calls them directly.
|
||||
|
||||
A card's corner delete asks nothing first, and the list's snackbar offers an
|
||||
**Undo**. `TimerEngine.delete` returns the row as `TimerDeletion.snapshot`
|
||||
decided, and `TimerEngine.restore` puts it back under its own id and place and
|
||||
resyncs. A running timer comes back with its anchors, so it has kept counting
|
||||
while it was gone, and one whose end passed in the meantime rings, as it would
|
||||
have had it never been deleted. A timer that had already finished comes back
|
||||
reset: deleting it silenced it, which is a dismissal, so it does not ring a
|
||||
second time (a finished transient timer offers no Undo). The pending row is
|
||||
`TimersViewModel` state, since both the delete and the snackbar are on the
|
||||
list; the editor's delete keeps its confirmation.
|
||||
|
||||
`TimerRepository` deliberately has **no generic `edit(id, transform)`** the way
|
||||
`AlarmRepository` does: a timer row carries three derived anchors whose
|
||||
consistency is the whole of §5, and a caller handed a `(Timer) -> Timer` can
|
||||
@@ -2462,8 +2516,9 @@ an id the list does not hold, and always a permutation of its input; a move that
|
||||
changes nothing writes nothing.
|
||||
|
||||
Remove is a long-press plus a confirmation that names the city, with
|
||||
deliberately **no undo chip**. That is the same trade M5 and M6 made: the cheap
|
||||
moment to ask is before. The pending id is `rememberSaveable`, so a rotation
|
||||
deliberately **no undo chip**. That was the trade M5 and M6 made too, and the
|
||||
alarm and timer lists have since moved to an Undo (§13, §14); removing a city
|
||||
still asks first. The pending id is `rememberSaveable`, so a rotation
|
||||
mid-confirmation does not lose the question.
|
||||
|
||||
A row whose zone the device's tzdata no longer knows shows **no time, no offset
|
||||
|
||||
+3
-2
@@ -247,7 +247,8 @@ line ("in 9h 12m") on each row. Built on `GroupedSurface`/`GroupedRow`.
|
||||
|
||||
Knowingly open: no hand reordering of alarms (the time order answers it, and
|
||||
`alarms` has no `sort_order` column), no multi-select or bulk delete, no undo
|
||||
chip after a delete (traded for a confirmation that names the alarm), no
|
||||
chip after a delete (traded for a confirmation that names the alarm; since
|
||||
replaced by swipe to delete with Undo, #20), no
|
||||
repeat presets, and no per-alarm auto-silence (still M10 at the earliest).
|
||||
Editing the app-wide defaults every override row offers to follow is M10's
|
||||
settings screen. The seven new instrumentation tests compile in the gate but
|
||||
@@ -316,7 +317,7 @@ 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; added since, #20), no ramp on a timer's ring, 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