Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions app/src/main/java/com/mapgie/dash/data/model/Reminder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
117 changes: 117 additions & 0 deletions app/src/main/java/com/mapgie/dash/data/model/SwipeAction.kt
Original file line number Diff line number Diff line change
@@ -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<SwipeAction>,
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +89,8 @@ data class AppSettings(
val fabOrder: List<AddMenuOption> = 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,
)
Expand Down Expand Up @@ -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<AppSettings> = context.dataStore.data
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String?>): TaskDto {
val client = requireClient()
return client.from("todos")
Expand Down Expand Up @@ -121,3 +125,7 @@ internal fun editTaskPayload(update: TaskUpdate): Map<String, String?> = mapOf(
/** Single-column payload flipping completion; null restores the task to active. */
internal fun completedAtPayload(completedAt: String?): Map<String, String?> =
mapOf("completed_at" to completedAt)

/** Single-column payload flipping archival; null brings the task back to the list. */
internal fun archivedAtPayload(archivedAt: String?): Map<String, String?> =
mapOf("archived_at" to archivedAt)
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Loading
Loading