diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..397f0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt index c882f88..0ec1af1 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt @@ -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() + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt index d9ff0a9..82317c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/AlarmEngine.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt index e638962..d8819b4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmDao.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt index d1b683c..05b92d7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt index 25f0656..950009e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt @@ -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()) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt index 3e93e70..dadd79e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerDao.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt index 8107091..515a0e7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt index 8d5d4e5..eaeaaec 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt @@ -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() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletion.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletion.kt new file mode 100644 index 0000000..2df40b9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletion.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletion.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletion.kt new file mode 100644 index 0000000..3348515 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletion.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt index 04b27cc..71f8155 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerEngine.kt @@ -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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt index 3e4a3dc..1b3fa7a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -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)) } - }, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index be4879f..7605bf1 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index c47eb5a..7e3752b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt new file mode 100644 index 0000000..63e04e8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmUndo.kt @@ -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(null) + + /** Null when there is nothing to undo. */ + val pending: StateFlow = 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index bdb6c1d..d630a79 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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(), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt index d17e9fe..00f33c4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -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 = 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 = 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt new file mode 100644 index 0000000..ec4f8db --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/SwipeToDelete.kt @@ -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() + // +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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt index d99eb4e..93d02bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimerRow.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 0741321..36b1db0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt index 513164e..3d07d9f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModel.kt @@ -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(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 = 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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..7904c6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Sound Delete timer Delete this timer? + Timer deleted Timers @@ -135,9 +136,9 @@ Snooze limit To dismiss Delete alarm - Delete this alarm? - The %1$s alarm will be removed. + %1$s alarm deleted Delete + Undo Cancel OK diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineDeleteTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineDeleteTest.kt new file mode 100644 index 0000000..d1cca3b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/alarm/AlarmEngineDeleteTest.kt @@ -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())) + } + + @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() 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()) + } + + @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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletionTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletionTest.kt new file mode 100644 index 0000000..b7a973c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmDeletionTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletionTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletionTest.kt new file mode 100644 index 0000000..53fd87f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDeletionTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index 591ec13..8143c25 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -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") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index b56b28e..2a168ea 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -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 = 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 } } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt index bc59532..c3adf5d 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt @@ -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() diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt index 4e9c0b9..5f5146b 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -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()) } + @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) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt index 1317b97..9abed55 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -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 = (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() 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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt index 3dc0fe1..bc90f75 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/timers/TimersViewModelTest.kt @@ -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) = diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97ef4e8..8eec71d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1060,7 +1060,7 @@ M10 adds no ``. | 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb7731b..d53556f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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