diff --git a/LESSONS.md b/LESSONS.md index a283981..38d4342 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -1340,3 +1340,32 @@ whole file on merge to main in one transaction: never exposed to pull requests. - `CREATE TABLE IF NOT EXISTS` never widens a constraint on an existing table, so a changed CHECK must be re-asserted with an explicit DROP/ADD lower in the file. + +--- + +## 59. A user-configurable gesture is a per-list allow-list, read live inside the remembered callback + +Settings › Swipe actions lets each list (chores, tasks, memos) map left and +right swipes to Done / Snooze / Archive / Delete / Nothing. Two things kept it +from becoming a bug farm: + +- **Each list declares what it can offer, and the stored value is sanitised at + read time** (`SwipeSubject.offered` + `resolve`). A chore has no delete (it is + a `tags` row; it retires by archiving), a task has no snooze. Storing the raw + enum name and dropping anything the list does not offer when reading means a + value written by a newer build, or a hand-edited preference, degrades to the + list's default for that direction instead of crashing a `when` or firing an + action the list cannot do. The defaults are the pre-setting behaviour, pinned + by `SwipeActionTest` so nobody changes them by accident. +- **`rememberSwipeToDismissBoxState` captures `confirmValueChange` once per + card**, the same trap as `ModalBottomSheet`'s `onDismissRequest` (#49). Read + the setting and the handler inside it through `rememberUpdatedState`, or a + card that stays in composition keeps acting on the old mapping after the user + changes it. The `enableDismissFrom*` flags are plain parameters and update on + every recomposition, so only the callback needs the indirection. + +A direction set to Nothing disables the drag (`enableDismissFrom* = false`) +rather than swallowing the event, so the card does not slide for no reason; and +the reveal panel's label is a per-card function of the action (Wake vs Snooze, +Restore vs Done, Turn off for a tag-alarm), not a property of the enum, which +keeps the enum free of UI and testable. diff --git a/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt b/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt index bb05876..0f0fd38 100644 --- a/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt +++ b/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt @@ -3,6 +3,7 @@ package com.mapgie.dash.data.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.time.DayOfWeek +import java.time.Duration import java.time.Instant import java.time.LocalTime import java.time.ZoneId @@ -197,3 +198,17 @@ fun ReminderDto.afterRing(now: Instant, zone: ZoneId = ZoneId.systemDefault()): */ fun ReminderDto.afterDone(now: Instant): ReminderDto = if (!repeats && !isTagAlarm) copy(completedAt = now.toString()) else this + +/** + * The record after a swipe-to-snooze on the list at [now]: the next ring moves + * [by] later than whichever is later, now or the ring it was waiting for. A + * once-only memo that has already rung (or was marked done) comes back to life + * and rings again; a repeating memo keeps its rota, only this ring shifts. A + * tag-alarm is not snoozed this way (its morning is set by its ring times), so + * it is returned unchanged. + */ +fun ReminderDto.snoozedBy(by: Duration, now: Instant): ReminderDto { + if (isTagAlarm) return this + val waitingFor = remindAtInstant()?.takeIf { it.isAfter(now) } ?: now + return copy(remindAt = waitingFor.plus(by).toString(), reminded = false, completedAt = null) +} diff --git a/app/src/main/java/com/mapgie/dash/data/model/SwipeAction.kt b/app/src/main/java/com/mapgie/dash/data/model/SwipeAction.kt new file mode 100644 index 0000000..6c6a70a --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/data/model/SwipeAction.kt @@ -0,0 +1,117 @@ +package com.mapgie.dash.data.model + +/** + * What a horizontal swipe on a list card does. Chosen per list and per + * direction under Settings › Swipe actions. [displayName] is the resting word; + * a card may reword it for its current state (Wake, Restore, Turn off). + */ +enum class SwipeAction(val displayName: String) { + /** The swipe is turned off for that direction. */ + NONE("Nothing"), + /** Log a chore, tick a task, mark a memo done (turn a tag-alarm off). */ + DONE("Done"), + /** Snooze a chore for its default duration; push a memo's next ring back an hour. */ + SNOOZE("Snooze"), + /** Archive without logging or completing; the item leaves the list but keeps its history. */ + ARCHIVE("Archive"), + /** Remove for good, behind a confirmation. */ + DELETE("Delete"); + + companion object { + fun fromName(name: String?): SwipeAction? = entries.firstOrNull { it.name == name } + } +} + +/** The two horizontal swipes in the user's words, independent of the Compose enum names (LESSONS #51). */ +enum class SwipeDirection(val displayName: String) { + LEFT("Swipe left"), + RIGHT("Swipe right"), +} + +/** The two swipes on one list's cards. */ +data class SwipePair(val left: SwipeAction, val right: SwipeAction) { + fun action(direction: SwipeDirection): SwipeAction = when (direction) { + SwipeDirection.LEFT -> left + SwipeDirection.RIGHT -> right + } + + fun with(direction: SwipeDirection, action: SwipeAction): SwipePair = when (direction) { + SwipeDirection.LEFT -> copy(left = action) + SwipeDirection.RIGHT -> copy(right = action) + } + + /** Whether the card should let a drag start in [direction] at all. */ + fun enabled(direction: SwipeDirection): Boolean = action(direction) != SwipeAction.NONE + + /** This pair with [action] turned off wherever it appears (a tag-alarm's card has no snooze). */ + fun without(action: SwipeAction): SwipePair = SwipePair( + left = if (left == action) SwipeAction.NONE else left, + right = if (right == action) SwipeAction.NONE else right, + ) +} + +/** + * Each list with swipeable cards: the actions it can offer (a chore has no + * delete, it retires by archiving; a task has no snooze) and what it does out + * of the box, which matches the behaviour before the setting existed. + */ +enum class SwipeSubject( + val key: String, + val offered: List, + val default: SwipePair, +) { + CHORES( + key = "chores", + offered = listOf(SwipeAction.NONE, SwipeAction.DONE, SwipeAction.SNOOZE, SwipeAction.ARCHIVE), + default = SwipePair(left = SwipeAction.SNOOZE, right = SwipeAction.DONE), + ), + TASKS( + key = "tasks", + offered = listOf(SwipeAction.NONE, SwipeAction.DONE, SwipeAction.ARCHIVE, SwipeAction.DELETE), + default = SwipePair(left = SwipeAction.NONE, right = SwipeAction.DONE), + ), + MEMOS( + key = "memos", + offered = listOf(SwipeAction.NONE, SwipeAction.DONE, SwipeAction.SNOOZE, SwipeAction.ARCHIVE, SwipeAction.DELETE), + default = SwipePair(left = SwipeAction.DONE, right = SwipeAction.DELETE), + ); + + /** The resting word for [action] on this list's cards and in Settings ("Log" for a chore's Done). */ + fun label(action: SwipeAction): String = when { + this == CHORES && action == SwipeAction.DONE -> "Log" + else -> action.displayName + } + + /** [pair] with anything this list does not offer replaced by its default for that direction. */ + fun sanitise(pair: SwipePair): SwipePair = SwipePair( + left = pair.left.takeIf { it in offered } ?: default.left, + right = pair.right.takeIf { it in offered } ?: default.right, + ) + + /** Stored names back into a pair: a missing or unknown name falls back to the default for that direction. */ + fun resolve(leftName: String?, rightName: String?): SwipePair = sanitise( + SwipePair( + left = SwipeAction.fromName(leftName) ?: default.left, + right = SwipeAction.fromName(rightName) ?: default.right, + ) + ) +} + +/** Settings › Swipe actions: one pair per list. */ +data class SwipeSettings( + val chores: SwipePair = SwipeSubject.CHORES.default, + val tasks: SwipePair = SwipeSubject.TASKS.default, + val memos: SwipePair = SwipeSubject.MEMOS.default, +) { + operator fun get(subject: SwipeSubject): SwipePair = when (subject) { + SwipeSubject.CHORES -> chores + SwipeSubject.TASKS -> tasks + SwipeSubject.MEMOS -> memos + } + + fun with(subject: SwipeSubject, pair: SwipePair): SwipeSettings = when (subject) { + SwipeSubject.CHORES -> copy(chores = subject.sanitise(pair)) + SwipeSubject.TASKS -> copy(tasks = subject.sanitise(pair)) + SwipeSubject.MEMOS -> copy(memos = subject.sanitise(pair)) + } +} diff --git a/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt b/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt index e611bb8..654ddb9 100644 --- a/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt +++ b/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt @@ -10,6 +10,10 @@ import com.mapgie.dash.data.model.ChoreSortKey import com.mapgie.dash.data.model.ChoreColourAxes import com.mapgie.dash.data.model.ColourChoresBy import com.mapgie.dash.data.model.ReminderLabelStyle +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipeSettings +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.Severity import com.mapgie.dash.data.model.ReminderSortKey import com.mapgie.dash.data.model.SortOrder @@ -85,6 +89,8 @@ data class AppSettings( val fabOrder: List = DEFAULT_FAB_ORDER, // Wording used for the reminders feature throughout the UI val reminderLabel: ReminderLabelStyle = ReminderLabelStyle.REMINDERS, + /** Settings › Swipe actions: what swiping a card left or right does, per list. */ + val swipeActions: SwipeSettings = SwipeSettings(), // Whether the first-run welcome sheet (chores vs tasks vs memos) has been dismissed val helpSeen: Boolean = false, ) @@ -145,6 +151,9 @@ class SettingsRepository @Inject constructor( fun severitySwatch(severity: Severity) = stringPreferencesKey("severity_swatch_${severity.name.lowercase()}") + + fun swipeAction(subject: SwipeSubject, direction: SwipeDirection) = + stringPreferencesKey("swipe_${subject.key}_${direction.name.lowercase()}") } val settings: Flow = context.dataStore.data @@ -223,6 +232,11 @@ class SettingsRepository @Inject constructor( reminderLabel = prefs[Keys.REMINDER_LABEL] ?.let { runCatching { ReminderLabelStyle.valueOf(it) }.getOrNull() } ?: ReminderLabelStyle.REMINDERS, + swipeActions = SwipeSettings( + chores = readSwipePair(prefs, SwipeSubject.CHORES), + tasks = readSwipePair(prefs, SwipeSubject.TASKS), + memos = readSwipePair(prefs, SwipeSubject.MEMOS), + ), helpSeen = prefs[Keys.HELP_SEEN] ?: false, ) } @@ -390,4 +404,15 @@ class SettingsRepository @Inject constructor( suspend fun setReminderLabel(style: ReminderLabelStyle) { context.dataStore.edit { it[Keys.REMINDER_LABEL] = style.name } } + + // A name the list does not offer is stored as read and dropped at read time, + // so a value from a newer app version never has to be migrated away. + private fun readSwipePair(prefs: Preferences, subject: SwipeSubject) = subject.resolve( + leftName = prefs[Keys.swipeAction(subject, SwipeDirection.LEFT)], + rightName = prefs[Keys.swipeAction(subject, SwipeDirection.RIGHT)], + ) + + suspend fun setSwipeAction(subject: SwipeSubject, direction: SwipeDirection, action: SwipeAction) { + context.dataStore.edit { it[Keys.swipeAction(subject, direction)] = action.name } + } } diff --git a/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt b/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt index e6e8b35..7eb207e 100644 --- a/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt +++ b/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt @@ -143,6 +143,14 @@ class ReminderRepository @Inject constructor( return requireNotNull(updated) { "Reminder $id not found" } } + /** + * Writes [reminder] back over the stored record with the same id (a swipe's + * snooze, or its Undo restoring the copy taken before). The caller re-syncs + * the alarm from the returned record. + */ + suspend fun replace(reminder: ReminderDto): ReminderDto? = + update(reminder.id) { reminder } + suspend fun archiveReminder(id: String, archived: Boolean): ReminderDto? = update(id) { it.copy(archivedAt = if (archived) Instant.now().toString() else null) } diff --git a/app/src/main/java/com/mapgie/dash/data/repository/TaskRepository.kt b/app/src/main/java/com/mapgie/dash/data/repository/TaskRepository.kt index 4bf59a8..82cbbf9 100644 --- a/app/src/main/java/com/mapgie/dash/data/repository/TaskRepository.kt +++ b/app/src/main/java/com/mapgie/dash/data/repository/TaskRepository.kt @@ -43,6 +43,10 @@ class TaskRepository @Inject constructor( suspend fun markUndone(taskId: String): TaskDto = patchTask(taskId, completedAtPayload(null)) + /** Archives (or restores) a task without touching its completion. */ + suspend fun archiveTask(taskId: String, archived: Boolean): TaskDto = + patchTask(taskId, archivedAtPayload(if (archived) Instant.now().toString() else null)) + private suspend fun patchTask(taskId: String, payload: Map): TaskDto { val client = requireClient() return client.from("todos") @@ -121,3 +125,7 @@ internal fun editTaskPayload(update: TaskUpdate): Map = mapOf( /** Single-column payload flipping completion; null restores the task to active. */ internal fun completedAtPayload(completedAt: String?): Map = mapOf("completed_at" to completedAt) + +/** Single-column payload flipping archival; null brings the task back to the list. */ +internal fun archivedAtPayload(archivedAt: String?): Map = + mapOf("archived_at" to archivedAt) diff --git a/app/src/main/java/com/mapgie/dash/ui/components/HelpContent.kt b/app/src/main/java/com/mapgie/dash/ui/components/HelpContent.kt index c419d33..80a365a 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/HelpContent.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/HelpContent.kt @@ -76,6 +76,8 @@ fun HelpContent( SheetBlock { HelpTip("Tap a card to log or finish it. Long-press to edit.") SheetRowDivider() + HelpTip("Swipe a card sideways to log, snooze, archive or delete it. Settings › Swipe actions chooses which.") + SheetRowDivider() HelpTip("Tap the + to add to the page you're on. Long-press it to pick any type from the menu.") SheetRowDivider() HelpTip("The sort pill above each list names its order in words. Tap it to change the key or direction.") diff --git a/app/src/main/java/com/mapgie/dash/ui/components/core/SwipeActionBackground.kt b/app/src/main/java/com/mapgie/dash/ui/components/core/SwipeActionBackground.kt new file mode 100644 index 0000000..bb2f601 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/ui/components/core/SwipeActionBackground.kt @@ -0,0 +1,72 @@ +package com.mapgie.dash.ui.components.core + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.ui.theme.Dimens + +/** + * The user's swipe for a Compose dismiss value, or null at rest. In an LTR + * layout the content follows the finger, so StartToEnd is the swipe to the + * right and EndToStart the swipe to the left (LESSONS #51). + */ +@OptIn(ExperimentalMaterial3Api::class) +fun SwipeToDismissBoxValue.toSwipeDirection(): SwipeDirection? = when (this) { + SwipeToDismissBoxValue.StartToEnd -> SwipeDirection.RIGHT + SwipeToDismissBoxValue.EndToStart -> SwipeDirection.LEFT + SwipeToDismissBoxValue.Settled -> null +} + +/** + * The tinted panel a card reveals mid-swipe, with [label] on the edge the card + * is leaving. Drawn only while a swipe is in progress so nothing sits behind a + * resting card. Colour follows the action, and the word says it too: delete is + * the one destructive action, so it alone uses the error container. + */ +@Composable +fun SwipeActionBackground( + direction: SwipeDirection?, + action: SwipeAction, + label: String, +) { + if (direction == null || action == SwipeAction.NONE) return + val container = when (action) { + SwipeAction.DONE -> MaterialTheme.colorScheme.secondaryContainer + SwipeAction.SNOOZE, SwipeAction.ARCHIVE -> MaterialTheme.colorScheme.tertiaryContainer + SwipeAction.DELETE -> MaterialTheme.colorScheme.errorContainer + SwipeAction.NONE -> return + } + val content = when (action) { + SwipeAction.DONE -> MaterialTheme.colorScheme.onSecondaryContainer + SwipeAction.SNOOZE, SwipeAction.ARCHIVE -> MaterialTheme.colorScheme.onTertiaryContainer + SwipeAction.DELETE -> MaterialTheme.colorScheme.onErrorContainer + SwipeAction.NONE -> return + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Dimens.cardInset) + .background(container, shape = MaterialTheme.shapes.medium), + // A right swipe exposes the left edge, and the other way round. + contentAlignment = if (direction == SwipeDirection.RIGHT) Alignment.CenterStart else Alignment.CenterEnd, + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 24.dp), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), + color = content, + ) + } +} diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt index fa2177a..1001294 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListScreen.kt @@ -28,20 +28,21 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -58,6 +59,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.mapgie.dash.data.model.AddMenuOption import com.mapgie.dash.data.model.Chore import com.mapgie.dash.data.model.NEW_DRAFT_KEY +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.ChoreSortKey import com.mapgie.dash.data.model.ReminderInsert import com.mapgie.dash.data.model.Swatch @@ -78,6 +83,8 @@ import com.mapgie.dash.ui.components.core.SectionHeaderRow import com.mapgie.dash.ui.components.core.SectionLabel import com.mapgie.dash.ui.components.core.SortControls import com.mapgie.dash.ui.components.core.SortSheet +import com.mapgie.dash.ui.components.core.SwipeActionBackground +import com.mapgie.dash.ui.components.core.toSwipeDirection import com.mapgie.dash.ui.theme.Dimens import com.mapgie.dash.ui.theme.LocalTypeAccents import com.mapgie.dash.ui.theme.LucideIcons @@ -92,6 +99,7 @@ import com.mapgie.dash.ui.theme.badgeContainerColor import com.mapgie.dash.ui.theme.textColor import com.mapgie.dash.util.formatAbsoluteDate import java.time.Instant +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -232,6 +240,31 @@ fun ChoreListScreen( viewModel.clearRecentSnooze() } + // Settings › Swipe actions decides what each direction does. Log and snooze + // already show their own Undo via recentScan / recentSnooze; archive shows + // its own here, since it takes the chore out of the list without a log. + val scope = rememberCoroutineScope() + fun swipeChore(action: SwipeAction, chore: Chore) { + when (action) { + SwipeAction.DONE -> viewModel.logChore(chore.tagId) + SwipeAction.SNOOZE -> viewModel.toggleSnooze(chore) + SwipeAction.ARCHIVE -> { + val restoring = chore.archivedAt != null + viewModel.archiveChore(chore.tagId, !restoring) + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + message = if (restoring) "${chore.label} restored" else "${chore.label} archived", + actionLabel = "Undo", + duration = SnackbarDuration.Short + ) + if (result == SnackbarResult.ActionPerformed) viewModel.archiveChore(chore.tagId, restoring) + } + } + SwipeAction.DELETE, SwipeAction.NONE -> Unit + } + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } ) { innerPadding -> @@ -359,8 +392,8 @@ fun ChoreListScreen( showCategory = true, onTap = { logTargetChore = it; showLogSheet = true }, onLongPress = { editTargetId = it.id; showEditSheet = true }, - onSwipeLog = { viewModel.logChore(it.tagId) }, - onSwipeSnooze = { viewModel.toggleSnooze(it) }, + swipe = uiState.swipe, + onSwipe = { action, c -> swipeChore(action, c) }, snoozedUntil = uiState.snoozedUntil(chore), isPinned = chore.id == uiState.pinnedChoreId, highlightQuery = query @@ -472,8 +505,8 @@ fun ChoreListScreen( showCategory = false, onTap = { logTargetChore = it; showLogSheet = true }, onLongPress = { editTargetId = it.id; showEditSheet = true }, - onSwipeLog = { viewModel.logChore(it.tagId) }, - onSwipeSnooze = { viewModel.toggleSnooze(it) }, + swipe = uiState.swipe, + onSwipe = { action, c -> swipeChore(action, c) }, snoozedUntil = uiState.snoozedUntil(chore), isPinned = chore.id == uiState.pinnedChoreId ) @@ -509,8 +542,8 @@ fun ChoreListScreen( showCategory = !uiState.groupByCategory, onTap = { logTargetChore = it; showLogSheet = true }, onLongPress = { editTargetId = it.id; showEditSheet = true }, - onSwipeLog = { viewModel.logChore(it.tagId) }, - onSwipeSnooze = { viewModel.toggleSnooze(it) }, + swipe = uiState.swipe, + onSwipe = { action, c -> swipeChore(action, c) }, snoozedUntil = uiState.snoozedUntil(chore), isPinned = chore.id == uiState.pinnedChoreId ) @@ -547,8 +580,8 @@ fun ChoreListScreen( showCategory = !uiState.groupByCategory, onTap = { logTargetChore = it; showLogSheet = true }, onLongPress = { editTargetId = it.id; showEditSheet = true }, - onSwipeLog = { viewModel.logChore(it.tagId) }, - onSwipeSnooze = { viewModel.toggleSnooze(it) }, + swipe = uiState.swipe, + onSwipe = { action, c -> swipeChore(action, c) }, snoozedUntil = uiState.snoozedUntil(chore), isPinned = chore.id == uiState.pinnedChoreId ) @@ -780,58 +813,39 @@ private fun SwipeToLogCard( showCategory: Boolean, onTap: (Chore) -> Unit, onLongPress: (Chore) -> Unit, - onSwipeLog: (Chore) -> Unit, - onSwipeSnooze: (Chore) -> Unit, + swipe: SwipePair, + onSwipe: (SwipeAction, Chore) -> Unit, snoozedUntil: Instant? = null, isPinned: Boolean = false, highlightQuery: String? = null ) { - // Swipe right (start to end) logs the chore; swipe left (end to start) - // snoozes it, or wakes it if it is already snoozed. + // Each direction does what Settings › Swipe actions says (out of the box: + // right logs, left snoozes or wakes). A direction set to Nothing is not + // draggable at all, so the card does not slide for no reason. + // The dismiss state is remembered once per card, so its callback reads the + // latest setting and handler through rememberUpdatedState (LESSONS #49). + val currentSwipe by rememberUpdatedState(swipe) + val currentOnSwipe by rememberUpdatedState(onSwipe) + fun labelFor(action: SwipeAction): String = when (action) { + SwipeAction.SNOOZE -> if (snoozedUntil != null) "Wake" else "Snooze" + SwipeAction.ARCHIVE -> if (chore.archivedAt != null) "Restore" else "Archive" + else -> SwipeSubject.CHORES.label(action) + } val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> - when (value) { - SwipeToDismissBoxValue.StartToEnd -> onSwipeLog(chore) - SwipeToDismissBoxValue.EndToStart -> onSwipeSnooze(chore) - SwipeToDismissBoxValue.Settled -> Unit - } + value.toSwipeDirection()?.let { currentOnSwipe(currentSwipe.action(it), chore) } false // never actually dismiss the item }, positionalThreshold = { it * 0.3f } ) SwipeToDismissBox( state = dismissState, - enableDismissFromStartToEnd = true, - enableDismissFromEndToStart = true, + enableDismissFromStartToEnd = swipe.enabled(SwipeDirection.RIGHT), + enableDismissFromEndToStart = swipe.enabled(SwipeDirection.LEFT), backgroundContent = { - // Only drawn mid-swipe so nothing sits behind a resting card. - val direction = dismissState.dismissDirection - if (direction != SwipeToDismissBoxValue.Settled) { - val snoozing = direction == SwipeToDismissBoxValue.EndToStart - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = Dimens.cardInset) - .background( - if (snoozing) MaterialTheme.colorScheme.tertiaryContainer - else MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.medium - ), - contentAlignment = if (snoozing) Alignment.CenterEnd else Alignment.CenterStart - ) { - Text( - when { - !snoozing -> "Log" - snoozedUntil != null -> "Wake" - else -> "Snooze" - }, - modifier = Modifier.padding(horizontal = 24.dp), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), - color = if (snoozing) MaterialTheme.colorScheme.onTertiaryContainer - else MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } + val direction = dismissState.dismissDirection.toSwipeDirection() + val action = direction?.let { swipe.action(it) } ?: SwipeAction.NONE + SwipeActionBackground(direction = direction, action = action, label = labelFor(action)) } ) { ChoreCard( diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt index 4a6f7a6..3904d72 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/chores/ChoreListViewModel.kt @@ -17,6 +17,8 @@ import com.mapgie.dash.data.model.OwnerFilter import com.mapgie.dash.data.model.ReminderInsert import com.mapgie.dash.data.model.ScanDto import com.mapgie.dash.data.model.SortOrder +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.defaultSnoozeDuration import com.mapgie.dash.data.model.remindAtInstant import com.mapgie.dash.data.preferences.CategoryStyleStore @@ -92,7 +94,9 @@ data class ChoreUiState( val recentSnooze: RecentSnooze? = null, val pinnedChoreId: String? = null, val scanHistory: List = emptyList(), - val pinChooser: PinChooserState? = null + val pinChooser: PinChooserState? = null, + /** Settings › Swipe actions for chore cards. */ + val swipe: SwipePair = SwipeSubject.CHORES.default, ) { private val ownerFiltered: List get() = active.filter { ownerFilter.matches(it.owner, ownerHandle) } @@ -289,6 +293,7 @@ class ChoreListViewModel @Inject constructor( smartVisibility = settings.smartChoreVisibility, choreLeadDays = settings.choreLeadDays, colourAxes = settings.colourAxes, + swipe = settings.swipeActions.chores, ) } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt index f7fb23c..f8e47dc 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt @@ -1,6 +1,5 @@ package com.mapgie.dash.ui.screens.reminders -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,20 +23,20 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -57,6 +56,11 @@ import com.mapgie.dash.data.model.ReminderAppearance import com.mapgie.dash.data.model.ReminderDto import com.mapgie.dash.data.model.ReminderSortKey import com.mapgie.dash.data.model.Swatch +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject +import com.mapgie.dash.data.model.isDone import com.mapgie.dash.data.model.isTagAlarm import com.mapgie.dash.permission.PermissionHelper import com.mapgie.dash.ui.components.AddReminderSheet @@ -72,6 +76,8 @@ import com.mapgie.dash.ui.components.core.SearchRow import com.mapgie.dash.ui.components.core.SectionLabel import com.mapgie.dash.ui.components.core.SortControls import com.mapgie.dash.ui.components.core.SortSheet +import com.mapgie.dash.ui.components.core.SwipeActionBackground +import com.mapgie.dash.ui.components.core.toSwipeDirection import com.mapgie.dash.ui.theme.Dimens import com.mapgie.dash.ui.theme.LocalTypeAccents import com.mapgie.dash.ui.theme.LucideIcons @@ -144,6 +150,45 @@ fun RemindersListScreen( } } + // Settings › Swipe actions decides what each direction does. Delete is + // confirmed inside the card before it reaches here; snooze and archive + // each leave an Undo like Done does. + fun swipeReminder(action: SwipeAction, reminder: ReminderDto) { + when (action) { + SwipeAction.DONE -> + if (!reminder.isTagAlarm && reminder.isDone) viewModel.setReminderDone(reminder.id, false) + else markReminderDoneWithUndo(reminder) + SwipeAction.SNOOZE -> { + if (reminder.isTagAlarm) return + viewModel.snoozeReminder(reminder.id) + scope.launch { + snackbarHost.currentSnackbarData?.dismiss() + val result = snackbarHost.showSnackbar( + message = "“${reminder.subject}” snoozed for an hour", + actionLabel = "Undo", + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.restoreReminder(reminder) + } + } + SwipeAction.ARCHIVE -> { + val restoring = reminder.archivedAt != null + viewModel.archiveReminder(reminder.id, !restoring) + scope.launch { + snackbarHost.currentSnackbarData?.dismiss() + val result = snackbarHost.showSnackbar( + message = if (restoring) "“${reminder.subject}” restored" else "“${reminder.subject}” archived", + actionLabel = "Undo", + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.archiveReminder(reminder.id, restoring) + } + } + SwipeAction.DELETE -> viewModel.deleteReminder(reminder.id) + SwipeAction.NONE -> Unit + } + } + var showAddSheet by rememberSaveable { mutableStateOf(false) } var showSortSheet by remember { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) } @@ -325,8 +370,8 @@ fun RemindersListScreen( spineSwatch = look.spineSwatch, iconSwatch = look.iconSwatch, onClick = { editTargetId = reminder.id }, - onDelete = { viewModel.deleteReminder(reminder.id) }, - onMarkDone = { markReminderDoneWithUndo(reminder) }, + swipe = uiState.swipe, + onSwipe = { swipeReminder(it, reminder) }, ) } } @@ -414,10 +459,11 @@ private fun ReminderAppearance.glyph(): ImageVector = icon?.let { LucideIcons.forCategory(it) } ?: LucideIcons.Bell /** - * A memo card with two swipes: left (end to start) marks it done / turns it off - * (a tag-alarm goes dormant); right (start to end) deletes it, behind a confirm. - * Done is reversible from the Undo snackbar the caller shows, so it needs no - * confirm of its own. + * A memo card whose two swipes do what Settings › Swipe actions says (out of + * the box: left marks it done or turns a tag-alarm off, right deletes behind a + * confirm). Delete is the only action confirmed here; the others are reversible + * from the Undo snackbar the caller shows. A tag-alarm has no snooze, so that + * direction is turned off on its card. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -428,16 +474,31 @@ private fun SwipeReminderCard( spineSwatch: Swatch?, iconSwatch: Swatch?, onClick: () -> Unit, - onDelete: () -> Unit, - onMarkDone: () -> Unit, + swipe: SwipePair, + onSwipe: (SwipeAction) -> Unit, ) { + val effective = if (reminder.isTagAlarm) swipe.without(SwipeAction.SNOOZE) else swipe + // The dismiss state is remembered once per card, so its callback reads the + // latest setting and handler through rememberUpdatedState (LESSONS #49). + val currentEffective by rememberUpdatedState(effective) + val currentOnSwipe by rememberUpdatedState(onSwipe) + fun labelFor(action: SwipeAction): String = when (action) { + SwipeAction.DONE -> when { + reminder.isTagAlarm -> "Turn off" + reminder.isDone -> "Restore" + else -> "Done" + } + SwipeAction.ARCHIVE -> if (reminder.archivedAt != null) "Restore" else "Archive" + else -> SwipeSubject.MEMOS.label(action) + } var showDeleteConfirm by remember { mutableStateOf(false) } val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> - when (value) { - SwipeToDismissBoxValue.StartToEnd -> showDeleteConfirm = true - SwipeToDismissBoxValue.EndToStart -> onMarkDone() - SwipeToDismissBoxValue.Settled -> Unit + value.toSwipeDirection()?.let { direction -> + when (val action = currentEffective.action(direction)) { + SwipeAction.DELETE -> showDeleteConfirm = true + else -> currentOnSwipe(action) + } } false // never actually dismiss the item }, @@ -445,33 +506,12 @@ private fun SwipeReminderCard( ) SwipeToDismissBox( state = dismissState, - enableDismissFromStartToEnd = true, - enableDismissFromEndToStart = true, + enableDismissFromStartToEnd = effective.enabled(SwipeDirection.RIGHT), + enableDismissFromEndToStart = effective.enabled(SwipeDirection.LEFT), backgroundContent = { - val direction = dismissState.dismissDirection - if (direction != SwipeToDismissBoxValue.Settled) { - // Swipe right (start to end) deletes; swipe left (end to start) marks done. - val deleting = direction == SwipeToDismissBoxValue.StartToEnd - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = Dimens.cardInset) - .background( - if (deleting) MaterialTheme.colorScheme.errorContainer - else MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.medium - ), - contentAlignment = if (deleting) Alignment.CenterStart else Alignment.CenterEnd - ) { - Text( - if (deleting) "Delete" else if (reminder.isTagAlarm) "Turn off" else "Done", - modifier = Modifier.padding(horizontal = 24.dp), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), - color = if (deleting) MaterialTheme.colorScheme.onErrorContainer - else MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } + val direction = dismissState.dismissDirection.toSwipeDirection() + val action = direction?.let { effective.action(it) } ?: SwipeAction.NONE + SwipeActionBackground(direction = direction, action = action, label = labelFor(action)) } ) { ReminderCard( @@ -493,7 +533,7 @@ private fun SwipeReminderCard( confirmButton = { TextButton(onClick = { showDeleteConfirm = false - onDelete() + currentOnSwipe(SwipeAction.DELETE) }) { Text("Delete") } }, dismissButton = { diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListViewModel.kt index 827d6cd..6327bf1 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListViewModel.kt @@ -27,6 +27,9 @@ import com.mapgie.dash.data.repository.ReminderRepository import com.mapgie.dash.data.repository.TaskRepository import com.mapgie.dash.tagalarm.TagAlarmService import com.mapgie.dash.notification.DeliveryMode +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject +import com.mapgie.dash.data.model.snoozedBy import com.mapgie.dash.data.supabase.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -34,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Duration import java.time.Instant import javax.inject.Inject @@ -69,6 +73,8 @@ data class ReminderUiState( val tagAlarmConflicts: List = emptyList(), /** Settings › Reminders & alerts style; decides which missing permissions the list warns about. */ val deliveryMode: String = DeliveryMode.NOTIFICATION, + /** Settings › Swipe actions for memo cards. */ + val swipe: SwipePair = SwipeSubject.MEMOS.default, ) { val active: List get() = sorted(reminders.filter { it.archivedAt == null && !it.isDone }) @@ -178,6 +184,7 @@ class RemindersListViewModel @Inject constructor( sort = settings.reminderSort, colourAxes = settings.colourAxes, deliveryMode = settings.deliveryMode, + swipe = settings.swipeActions.memos, ) } } @@ -329,6 +336,37 @@ class RemindersListViewModel @Inject constructor( } } + /** + * Swipe-to-snooze: the memo's next ring moves back an hour (see + * [snoozedBy]); a once-only memo that had rung comes back to Active. The + * caller keeps the record from before for [restoreReminder] on Undo. + */ + fun snoozeReminder(id: String) { + viewModelScope.launch { + runCatching { + val memo = _uiState.value.reminders.find { it.id == id } ?: return@runCatching + val snoozed = memo.snoozedBy(SWIPE_SNOOZE, Instant.now()) + if (snoozed == memo) return@runCatching + reminderRepository.replace(snoozed)?.let { alarmScheduler.syncReminder(it) } + load() + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + + /** Undo for [snoozeReminder]: writes the earlier copy back and re-arms from it. */ + fun restoreReminder(record: ReminderDto) { + viewModelScope.launch { + runCatching { + reminderRepository.replace(record)?.let { alarmScheduler.syncReminder(it) } + load() + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + fun deleteReminder(id: String) { viewModelScope.launch { runCatching { @@ -343,3 +381,6 @@ class RemindersListViewModel @Inject constructor( fun clearError() = _uiState.update { it.copy(error = null) } } + +/** How far a swipe-to-snooze on the list pushes a memo's next ring. */ +private val SWIPE_SNOOZE: Duration = Duration.ofHours(1) diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/CozyControls.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/CozyControls.kt index 6dff975..2680043 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/CozyControls.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/CozyControls.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -285,6 +287,63 @@ fun CozySegmented( } } +/** + * A wrapping row of pill choices for a single-select with more options than a + * segmented control fits (four or five words). Same grammar as [CozySegmented]: + * outline pills, the selected one filled with the accent tint and led by a + * check, each a 44dp+ radio target. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CozyChoiceChips( + options: List, + selected: T, + onSelect: (T) -> Unit, + label: (T) -> String, + modifier: Modifier = Modifier, +) { + val tokens = LocalDashTokens.current + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + val isSelected = option == selected + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .heightIn(min = 44.dp) + .clip(PillShape) + .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) + .border(1.5.dp, tokens.pillOutline, PillShape) + .semantics { role = Role.RadioButton } + .selectable(selected = isSelected, onClick = { onSelect(option) }) + .padding(horizontal = 16.dp), + ) { + if (isSelected) { + Icon( + imageVector = LucideIcons.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(15.dp), + ) + Box(Modifier.width(5.dp)) + } + Text( + label(option), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.Bold, + ), + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } +} + /** A checkbox row: 24dp rounded square with a 2dp accent border, filled with a check when on. */ @Composable fun CozyCheckboxRow( diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt index cf1d6b7..4fae8e5 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt @@ -100,7 +100,7 @@ import java.io.InputStreamReader import kotlin.math.roundToInt enum class SettingsSubScreen { - NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, REMINDERS, WIDGET, TAGS, ABOUT, HELP + NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, SWIPE, REMINDERS, WIDGET, TAGS, ABOUT, HELP } private const val CHANGELOG_URL = "https://github.com/mapgie/choreDash-Android/blob/main/CHANGELOG.md" @@ -173,6 +173,10 @@ fun SettingsScreen( onBack = { subScreen = SettingsSubScreen.NONE }, viewModel = viewModel, ) + SettingsSubScreen.SWIPE -> SwipeSubScreen( + onBack = { subScreen = SettingsSubScreen.NONE }, + viewModel = viewModel, + ) SettingsSubScreen.REMINDERS -> RemindersSubScreen( onBack = { subScreen = SettingsSubScreen.NONE }, viewModel = viewModel, @@ -265,6 +269,12 @@ private fun SettingsMainList( onClick = { onNavigate(SettingsSubScreen.QUICK_ADD) } ) SettingsHairline() + SettingsNavRow( + title = "Swipe actions", + subtitle = "What swiping a card left or right does", + onClick = { onNavigate(SettingsSubScreen.SWIPE) } + ) + SettingsHairline() SettingsNavRow( title = "Widget customisation", subtitle = "Choose what your home-screen widget shows", @@ -318,7 +328,7 @@ private fun SettingsMainList( /** Scrolling column every sub-screen uses: page inset, 14dp section gap. */ @Composable -private fun SubScreenColumn( +internal fun SubScreenColumn( innerPadding: PaddingValues, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, ) { diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt index b253516..965d5bd 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt @@ -12,6 +12,9 @@ import com.mapgie.dash.data.model.CategoryStyle import com.mapgie.dash.data.model.ColourChoresBy import com.mapgie.dash.data.model.GENERAL_CATEGORY import com.mapgie.dash.data.model.ReminderLabelStyle +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.Severity import com.mapgie.dash.data.model.Swatch import com.mapgie.dash.data.preferences.AppSettings @@ -386,4 +389,10 @@ class SettingsViewModel @Inject constructor( fun setReminderLabel(style: ReminderLabelStyle) { viewModelScope.launch { settingsRepository.setReminderLabel(style) } } + + // ── Swipe actions ──────────────────────────────────────────────────────── + + fun setSwipeAction(subject: SwipeSubject, direction: SwipeDirection, action: SwipeAction) { + viewModelScope.launch { settingsRepository.setSwipeAction(subject, direction, action) } + } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SwipeSubScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SwipeSubScreen.kt new file mode 100644 index 0000000..6a2ac77 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SwipeSubScreen.kt @@ -0,0 +1,100 @@ +package com.mapgie.dash.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSettings +import com.mapgie.dash.data.model.SwipeSubject +import com.mapgie.dash.ui.components.core.LocalReminderLabel + +/** + * Settings › Swipe actions: one card per list (chores, tasks, memos) with a + * row of pill choices for the left swipe and another for the right. The + * choices on offer differ per list (see [SwipeSubject.offered]); the words + * match what the card shows mid-swipe. + */ +@Composable +fun SwipeSubScreen( + onBack: () -> Unit, + viewModel: SettingsViewModel, +) { + val settings by viewModel.settings.collectAsState() + val swipe = settings?.swipeActions ?: SwipeSettings() + val memoWord = LocalReminderLabel.current + + SettingsSubScreenScaffold(title = "Swipe actions", onBack = onBack) { innerPadding -> + SubScreenColumn(innerPadding) { + SettingsCaption( + "Swipe a card sideways to act on it without opening it. " + + "Log, Done, Snooze and Archive can be undone from the message that appears. " + + "Delete asks first. Choose Nothing to turn that swipe off." + ) + + SwipeSubject.entries.forEach { subject -> + SettingsSectionLabel( + when (subject) { + SwipeSubject.CHORES -> "Chores" + SwipeSubject.TASKS -> "Tasks" + SwipeSubject.MEMOS -> memoWord.displayName + } + ) + SettingsCard { + SwipeDirectionRow( + direction = SwipeDirection.LEFT, + subject = subject, + pair = swipe[subject], + onSelect = { viewModel.setSwipeAction(subject, SwipeDirection.LEFT, it) }, + ) + SettingsHairline() + SwipeDirectionRow( + direction = SwipeDirection.RIGHT, + subject = subject, + pair = swipe[subject], + onSelect = { viewModel.setSwipeAction(subject, SwipeDirection.RIGHT, it) }, + ) + } + if (subject == SwipeSubject.MEMOS) { + SettingsCaption( + "Snooze pushes the next ring back an hour. A tag-alarm keeps its morning, " + + "so Snooze does nothing on one of those." + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + } +} + +@Composable +private fun SwipeDirectionRow( + direction: SwipeDirection, + subject: SwipeSubject, + pair: SwipePair, + onSelect: (SwipeAction) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + ) { + SettingsRowText(title = direction.displayName) + Spacer(Modifier.height(10.dp)) + CozyChoiceChips( + options = subject.offered, + selected = pair.action(direction), + onSelect = onSelect, + label = { subject.label(it) }, + ) + } +} diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt index 0cf38fc..99489ac 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,22 +26,22 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -49,11 +50,14 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.mapgie.dash.data.model.AddMenuOption import com.mapgie.dash.data.model.ReminderInsert +import com.mapgie.dash.data.model.SwipeAction +import com.mapgie.dash.data.model.SwipeDirection +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.draftKeyFor import com.mapgie.dash.data.model.TaskSortKey @@ -70,6 +74,8 @@ import com.mapgie.dash.ui.components.core.SectionHeaderRow import com.mapgie.dash.ui.components.core.SectionLabel import com.mapgie.dash.ui.components.core.SortControls import com.mapgie.dash.ui.components.core.SortSheet +import com.mapgie.dash.ui.components.core.SwipeActionBackground +import com.mapgie.dash.ui.components.core.toSwipeDirection import com.mapgie.dash.ui.theme.Dimens import com.mapgie.dash.ui.theme.LocalTypeAccents import com.mapgie.dash.ui.theme.LucideIcons @@ -131,6 +137,29 @@ fun TaskListScreen( } } + // Settings › Swipe actions decides what each direction does. Delete is + // confirmed inside the card before it reaches here; archive gets an Undo. + fun swipeTask(action: SwipeAction, task: TaskDto) { + when (action) { + SwipeAction.DONE -> + if (task.completedAt != null) viewModel.markUndone(task.id) else completeTaskWithUndo(task) + SwipeAction.ARCHIVE -> { + viewModel.archiveTask(task.id, true) + scope.launch { + snackbarHost.currentSnackbarData?.dismiss() + val result = snackbarHost.showSnackbar( + message = "“${task.title}” archived", + actionLabel = "Undo", + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.archiveTask(task.id, false) + } + } + SwipeAction.DELETE -> viewModel.deleteTask(task.id) + SwipeAction.SNOOZE, SwipeAction.NONE -> Unit + } + } + // The Edit sheet's target is kept by id and resolved from uiState so the open // sheet survives rotation and process death; it closes cleanly if the task // is gone once the list has loaded. @@ -287,6 +316,8 @@ fun TaskListScreen( if (task.completedAt != null) viewModel.markUndone(task.id) else completeTaskWithUndo(task) }, + swipe = uiState.swipe, + onSwipe = { swipeTask(it, task) }, isPinned = task.id == uiState.pinnedTaskId, highlightQuery = query ) @@ -355,6 +386,8 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, + swipe = uiState.swipe, + onSwipe = { swipeTask(it, task) }, showCategory = false, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode, @@ -390,6 +423,8 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, + swipe = uiState.swipe, + onSwipe = { swipeTask(it, task) }, showCategory = !uiState.groupByCategory, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode, @@ -426,6 +461,8 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { viewModel.markUndone(task.id) }, + swipe = uiState.swipe, + onSwipe = { swipeTask(it, task) }, showCategory = !uiState.groupByCategory, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode @@ -534,6 +571,8 @@ private fun SwipeToCompleteCard( onTap: (TaskDto) -> Unit, onLongPress: (TaskDto) -> Unit, onToggleDone: () -> Unit, + swipe: SwipePair, + onSwipe: (SwipeAction) -> Unit, showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, @@ -541,42 +580,41 @@ private fun SwipeToCompleteCard( highlightQuery: String? = null ) { val isDone = task.completedAt != null + // Each direction does what Settings › Swipe actions says (out of the box: + // right completes, left does nothing). Delete asks first, here, so the + // caller only ever hears about a confirmed one. + var showDeleteConfirm by remember { mutableStateOf(false) } + // The dismiss state is remembered once per card, so its callback reads the + // latest setting and handler through rememberUpdatedState (LESSONS #49). + val currentSwipe by rememberUpdatedState(swipe) + val currentOnSwipe by rememberUpdatedState(onSwipe) + fun labelFor(action: SwipeAction): String = when (action) { + SwipeAction.DONE -> if (isDone) "Restore" else "Done" + else -> SwipeSubject.TASKS.label(action) + } val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.StartToEnd) { - onToggleDone() + value.toSwipeDirection()?.let { direction -> + when (val action = currentSwipe.action(direction)) { + SwipeAction.DELETE -> showDeleteConfirm = true + else -> currentOnSwipe(action) + } } false // never actually dismiss the item }, - // Require a deliberate swipe most of the way across the card before a - // completion registers, so a stray horizontal drag while scrolling the + // Require a deliberate swipe most of the way across the card before an + // action registers, so a stray horizontal drag while scrolling the // list doesn't silently tick a task off. positionalThreshold = { it * 0.6f } ) SwipeToDismissBox( state = dismissState, - enableDismissFromStartToEnd = true, - enableDismissFromEndToStart = false, + enableDismissFromStartToEnd = swipe.enabled(SwipeDirection.RIGHT), + enableDismissFromEndToStart = swipe.enabled(SwipeDirection.LEFT), backgroundContent = { - if (dismissState.dismissDirection != SwipeToDismissBoxValue.Settled) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = Dimens.cardInset) - .background( - MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.medium - ), - contentAlignment = Alignment.CenterStart - ) { - Text( - if (isDone) "Restore" else "Done", - modifier = Modifier.padding(start = 24.dp), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } + val direction = dismissState.dismissDirection.toSwipeDirection() + val action = direction?.let { swipe.action(it) } ?: SwipeAction.NONE + SwipeActionBackground(direction = direction, action = action, label = labelFor(action)) } ) { TaskCard( @@ -596,4 +634,21 @@ private fun SwipeToCompleteCard( ) ) } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete task?") }, + text = { Text("“${task.title}” will be permanently removed.") }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + currentOnSwipe(SwipeAction.DELETE) + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + } + ) + } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt index d2df143..de4d911 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/tasks/TaskListViewModel.kt @@ -10,6 +10,8 @@ import com.mapgie.dash.data.model.DraftStore import com.mapgie.dash.data.model.OwnerFilter import com.mapgie.dash.data.model.ReminderInsert import com.mapgie.dash.data.model.SortOrder +import com.mapgie.dash.data.model.SwipePair +import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.TaskDraft import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.TaskInsert @@ -61,6 +63,8 @@ data class TaskUiState( val zenSortAscending: Boolean = true, val catalog: CategoryCatalog = CategoryCatalog(), val pinChooser: PinChooserState? = null, + /** Settings › Swipe actions for task cards. */ + val swipe: SwipePair = SwipeSubject.TASKS.default, ) { val displayed: List get() { @@ -194,6 +198,7 @@ class TaskListViewModel @Inject constructor( hideThresholdDays = s.taskHideThresholdDays, zenMode = s.taskZenMode, sort = s.taskSort, + swipe = s.swipeActions.tasks, ) } } @@ -324,13 +329,7 @@ class TaskListViewModel @Inject constructor( fun markDone(id: String, at: Instant? = null) { viewModelScope.launch { runCatching { - alarmScheduler.cancelTask(id) - reminderRepository.loadReminders() - .filter { it.taskId == id && it.archivedAt == null } - .forEach { reminder -> - alarmScheduler.cancelReminder(reminder.id) - reminderRepository.archiveReminder(reminder.id, true) - } + silenceLinkedReminders(id) taskRepository.markDone(id, at ?: Instant.now()) load() WidgetUpdater.updateAll(appContext) @@ -344,27 +343,28 @@ class TaskListViewModel @Inject constructor( viewModelScope.launch { runCatching { taskRepository.markUndone(id) - val archivedReminders = reminderRepository.loadReminders() - .filter { it.taskId == id && it.archivedAt != null } - archivedReminders.forEach { reminder -> - reminderRepository.archiveReminder(reminder.id, false) - if (!reminder.reminded && reminder.completedAt == null) { - reminder.remindAtInstant()?.let { at -> - if (at.isAfter(Instant.now())) { - alarmScheduler.scheduleReminder(reminder.id, reminder.subject, at, id) - } - } - } - } - if (archivedReminders.isEmpty()) { - // Old-style task: reschedule via task alarm - taskRepository.loadTasks().find { it.id == id }?.let { task -> - task.reminderInstant()?.let { at -> - if (at.isAfter(Instant.now())) { - alarmScheduler.scheduleTask(id, task.title, at) - } - } - } + restoreLinkedReminders(id) + load() + WidgetUpdater.updateAll(appContext) + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + + /** + * Archives a task from a swipe (or restores it from the Undo). Its reminders + * go quiet like a done task's, and come back with it, so nothing rings for a + * task that is out of the list. + */ + fun archiveTask(id: String, archived: Boolean) { + viewModelScope.launch { + runCatching { + if (archived) silenceLinkedReminders(id) + taskRepository.archiveTask(id, archived) + if (!archived) restoreLinkedReminders(id) + if (archived && _uiState.value.pinnedTaskId == id) { + pinnedItemStore.setPinned(null) } load() WidgetUpdater.updateAll(appContext) @@ -374,6 +374,43 @@ class TaskListViewModel @Inject constructor( } } + /** Cancels the task's own alarm and archives (silencing) every memo linked to it. */ + private suspend fun silenceLinkedReminders(id: String) { + alarmScheduler.cancelTask(id) + reminderRepository.loadReminders() + .filter { it.taskId == id && it.archivedAt == null } + .forEach { reminder -> + alarmScheduler.cancelReminder(reminder.id) + reminderRepository.archiveReminder(reminder.id, true) + } + } + + /** Unarchives the task's memos and re-arms whatever is still in the future. */ + private suspend fun restoreLinkedReminders(id: String) { + val archivedReminders = reminderRepository.loadReminders() + .filter { it.taskId == id && it.archivedAt != null } + archivedReminders.forEach { reminder -> + reminderRepository.archiveReminder(reminder.id, false) + if (!reminder.reminded && reminder.completedAt == null) { + reminder.remindAtInstant()?.let { at -> + if (at.isAfter(Instant.now())) { + alarmScheduler.scheduleReminder(reminder.id, reminder.subject, at, id) + } + } + } + } + if (archivedReminders.isEmpty()) { + // Old-style task: reschedule via task alarm + taskRepository.loadTasks().find { it.id == id }?.let { task -> + task.reminderInstant()?.let { at -> + if (at.isAfter(Instant.now())) { + alarmScheduler.scheduleTask(id, task.title, at) + } + } + } + } + } + fun deleteTask(id: String) { viewModelScope.launch { runCatching { diff --git a/app/src/test/java/com/mapgie/dash/data/model/ReminderModelTest.kt b/app/src/test/java/com/mapgie/dash/data/model/ReminderModelTest.kt index 936b45f..225fa3a 100644 --- a/app/src/test/java/com/mapgie/dash/data/model/ReminderModelTest.kt +++ b/app/src/test/java/com/mapgie/dash/data/model/ReminderModelTest.kt @@ -1,6 +1,7 @@ package com.mapgie.dash.data.model import java.time.DayOfWeek +import java.time.Duration import java.time.Instant import java.time.ZoneId import org.junit.Assert.assertEquals @@ -163,4 +164,38 @@ class ReminderModelTest { assertTrue(legacy.isDone) assertEquals(Instant.parse("2026-07-10T07:00:00Z"), legacy.rangAt()) } + + // ── Swipe-to-snooze on the list ───────────────────────────────────────── + + @Test + fun `snoozing a memo still waiting to ring pushes that ring back by the duration`() { + val memo = reminder(remindAt = "2026-07-10T12:00:00Z") + val snoozed = memo.snoozedBy(Duration.ofHours(1), now) + assertEquals(Instant.parse("2026-07-10T13:00:00Z"), snoozed.remindAtInstant()) + } + + @Test + fun `snoozing a memo that already rang brings it back to ring an hour from now`() { + val memo = reminder(remindAt = "2026-07-10T09:00:00Z", reminded = true, completedAt = "2026-07-10T09:05:00Z") + val snoozed = memo.snoozedBy(Duration.ofHours(1), now) + assertEquals(now.plus(Duration.ofHours(1)), snoozed.remindAtInstant()) + assertFalse(snoozed.reminded) + assertNull(snoozed.completedAt) + assertFalse(snoozed.isDone) + } + + @Test + fun `snoozing a repeating memo shifts only the next ring and keeps its rota`() { + val memo = reminder(remindAt = "2026-07-13T09:00:00Z", repeatDays = monWedFri) + val snoozed = memo.snoozedBy(Duration.ofHours(1), now) + assertEquals(Instant.parse("2026-07-13T10:00:00Z"), snoozed.remindAtInstant()) + assertEquals(monWedFri, snoozed.repeatDays) + assertTrue(snoozed.repeats) + } + + @Test + fun `a tag-alarm is not snoozed from the list`() { + val alarm = reminder(remindAt = "2026-07-11T05:15:00Z").copy(tagAlarm = true, ringTimes = listOf("05:15")) + assertEquals(alarm, alarm.snoozedBy(Duration.ofHours(1), now)) + } } diff --git a/app/src/test/java/com/mapgie/dash/data/model/SwipeActionTest.kt b/app/src/test/java/com/mapgie/dash/data/model/SwipeActionTest.kt new file mode 100644 index 0000000..a7d8edb --- /dev/null +++ b/app/src/test/java/com/mapgie/dash/data/model/SwipeActionTest.kt @@ -0,0 +1,102 @@ +package com.mapgie.dash.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Settings › Swipe actions as a contract: what each list swipes do out of the + * box (the behaviour before the setting existed), which actions each list may + * offer, and how a stored choice survives an unknown or unsupported value. + */ +class SwipeActionTest { + + @Test + fun `chores log on a right swipe and snooze on a left swipe by default`() { + assertEquals(SwipeAction.DONE, SwipeSubject.CHORES.default.right) + assertEquals(SwipeAction.SNOOZE, SwipeSubject.CHORES.default.left) + } + + @Test + fun `tasks complete on a right swipe and do nothing on a left swipe by default`() { + assertEquals(SwipeAction.DONE, SwipeSubject.TASKS.default.right) + assertEquals(SwipeAction.NONE, SwipeSubject.TASKS.default.left) + } + + @Test + fun `memos mark done on a left swipe and delete on a right swipe by default`() { + assertEquals(SwipeAction.DONE, SwipeSubject.MEMOS.default.left) + assertEquals(SwipeAction.DELETE, SwipeSubject.MEMOS.default.right) + } + + @Test + fun `every list offers its own defaults and a way to turn a swipe off`() { + SwipeSubject.entries.forEach { subject -> + assertTrue(subject.name, subject.default.left in subject.offered) + assertTrue(subject.name, subject.default.right in subject.offered) + assertTrue(subject.name, SwipeAction.NONE in subject.offered) + } + } + + @Test + fun `chores cannot be deleted by a swipe, they retire by archiving`() { + assertFalse(SwipeAction.DELETE in SwipeSubject.CHORES.offered) + assertTrue(SwipeAction.ARCHIVE in SwipeSubject.CHORES.offered) + } + + @Test + fun `tasks have no snooze to offer`() { + assertFalse(SwipeAction.SNOOZE in SwipeSubject.TASKS.offered) + } + + @Test + fun `nothing disables that direction only`() { + val pair = SwipePair(left = SwipeAction.NONE, right = SwipeAction.DONE) + assertFalse(pair.enabled(SwipeDirection.LEFT)) + assertTrue(pair.enabled(SwipeDirection.RIGHT)) + } + + @Test + fun `turning one action off leaves the other direction as it was`() { + val pair = SwipePair(left = SwipeAction.SNOOZE, right = SwipeAction.DONE).without(SwipeAction.SNOOZE) + assertEquals(SwipePair(left = SwipeAction.NONE, right = SwipeAction.DONE), pair) + assertEquals(pair, pair.without(SwipeAction.DELETE)) + } + + @Test + fun `an action a list does not offer falls back to that list's default for the direction`() { + val pair = SwipePair(left = SwipeAction.DELETE, right = SwipeAction.ARCHIVE) + val sanitised = SwipeSubject.CHORES.sanitise(pair) + assertEquals(SwipeAction.SNOOZE, sanitised.left) + assertEquals(SwipeAction.ARCHIVE, sanitised.right) + } + + @Test + fun `a missing or unknown stored name falls back to the default for that direction`() { + assertEquals(SwipeSubject.TASKS.default, SwipeSubject.TASKS.resolve(null, null)) + assertEquals(SwipeSubject.TASKS.default, SwipeSubject.TASKS.resolve("BANANA", "TELEPORT")) + assertEquals( + SwipePair(left = SwipeAction.DELETE, right = SwipeAction.DONE), + SwipeSubject.TASKS.resolve("DELETE", "garbage"), + ) + } + + @Test + fun `a chore's Done reads as Log, everything else keeps its name`() { + assertEquals("Log", SwipeSubject.CHORES.label(SwipeAction.DONE)) + assertEquals("Done", SwipeSubject.TASKS.label(SwipeAction.DONE)) + assertEquals("Done", SwipeSubject.MEMOS.label(SwipeAction.DONE)) + assertEquals("Nothing", SwipeSubject.CHORES.label(SwipeAction.NONE)) + } + + @Test + fun `changing one direction on one list leaves the rest untouched`() { + val settings = SwipeSettings() + .with(SwipeSubject.TASKS, SwipeSubject.TASKS.default.with(SwipeDirection.LEFT, SwipeAction.DELETE)) + assertEquals(SwipeAction.DELETE, settings.tasks.left) + assertEquals(SwipeAction.DONE, settings.tasks.right) + assertEquals(SwipeSubject.CHORES.default, settings[SwipeSubject.CHORES]) + assertEquals(SwipeSubject.MEMOS.default, settings[SwipeSubject.MEMOS]) + } +} diff --git a/app/src/test/java/com/mapgie/dash/data/repository/TaskPayloadTest.kt b/app/src/test/java/com/mapgie/dash/data/repository/TaskPayloadTest.kt index a9d2f78..f8f9684 100644 --- a/app/src/test/java/com/mapgie/dash/data/repository/TaskPayloadTest.kt +++ b/app/src/test/java/com/mapgie/dash/data/repository/TaskPayloadTest.kt @@ -53,6 +53,17 @@ class TaskPayloadTest { assertEquals("2026-08-26T12:00:00Z", completedAtPayload("2026-08-26T12:00:00Z")["completed_at"]) } + @Test + fun `archiving sends only archived_at, restoring sends it as an explicit null`() { + val archived = archivedAtPayload("2026-08-26T12:00:00Z") + assertEquals(setOf("archived_at"), archived.keys) + assertEquals("2026-08-26T12:00:00Z", archived["archived_at"]) + + val restored = archivedAtPayload(null) + assertEquals(setOf("archived_at"), restored.keys) + assertNull(restored["archived_at"]) + } + @Test fun `serializing TaskUpdate drops null fields, which is why payloads are maps`() { // Documents the kotlinx.serialization behaviour behind the bug: with diff --git a/changelog/unreleased/swipe-actions.json b/changelog/unreleased/swipe-actions.json new file mode 100644 index 0000000..23aefb6 --- /dev/null +++ b/changelog/unreleased/swipe-actions.json @@ -0,0 +1,12 @@ +{ + "bump": "minor", + "added": [ + "Settings › Swipe actions: choose what swiping a card left or right does, separately for chores, tasks and reminders. Each swipe can log or mark done, snooze, archive, delete, or do nothing, within what that list supports.", + "Swipe to archive a chore, task or reminder without logging or completing it, with an Undo.", + "Swipe to snooze a reminder: its next ring moves back an hour, with an Undo.", + "Swipe to delete a task, behind the same confirmation as the edit sheet." + ], + "changed": [ + "Swiping a done reminder or task with the Done action now restores it instead of marking it done again." + ] +}