From 8da2189fe4bf0aad0f998ccde1b13db25b8a9975 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:02:16 +0000 Subject: [PATCH 1/5] Tasks: colour spine, badge and icon by the Settings > Colours axes Task cards ignored Settings > Colours and always coloured their spine, badge and icon chip by urgency tone, while chore and memo cards followed the two "colour each element by" axes. That is the "tasks doing their own thing" the user saw. TaskUiState now carries colourAxes (fed from settings) and exposes pure, tested spineSwatchFor/iconSwatchFor helpers mirroring the chore side. TaskCard takes spineSwatch/iconSwatch params and resolves spine, icon chip and due badge through them, falling back to the urgency tone when a swatch is null, exactly as ChoreCard does. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sg2m9t6JspJ1MgpXgwCLTX --- .../com/mapgie/dash/ui/components/TaskCard.kt | 38 ++++++++++++++---- .../dash/ui/screens/tasks/TaskListScreen.kt | 13 +++++++ .../ui/screens/tasks/TaskListViewModel.kt | 15 +++++++ .../dash/ui/screens/tasks/TaskUiStateTest.kt | 39 +++++++++++++++++++ changelog/unreleased/task-colour-axes.json | 6 +++ 5 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 changelog/unreleased/task-colour-axes.json diff --git a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt index 4eebe0e..edeb552 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.mapgie.dash.data.model.Swatch import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.TaskPriority import com.mapgie.dash.data.model.TaskUrgency @@ -45,8 +46,10 @@ import com.mapgie.dash.ui.theme.badgeContainerColor import com.mapgie.dash.ui.theme.barColor import com.mapgie.dash.ui.theme.isDarkScheme import com.mapgie.dash.ui.theme.mutedCardContainer +import com.mapgie.dash.ui.theme.spineColor import com.mapgie.dash.ui.theme.statusTone import com.mapgie.dash.ui.theme.textColor +import com.mapgie.dash.ui.theme.tintColor import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -59,8 +62,11 @@ import java.time.temporal.ChronoUnit * title with an uppercase "CATEGORY · HIGH" caption beneath, and a single * right-hand row of owner avatar then due badge. * - * The spine colour means urgency (the app-wide bar meaning, see `StatusTone.kt`); - * priority is carried by the caption text, never by colour alone. + * Colour follows Settings › Colours' two axes, exactly as the Chores card does: + * the spine and due badge take [spineSwatch] (the category colour, badge text + * neutral) or, when it is null, the urgency status tone; the round icon chip does + * the same with [iconSwatch]. Priority is always carried by the caption text, and + * the badge words restate the state, so colour is never the only signal. */ @Composable fun TaskCard( @@ -71,6 +77,8 @@ fun TaskCard( showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, + spineSwatch: Swatch? = null, + iconSwatch: Swatch? = null, isPinned: Boolean = false, highlightQuery: String? = null ) { @@ -78,15 +86,21 @@ fun TaskCard( val accents = LocalTypeAccents.current val tone = task.statusTone() val dark = isDarkScheme() - val barColor = if (zenMode) Color.Transparent else tone.barColor() + val barColor = when { + zenMode -> Color.Transparent + spineSwatch != null -> spineSwatch.spineColor() + else -> tone.barColor() + } val chipContainer = when { zenMode -> Color.Transparent isDone -> MaterialTheme.colorScheme.surfaceContainerHigh + iconSwatch != null -> iconSwatch.tintColor() else -> tone.badgeContainerColor() ?: accents.taskContainer } val chipContent = when { zenMode || isDone -> MaterialTheme.colorScheme.onSurfaceVariant + iconSwatch != null -> iconSwatch.textColor() tone == StatusTone.NEUTRAL || tone == StatusTone.NONE -> accents.onTaskContainer else -> tone.textColor() } @@ -199,7 +213,7 @@ fun TaskCard( task.owner?.takeIf { showOwner && it.isNotBlank() }?.let { owner -> OwnerAvatar(handle = owner) } - if (!zenMode && !isDone) DueBadge(task = task) + if (!zenMode && !isDone) DueBadge(task = task, spineSwatch = spineSwatch) } } } @@ -211,28 +225,36 @@ fun TaskCard( * anything further out, matching the handoff's right-hand cluster. */ @Composable -private fun DueBadge(task: TaskDto) { +private fun DueBadge(task: TaskDto, spineSwatch: Swatch? = null) { val today = LocalDate.now(ZoneId.systemDefault()) val date = task.dueDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + // When the spine follows the category colour, the badge follows it too (they + // are one axis); the badge text drops to neutral so the words still lead. + val containerOverride = spineSwatch?.tintColor() + val textOverride = if (spineSwatch != null) MaterialTheme.colorScheme.onSurfaceVariant else null // "Eventually" carries no urgency but is still worth showing, so it never reads // as a task with no due at all. if (task.dueDate == null && task.duePeriod == "eventually") { - StatusBadge(text = "eventually", tone = StatusTone.NEUTRAL) + StatusBadge(text = "eventually", tone = StatusTone.NEUTRAL, containerOverride = containerOverride, textOverride = textOverride) return } when (task.urgency()) { TaskUrgency.OVERDUE -> { val late = date?.let { ChronoUnit.DAYS.between(it, today) } ?: 1L - StatusBadge(text = "${late}d late", tone = StatusTone.CRITICAL) + StatusBadge(text = "${late}d late", tone = StatusTone.CRITICAL, containerOverride = containerOverride, textOverride = textOverride) } - TaskUrgency.TODAY -> StatusBadge(text = "today", tone = StatusTone.ATTENTION) + TaskUrgency.TODAY -> StatusBadge(text = "today", tone = StatusTone.ATTENTION, containerOverride = containerOverride, textOverride = textOverride) TaskUrgency.THIS_WEEK -> StatusBadge( text = date?.format(DateTimeFormatter.ofPattern("EEE")) ?: "this week", tone = StatusTone.NEUTRAL, + containerOverride = containerOverride, + textOverride = textOverride, ) TaskUrgency.LATER -> StatusBadge( text = date?.format(DateTimeFormatter.ofPattern("d MMM")) ?: "this month", tone = StatusTone.NEUTRAL, + containerOverride = containerOverride, + textOverride = textOverride, ) TaskUrgency.NONE -> Unit } 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..d7f9ec3 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 @@ -54,6 +54,7 @@ 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.Swatch import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.draftKeyFor import com.mapgie.dash.data.model.TaskSortKey @@ -281,6 +282,8 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, icon = iconFor(task), + spineSwatch = uiState.spineSwatchFor(task), + iconSwatch = uiState.iconSwatchFor(task), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { @@ -352,6 +355,8 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, icon = iconFor(task), + spineSwatch = uiState.spineSwatchFor(task), + iconSwatch = uiState.iconSwatchFor(task), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, @@ -387,6 +392,8 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, icon = iconFor(task), + spineSwatch = uiState.spineSwatchFor(task), + iconSwatch = uiState.iconSwatchFor(task), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, @@ -423,6 +430,8 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, icon = iconFor(task), + spineSwatch = uiState.spineSwatchFor(task), + iconSwatch = uiState.iconSwatchFor(task), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { viewModel.markUndone(task.id) }, @@ -537,6 +546,8 @@ private fun SwipeToCompleteCard( showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, + spineSwatch: Swatch? = null, + iconSwatch: Swatch? = null, isPinned: Boolean = false, highlightQuery: String? = null ) { @@ -586,6 +597,8 @@ private fun SwipeToCompleteCard( showCategory = showCategory, showOwner = showOwner, zenMode = zenMode, + spineSwatch = spineSwatch, + iconSwatch = iconSwatch, isPinned = isPinned, highlightQuery = highlightQuery, modifier = Modifier 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..6bf4f27 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 @@ -6,10 +6,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mapgie.dash.alarm.AlarmScheduler import com.mapgie.dash.data.model.CategoryCatalog +import com.mapgie.dash.data.model.ChoreColourAxes 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.Swatch import com.mapgie.dash.data.model.TaskDraft import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.TaskInsert @@ -60,8 +62,20 @@ data class TaskUiState( val zenMode: Boolean = false, val zenSortAscending: Boolean = true, val catalog: CategoryCatalog = CategoryCatalog(), + val colourAxes: ChoreColourAxes = ChoreColourAxes(), val pinChooser: PinChooserState? = null, ) { + /** + * The swatch the task card's spine and due badge wear, or null to follow the + * urgency tone. Mirrors the Chores card so both lists obey Settings › Colours' + * "spine + badge" axis instead of each doing its own thing. + */ + fun spineSwatchFor(task: TaskDto): Swatch? = + colourAxes.spineSwatch(catalog.effectiveSwatch(task.category)) + + /** The swatch the task's round icon chip wears, or null to follow the urgency tone. */ + fun iconSwatchFor(task: TaskDto): Swatch? = + colourAxes.iconSwatch(catalog.effectiveSwatch(task.category)) val displayed: List get() { // Archived tasks never show; open and done tasks are split into the @@ -194,6 +208,7 @@ class TaskListViewModel @Inject constructor( hideThresholdDays = s.taskHideThresholdDays, zenMode = s.taskZenMode, sort = s.taskSort, + colourAxes = s.colourAxes, ) } } diff --git a/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt b/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt index 3271a64..437a931 100644 --- a/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt +++ b/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt @@ -1,13 +1,18 @@ package com.mapgie.dash.ui.screens.tasks import com.mapgie.dash.data.model.CategoryCatalog +import com.mapgie.dash.data.model.CategoryStyle +import com.mapgie.dash.data.model.ChoreColourAxes +import com.mapgie.dash.data.model.ColourChoresBy import com.mapgie.dash.data.model.OwnerFilter import com.mapgie.dash.data.model.SortOrder +import com.mapgie.dash.data.model.Swatch import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.TaskSortKey import java.time.LocalDate import java.time.ZoneId import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test /** @@ -196,4 +201,38 @@ class TaskUiStateTest { assertEquals(listOf("open"), ids(state.zenRows)) assertEquals(listOf("done_today"), ids(state.doneTasks)) } + + // ── Colour axes (Settings › Colours) ────────────────────────────────────── + // The Tasks list must obey the same spine/icon axes as the Chores list, not + // colour itself purely by urgency. + + private val kitchen = TaskDto(id = "k", title = "k", category = "Kitchen") + private val uncategorised = TaskDto(id = "u", title = "u", category = null) + private val kitchenCatalog = CategoryCatalog(styles = mapOf("Kitchen" to CategoryStyle(swatch = Swatch.PEACH.name))) + + @Test + fun `by default a task colours its icon by category and its spine by severity`() { + // Fresh install defaults: spine + badge by severity, icon by category. + val state = TaskUiState(catalog = kitchenCatalog) + assertEquals(Swatch.PEACH, state.iconSwatchFor(kitchen)) + assertNull(state.spineSwatchFor(kitchen)) + } + + @Test + fun `colouring the spine by category gives the task its category swatch`() { + val state = TaskUiState( + catalog = kitchenCatalog, + colourAxes = ChoreColourAxes(ColourChoresBy.CATEGORY, ColourChoresBy.CATEGORY), + ) + assertEquals(Swatch.PEACH, state.spineSwatchFor(kitchen)) + assertEquals(Swatch.PEACH, state.iconSwatchFor(kitchen)) + } + + @Test + fun `an uncategorised task still gets a stable fallback swatch when colouring by category`() { + val state = TaskUiState(colourAxes = ChoreColourAxes(ColourChoresBy.CATEGORY, ColourChoresBy.CATEGORY)) + // effectiveSwatch(null) is a fixed fallback, never null, so the chip is always coloured. + assertEquals(Swatch.SAGE, state.iconSwatchFor(uncategorised)) + assertEquals(Swatch.SAGE, state.spineSwatchFor(uncategorised)) + } } diff --git a/changelog/unreleased/task-colour-axes.json b/changelog/unreleased/task-colour-axes.json new file mode 100644 index 0000000..bbd11b0 --- /dev/null +++ b/changelog/unreleased/task-colour-axes.json @@ -0,0 +1,6 @@ +{ + "bump": "patch", + "fixed": [ + "Task cards now follow the Settings > Colours spine and icon axes, the same as chore cards, instead of always colouring by urgency." + ] +} From ca69841ebe0c9f92da2892203052f981d21a8b28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:13:59 +0000 Subject: [PATCH 2/5] Categories & memos: more icons and colours Adds ten category/memo icons (pets, baby, fitness, shopping, repairs, study, gifts, work, money, school) with their Lucide glyphs and keyword defaults, and four palette colours (teal, coral, slate, clay) with hand-tuned light/dark tones that clear the 4.5:1 contrast floor on tint, card and ground in both themes (SwatchContrastTest covers them). Both the category picker and the memo colour/icon pickers read the palettes dynamically, so the new choices appear in both places. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sg2m9t6JspJ1MgpXgwCLTX --- .../mapgie/dash/data/model/CategoryIcon.kt | 21 +++ .../java/com/mapgie/dash/data/model/Swatch.kt | 28 +++- .../com/mapgie/dash/ui/theme/LucideIcons.kt | 121 ++++++++++++++++++ .../dash/data/model/CategoryCatalogTest.kt | 11 ++ .../more-category-icons-colours.json | 7 + 5 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 changelog/unreleased/more-category-icons-colours.json diff --git a/app/src/main/java/com/mapgie/dash/data/model/CategoryIcon.kt b/app/src/main/java/com/mapgie/dash/data/model/CategoryIcon.kt index 9be58f1..45ca99f 100644 --- a/app/src/main/java/com/mapgie/dash/data/model/CategoryIcon.kt +++ b/app/src/main/java/com/mapgie/dash/data/model/CategoryIcon.kt @@ -24,6 +24,16 @@ enum class CategoryIcon(val label: String) { BRUSH("Cleaning"), LEAF("Leaf"), LAMP("Lamp"), + PAW_PRINT("Pets"), + BABY("Baby"), + DUMBBELL("Fitness"), + SHOPPING_CART("Shopping"), + WRENCH("Repairs"), + BOOK_OPEN("Study"), + GIFT("Gifts"), + BRIEFCASE("Work"), + WALLET("Money"), + GRADUATION_CAP("School"), CIRCLE_ALERT("Other"); companion object { @@ -31,6 +41,7 @@ enum class CategoryIcon(val label: String) { val pickerSet: List = listOf( WASHING_MACHINE, BRUSH, HOME, DROPLET, SPROUT, UTENSILS, BATH, TREE_PINE, LEAF, LAMP, CAR, ZAP, PILL, PRINTER, PLANE, SHIELD, CALENDAR, + PAW_PRINT, BABY, DUMBBELL, SHOPPING_CART, WRENCH, BOOK_OPEN, GIFT, BRIEFCASE, WALLET, GRADUATION_CAP, ) fun fromName(name: String?): CategoryIcon? = name?.let { n -> entries.firstOrNull { it.name == n } } @@ -56,6 +67,16 @@ enum class CategoryIcon(val label: String) { has("insur") -> SHIELD has("clean", "dust", "vacuum", "hoover", "mop") -> BRUSH has("water", "filter", "softener", "boiler") -> DROPLET + has("pet", "dog", "cat", "animal", "vet") -> PAW_PRINT + has("baby", "infant", "nappy", "diaper", "pram", "stroller") -> BABY + has("gym", "fitness", "workout", "exercise") -> DUMBBELL + has("shop", "grocer", "market", "store") -> SHOPPING_CART + has("repair", "maintenance", "diy", "tool", "fix") -> WRENCH + has("study", "read", "book", "homework") -> BOOK_OPEN + has("gift", "present", "birthday", "christmas") -> GIFT + has("work", "office", "job", "meeting") -> BRIEFCASE + has("money", "bank", "budget", "wallet", "saving") -> WALLET + has("school", "class", "college", "uni", "course", "exam") -> GRADUATION_CAP has("house", "home", "flat", "apartment") -> HOME else -> PRINTER } diff --git a/app/src/main/java/com/mapgie/dash/data/model/Swatch.kt b/app/src/main/java/com/mapgie/dash/data/model/Swatch.kt index b944ae0..04844ed 100644 --- a/app/src/main/java/com/mapgie/dash/data/model/Swatch.kt +++ b/app/src/main/java/com/mapgie/dash/data/model/Swatch.kt @@ -12,9 +12,9 @@ data class SwatchTones( ) /** - * The seven-colour palette the user picks from in Settings › Colours (severity - * tints) and Settings › Categories (a category's own colour). Plain Kotlin so - * the persisted name, the settings screens and the unit tests all share it. + * The palette the user picks from in Settings › Colours (severity tints) and + * Settings › Categories (a category's own colour). Plain Kotlin so the + * persisted name, the settings screens and the unit tests all share it. * * Each swatch carries a hand-tuned set of tones per brightness, matching the * Cozy Cream / Zen Dark handoff. The dark text tones are the design's; the @@ -60,6 +60,26 @@ enum class Swatch( "Peach", light = SwatchTones(spineArgb = 0xFFE0B28DL, textArgb = 0xFF8A562AL, tintArgb = 0xFFF6E8DCL), dark = SwatchTones(spineArgb = 0xFFE0B28DL, textArgb = 0xFFE0B28DL, tintArgb = 0xFF4A3A2EL), + ), + TEAL( + "Teal", + light = SwatchTones(spineArgb = 0xFF4F9D97L, textArgb = 0xFF2E6B66L, tintArgb = 0xFFDCEBE9L), + dark = SwatchTones(spineArgb = 0xFF6FB3ACL, textArgb = 0xFF8FCFC8L, tintArgb = 0xFF2B3D3AL), + ), + CORAL( + "Coral", + light = SwatchTones(spineArgb = 0xFFDD7F63L, textArgb = 0xFF9E4830L, tintArgb = 0xFFF7E4DCL), + dark = SwatchTones(spineArgb = 0xFFE39178L, textArgb = 0xFFEAA791L, tintArgb = 0xFF48332BL), + ), + SLATE( + "Slate", + light = SwatchTones(spineArgb = 0xFF7B8794L, textArgb = 0xFF4C5762L, tintArgb = 0xFFE4E7EAL), + dark = SwatchTones(spineArgb = 0xFF9AA6B2L, textArgb = 0xFFB4BEC8L, tintArgb = 0xFF373C42L), + ), + CLAY( + "Clay", + light = SwatchTones(spineArgb = 0xFFB56A4EL, textArgb = 0xFF9A4F34L, tintArgb = 0xFFF1E1D8L), + dark = SwatchTones(spineArgb = 0xFFC98363L, textArgb = 0xFFDCA184L, tintArgb = 0xFF433026L), ); fun tones(dark: Boolean): SwatchTones = if (dark) this.dark else light @@ -68,7 +88,7 @@ enum class Swatch( /** The six swatches offered for severity colours (Settings › Colours). */ val severityPalette: List = listOf(ROSE, GOLD, AMBER, SAGE, BLUE, LAVENDER) - /** All seven swatches, offered for category colours (Settings › Categories). */ + /** Every swatch, offered for category and memo colours (Settings › Categories). */ val categoryPalette: List = entries.toList() fun fromName(name: String?): Swatch? = name?.let { n -> entries.firstOrNull { it.name == n } } diff --git a/app/src/main/java/com/mapgie/dash/ui/theme/LucideIcons.kt b/app/src/main/java/com/mapgie/dash/ui/theme/LucideIcons.kt index 3abd960..9324f46 100644 --- a/app/src/main/java/com/mapgie/dash/ui/theme/LucideIcons.kt +++ b/app/src/main/java/com/mapgie/dash/ui/theme/LucideIcons.kt @@ -383,6 +383,117 @@ object LucideIcons { ) } + val PawPrint: ImageVector by lazy { + lucide( + "PawPrint", + listOf( + circle(11f, 4f, 2f), + circle(18f, 8f, 2f), + circle(20f, 16f, 2f), + "M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z", + ), + ) + } + + val Baby: ImageVector by lazy { + lucide( + "Baby", + listOf( + "M10 16c.5.3 1.2.5 2 .5s1.5-.2 2-.5", + "M15 12h.2", + "M19.38 6.813A9 9 0 0 1 20.8 10.2a2 2 0 0 1 0 3.6 9 9 0 0 1-17.6 0 2 2 0 0 1 0-3.6A9 9 0 0 1 12 3c2 0 3.5 1.1 3.5 2.5s-.9 2.5-2 2.5c-.8 0-1.5-.4-1.5-1", + "M9 12h.2", + ), + ) + } + + val Dumbbell: ImageVector by lazy { + lucide( + "Dumbbell", + listOf( + "M17.596 12.768a2 2 0 1 0 2.829-2.829l-1.768-1.767a2 2 0 0 0 2.828-2.829l-2.828-2.828a2 2 0 0 0-2.829 2.828l-1.767-1.768a2 2 0 1 0-2.829 2.829z", + "m2.5 21.5 1.4-1.4", + "m20.1 3.9 1.4-1.4", + "M5.343 21.485a2 2 0 1 0 2.829-2.828l1.767 1.768a2 2 0 1 0 2.829-2.829l-6.364-6.364a2 2 0 1 0-2.829 2.829l1.768 1.767a2 2 0 0 0-2.828 2.829z", + "m9.6 14.4 4.8-4.8", + ), + ) + } + + val ShoppingCart: ImageVector by lazy { + lucide( + "ShoppingCart", + listOf( + "m2.05 2.05 1.099-.028a1 1 0 0 1 1.008.815l2.69 14.347A1 1 0 0 0 7.83 18H18", + "M4.563 5h16.435a1 1 0 0 1 .981 1.204l-1.026 6.226A2 2 0 0 1 18.962 14H6.25", + circle(18f, 20f, 2f), + circle(8f, 20f, 2f), + ), + ) + } + + val Wrench: ImageVector by lazy { + lucide( + "Wrench", + listOf( + "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z", + ), + ) + } + + val BookOpen: ImageVector by lazy { + lucide( + "BookOpen", + listOf( + "M12 5v16", + "M20.001 19A2 2 0 0 0 22 17V5a2 2 0 0 0-1.999-2L16 3.002A5 5 0 0 0 12 5a5 5 0 0 0-4-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 1.999 2H8a5 5 0 0 1 4 2 5 5 0 0 1 4-2z", + ), + ) + } + + val Gift: ImageVector by lazy { + lucide( + "Gift", + listOf( + "M12 7v14", + "M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8", + "M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5", + rect(3f, 7f, 18f, 4f, 1f), + ), + ) + } + + val Briefcase: ImageVector by lazy { + lucide( + "Briefcase", + listOf( + "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16", + rect(2f, 6f, 20f, 14f, 2f), + ), + ) + } + + val Wallet: ImageVector by lazy { + lucide( + "Wallet", + listOf( + "M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1", + "M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4", + ), + ) + } + + val GraduationCap: ImageVector by lazy { + lucide( + "GraduationCap", + listOf( + "M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z", + "M22 10v6", + "M6 12.5V16a6 3 0 0 0 12 0v-3.5", + ), + ) + } + /** Resolves the drawable for a persisted [CategoryIcon]. */ fun forCategory(icon: CategoryIcon): ImageVector = when (icon) { CategoryIcon.DROPLET -> Droplet @@ -402,6 +513,16 @@ object LucideIcons { CategoryIcon.BRUSH -> Brush CategoryIcon.LEAF -> Leaf CategoryIcon.LAMP -> Lamp + CategoryIcon.PAW_PRINT -> PawPrint + CategoryIcon.BABY -> Baby + CategoryIcon.DUMBBELL -> Dumbbell + CategoryIcon.SHOPPING_CART -> ShoppingCart + CategoryIcon.WRENCH -> Wrench + CategoryIcon.BOOK_OPEN -> BookOpen + CategoryIcon.GIFT -> Gift + CategoryIcon.BRIEFCASE -> Briefcase + CategoryIcon.WALLET -> Wallet + CategoryIcon.GRADUATION_CAP -> GraduationCap CategoryIcon.CIRCLE_ALERT -> CircleAlert } } diff --git a/app/src/test/java/com/mapgie/dash/data/model/CategoryCatalogTest.kt b/app/src/test/java/com/mapgie/dash/data/model/CategoryCatalogTest.kt index 4a5dc74..eec2148 100644 --- a/app/src/test/java/com/mapgie/dash/data/model/CategoryCatalogTest.kt +++ b/app/src/test/java/com/mapgie/dash/data/model/CategoryCatalogTest.kt @@ -27,6 +27,17 @@ class CategoryCatalogTest { assertNull(catalog.swatchFor("Laundry")) } + @Test + fun `the added keyword defaults cover pets, fitness, money and school`() { + val catalog = CategoryCatalog() + assertEquals(CategoryIcon.PAW_PRINT, catalog.iconFor("Pets")) + assertEquals(CategoryIcon.DUMBBELL, catalog.iconFor("Gym")) + assertEquals(CategoryIcon.SHOPPING_CART, catalog.iconFor("Groceries")) + assertEquals(CategoryIcon.WALLET, catalog.iconFor("Money")) + assertEquals(CategoryIcon.GRADUATION_CAP, catalog.iconFor("School run")) + assertEquals(CategoryIcon.GIFT, catalog.iconFor("Birthday presents")) + } + @Test fun `general is always listed last and cannot be added or reordered`() { val catalog = CategoryCatalog().added("General").added("Car").withOrder(listOf("General", "Car", "Plants")) diff --git a/changelog/unreleased/more-category-icons-colours.json b/changelog/unreleased/more-category-icons-colours.json new file mode 100644 index 0000000..6b8ccd1 --- /dev/null +++ b/changelog/unreleased/more-category-icons-colours.json @@ -0,0 +1,7 @@ +{ + "bump": "minor", + "added": [ + "More category and memo icons: pets, baby, fitness, shopping, repairs, study, gifts, work, money and school.", + "Four more category and memo colours: teal, coral, slate and clay." + ] +} From 9d69ff747939e3ec518e1bb0cfa8f383573c2ec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:26:43 +0000 Subject: [PATCH 3/5] Tasks: a task can own many reminders, shown on the card and overview A task's reminders are its linked memos, and a task may have several. The list card shows a bell with the live count; the task overview lists each reminder and lets you remove it, alongside the existing Remind action that adds one. Editing a task no longer wipes those reminders: updateTask now reconciles only the memo that mirrors the task's own reminder_at (matched by its exact fire time), leaving every other attached reminder in place. Adding a reminder therefore keeps the task in the Tasks list and the memo in Memos, linked, rather than appearing to move the task wholesale into a memo. TaskUiState carries the linked reminders and exposes a tested activeReminderCountFor helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sg2m9t6JspJ1MgpXgwCLTX --- .../com/mapgie/dash/ui/components/TaskCard.kt | 23 ++++--- .../dash/ui/components/TaskOverviewSheet.kt | 64 ++++++++++++++++--- .../dash/ui/screens/tasks/TaskListScreen.kt | 11 ++++ .../ui/screens/tasks/TaskListViewModel.kt | 52 ++++++++++++--- .../dash/ui/screens/tasks/TaskUiStateTest.kt | 23 +++++++ .../unreleased/task-owns-many-reminders.json | 9 +++ 6 files changed, 153 insertions(+), 29 deletions(-) create mode 100644 changelog/unreleased/task-owns-many-reminders.json diff --git a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt index edeb552..35a40ee 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -50,7 +49,6 @@ import com.mapgie.dash.ui.theme.spineColor import com.mapgie.dash.ui.theme.statusTone import com.mapgie.dash.ui.theme.textColor import com.mapgie.dash.ui.theme.tintColor -import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -79,6 +77,7 @@ fun TaskCard( zenMode: Boolean = false, spineSwatch: Swatch? = null, iconSwatch: Swatch? = null, + reminderCount: Int = 0, isPinned: Boolean = false, highlightQuery: String? = null ) { @@ -175,20 +174,20 @@ fun TaskCard( modifier = Modifier.size(14.dp) ) } - if (task.reminderAt != null && task.reminded != true && !isDone) { - val reminderInstant = remember(task.reminderAt) { - runCatching { Instant.parse(task.reminderAt) }.getOrNull() - } - val isReminderPast = reminderInstant != null && reminderInstant.isBefore(Instant.now()) + if (reminderCount > 0 && !isDone) { Icon( imageVector = LucideIcons.Bell, - contentDescription = if (isReminderPast) "Reminder passed" else "Reminder set", - tint = if (isReminderPast) - MaterialTheme.colorScheme.onSurfaceVariant - else - MaterialTheme.colorScheme.primary, + contentDescription = if (reminderCount == 1) "1 reminder" else "$reminderCount reminders", + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp) ) + if (reminderCount > 1) { + Text( + text = reminderCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } } } if (zenMode) { diff --git a/app/src/main/java/com/mapgie/dash/ui/components/TaskOverviewSheet.kt b/app/src/main/java/com/mapgie/dash/ui/components/TaskOverviewSheet.kt index c28273d..44411b7 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/TaskOverviewSheet.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/TaskOverviewSheet.kt @@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties @@ -32,10 +35,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.mapgie.dash.data.model.ReminderDto import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.TaskPriority import com.mapgie.dash.data.model.TaskUrgency import com.mapgie.dash.data.model.priorityEnum +import com.mapgie.dash.data.model.remindAtInstant +import com.mapgie.dash.data.model.repeats import com.mapgie.dash.data.model.urgency import com.mapgie.dash.ui.components.core.StatusBadge import com.mapgie.dash.ui.components.sheet.DoneWhen @@ -80,10 +86,12 @@ fun TaskOverviewSheet( icon: ImageVector, isPinned: Boolean, sheetState: SheetState, + reminders: List = emptyList(), onMarkDone: (TaskDto, Instant?) -> Unit, onRestore: (TaskDto) -> Unit, onTogglePin: (TaskDto) -> Unit, onAddReminder: (TaskDto) -> Unit, + onDeleteReminder: (ReminderDto) -> Unit = {}, onEdit: (TaskDto) -> Unit, onDismiss: () -> Unit ) { @@ -221,6 +229,43 @@ fun TaskOverviewSheet( ), ) + if (reminders.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = if (reminders.size == 1) "1 reminder" else "${reminders.size} reminders", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold), + color = tokens.inkFaint, + ) + reminders.forEach { reminder -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = LucideIcons.Bell, + contentDescription = null, + tint = accents.onTaskContainer, + modifier = Modifier.size(16.dp), + ) + Text( + text = reminderRowText(reminder), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { onDeleteReminder(reminder) }) { + Icon( + imageVector = LucideIcons.X, + contentDescription = "Remove reminder", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + task.notes?.takeIf { it.isNotBlank() }?.let { NotesReadBlock(text = it) } } } @@ -289,7 +334,6 @@ private fun taskCalendarInfo(task: TaskDto) = when { private val DAY_MONTH_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM") private val DONE_AT_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM 'at' HH:mm") -private val REMINDER_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMM HH:mm") /** "2d late", "due today", "due Thu", "due 12 Sep", "due this week", or null. */ private fun dueBadgeText(task: TaskDto): String? { @@ -304,7 +348,7 @@ private fun dueBadgeText(task: TaskDto): String? { } } -/** "added 12 Aug · no reminder" / "reminder 4 Sep 09:00" / "done Thu 4 Sep at 10:15". */ +/** "added 12 Aug" / "done Thu 4 Sep at 10:15". Reminders now live in their own section. */ private fun metaText(task: TaskDto): String { task.completedAt?.let { raw -> val completedAt = runCatching { Instant.parse(raw) }.getOrNull() ?: return "done" @@ -312,10 +356,14 @@ private fun metaText(task: TaskDto): String { } val added = runCatching { Instant.parse(task.createdAt) }.getOrNull() ?.atZone(ZoneId.systemDefault())?.format(DAY_MONTH_FORMATTER) - val reminder = task.reminderAt?.let { runCatching { Instant.parse(it) }.getOrNull() } - ?.atZone(ZoneId.systemDefault())?.format(REMINDER_FORMATTER) - return listOfNotNull( - added?.let { "added $it" }, - reminder?.let { "reminder $it" } ?: "no reminder", - ).joinToString(" · ") + return added?.let { "added $it" } ?: "task" +} + +private val REMINDER_ROW_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM 'at' HH:mm") + +/** "Thu 4 Sep at 09:00", plus " · repeats" for a weekly memo. */ +private fun reminderRowText(reminder: ReminderDto): String { + val at = reminder.remindAtInstant()?.atZone(ZoneId.systemDefault())?.format(REMINDER_ROW_FORMATTER) + ?: return "reminder" + return if (reminder.repeats) "$at · repeats" else at } 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 d7f9ec3..f1ae09f 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 @@ -54,6 +54,7 @@ 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.isDone import com.mapgie.dash.data.model.Swatch import com.mapgie.dash.data.model.TaskDto import com.mapgie.dash.data.model.draftKeyFor @@ -284,6 +285,7 @@ fun TaskListScreen( icon = iconFor(task), spineSwatch = uiState.spineSwatchFor(task), iconSwatch = uiState.iconSwatchFor(task), + reminderCount = uiState.activeReminderCountFor(task.id), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { @@ -357,6 +359,7 @@ fun TaskListScreen( icon = iconFor(task), spineSwatch = uiState.spineSwatchFor(task), iconSwatch = uiState.iconSwatchFor(task), + reminderCount = uiState.activeReminderCountFor(task.id), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, @@ -394,6 +397,7 @@ fun TaskListScreen( icon = iconFor(task), spineSwatch = uiState.spineSwatchFor(task), iconSwatch = uiState.iconSwatchFor(task), + reminderCount = uiState.activeReminderCountFor(task.id), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, @@ -432,6 +436,7 @@ fun TaskListScreen( icon = iconFor(task), spineSwatch = uiState.spineSwatchFor(task), iconSwatch = uiState.iconSwatchFor(task), + reminderCount = uiState.activeReminderCountFor(task.id), onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { viewModel.markUndone(task.id) }, @@ -484,6 +489,10 @@ fun TaskListScreen( icon = iconFor(task), isPinned = task.id == uiState.pinnedTaskId, sheetState = overviewSheetState, + reminders = uiState.reminders + .filter { it.taskId == task.id && it.archivedAt == null && !it.isDone } + .sortedBy { it.remindAt }, + onDeleteReminder = { viewModel.deleteTaskReminder(it.id) }, onMarkDone = { t, at -> viewModel.markDone(t.id, at) showOverviewSheet = false @@ -548,6 +557,7 @@ private fun SwipeToCompleteCard( zenMode: Boolean = false, spineSwatch: Swatch? = null, iconSwatch: Swatch? = null, + reminderCount: Int = 0, isPinned: Boolean = false, highlightQuery: String? = null ) { @@ -599,6 +609,7 @@ private fun SwipeToCompleteCard( zenMode = zenMode, spineSwatch = spineSwatch, iconSwatch = iconSwatch, + reminderCount = reminderCount, isPinned = isPinned, highlightQuery = highlightQuery, modifier = Modifier 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 6bf4f27..793ac74 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 @@ -19,6 +19,8 @@ import com.mapgie.dash.data.model.TaskPriority import com.mapgie.dash.data.model.TaskSortKey import com.mapgie.dash.data.model.TaskUpdate import com.mapgie.dash.data.model.TaskUrgency +import com.mapgie.dash.data.model.ReminderDto +import com.mapgie.dash.data.model.isDone import com.mapgie.dash.data.model.priorityEnum import com.mapgie.dash.data.model.remindAtInstant import com.mapgie.dash.data.model.reminderInstant @@ -63,8 +65,17 @@ data class TaskUiState( val zenSortAscending: Boolean = true, val catalog: CategoryCatalog = CategoryCatalog(), val colourAxes: ChoreColourAxes = ChoreColourAxes(), + /** Every reminder (memo) linked to a task, so a task can carry more than one. */ + val reminders: List = emptyList(), val pinChooser: PinChooserState? = null, ) { + /** + * How many live reminders a task carries: its linked memos that are neither + * archived nor already done. A task can own several, so the card shows a + * count, and adding one never moves the task out of the Tasks list. + */ + fun activeReminderCountFor(taskId: String): Int = + reminders.count { it.taskId == taskId && it.archivedAt == null && !it.isDone } /** * The swatch the task card's spine and due badge wear, or null to follow the * urgency tone. Mirrors the Chores card so both lists obey Settings › Colours' @@ -224,6 +235,11 @@ class TaskListViewModel @Inject constructor( _uiState.update { it.copy(pinnedTaskId = pinnedTaskId) } } } + viewModelScope.launch { + reminderRepository.remindersFlow.collect { all -> + _uiState.update { state -> state.copy(reminders = all.filter { it.taskId != null }) } + } + } load() } @@ -293,17 +309,21 @@ class TaskListViewModel @Inject constructor( fun updateTask(id: String, update: TaskUpdate) { viewModelScope.launch { runCatching { + val old = _uiState.value.tasks.find { it.id == id } + val oldReminderAt = old?.reminderAt // Cancel old-style task alarm (backward compat for reminders created before this change) - _uiState.value.tasks.find { it.id == id }?.let { old -> - if (old.reminderAt != null) alarmScheduler.cancelTask(id) + if (oldReminderAt != null) alarmScheduler.cancelTask(id) + // Reconcile only the memo that mirrors this task's own reminder_at (it + // carries the exact same fire time); leave every other reminder the user + // attached to the task in place, so editing a task never wipes them. + if (oldReminderAt != null) { + reminderRepository.loadReminders() + .filter { it.taskId == id && it.archivedAt == null && it.remindAt == oldReminderAt } + .forEach { reminder -> + alarmScheduler.cancelReminder(reminder.id) + reminderRepository.deleteReminder(reminder.id) + } } - // Cancel and delete any existing ReminderDto linked to this task - reminderRepository.loadReminders() - .filter { it.taskId == id && it.archivedAt == null } - .forEach { reminder -> - alarmScheduler.cancelReminder(reminder.id) - reminderRepository.deleteReminder(reminder.id) - } val task = taskRepository.updateTask(id, update) if (task.reminderAt != null) { task.reminderInstant()?.let { at -> @@ -329,6 +349,20 @@ class TaskListViewModel @Inject constructor( reminder.remindAtInstant()?.let { at -> alarmScheduler.scheduleReminder(reminder.id, reminder.subject, at, insert.taskId) } + WidgetUpdater.updateAll(appContext) + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + + /** Removes one reminder from a task (from the overview's reminders list). */ + fun deleteTaskReminder(reminderId: String) { + viewModelScope.launch { + runCatching { + alarmScheduler.cancelReminder(reminderId) + reminderRepository.deleteReminder(reminderId) + WidgetUpdater.updateAll(appContext) }.onFailure { e -> _uiState.update { it.copy(error = e.userFacingMessage()) } } diff --git a/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt b/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt index 437a931..c8aaeef 100644 --- a/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt +++ b/app/src/test/java/com/mapgie/dash/ui/screens/tasks/TaskUiStateTest.kt @@ -5,6 +5,7 @@ import com.mapgie.dash.data.model.CategoryStyle import com.mapgie.dash.data.model.ChoreColourAxes import com.mapgie.dash.data.model.ColourChoresBy import com.mapgie.dash.data.model.OwnerFilter +import com.mapgie.dash.data.model.ReminderDto import com.mapgie.dash.data.model.SortOrder import com.mapgie.dash.data.model.Swatch import com.mapgie.dash.data.model.TaskDto @@ -235,4 +236,26 @@ class TaskUiStateTest { assertEquals(Swatch.SAGE, state.iconSwatchFor(uncategorised)) assertEquals(Swatch.SAGE, state.spineSwatchFor(uncategorised)) } + + // ── Reminders a task owns ───────────────────────────────────────────────── + // A task can carry several reminders (memos linked by task_id); the card + // counts the live ones so adding a reminder never hides the task. + + private fun memo(id: String, taskId: String?, archivedAt: String? = null, completedAt: String? = null) = + ReminderDto(id = id, subject = "s", remindAt = "2026-09-20T09:00:00Z", taskId = taskId, archivedAt = archivedAt, completedAt = completedAt) + + @Test + fun `a task counts its live reminders and ignores archived, done and other tasks'`() { + val reminders = listOf( + memo("a", taskId = "t1"), + memo("b", taskId = "t1"), + memo("c", taskId = "t1", archivedAt = "2026-09-01T00:00:00Z"), + memo("d", taskId = "t1", completedAt = "2026-09-01T00:00:00Z"), + memo("e", taskId = "other"), + ) + val state = TaskUiState(reminders = reminders) + assertEquals(2, state.activeReminderCountFor("t1")) + assertEquals(1, state.activeReminderCountFor("other")) + assertEquals(0, state.activeReminderCountFor("none")) + } } diff --git a/changelog/unreleased/task-owns-many-reminders.json b/changelog/unreleased/task-owns-many-reminders.json new file mode 100644 index 0000000..92bad0e --- /dev/null +++ b/changelog/unreleased/task-owns-many-reminders.json @@ -0,0 +1,9 @@ +{ + "bump": "minor", + "added": [ + "A task can now carry more than one reminder. The card shows how many, and the task overview lists them so you can add or remove each." + ], + "fixed": [ + "Adding a reminder to a task no longer moves it out of the Tasks list, and editing a task no longer deletes the reminders you attached to it." + ] +} From ef0c56c1b51efcf5e63aa6e3fdee54276591dc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:30:05 +0000 Subject: [PATCH 4/5] Tasks: swipe left to delete, with a confirm prompt and Undo Swipe-right still marks a task done; swipe-left now starts a delete. A dialog confirms it (the destructive action needs a deliberate second tap), then the task and its reminders are removed and an Undo snackbar offers to bring them back. Undo re-creates the task with its due, notes, owner and reminders, and restores its widget pin. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sg2m9t6JspJ1MgpXgwCLTX --- .../dash/ui/screens/tasks/TaskListScreen.kt | 75 +++++++++++++-- .../ui/screens/tasks/TaskListViewModel.kt | 96 +++++++++++++++++++ changelog/unreleased/task-swipe-delete.json | 6 ++ 3 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 changelog/unreleased/task-swipe-delete.json 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 f1ae09f..3b5bab0 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 @@ -104,6 +105,7 @@ fun TaskListScreen( val collapsedCategories = remember { mutableStateListOf() } var showSortSheet by remember { mutableStateOf(false) } var reminderTargetTask by remember { mutableStateOf(null) } + var pendingDeleteTask by remember { mutableStateOf(null) } var searchActive by rememberSaveable { mutableStateOf(false) } var searchQuery by rememberSaveable { mutableStateOf("") } @@ -152,6 +154,19 @@ fun TaskListScreen( } } + // Undo snackbar for a swipe-deleted task. + LaunchedEffect(uiState.recentDelete) { + val recent = uiState.recentDelete ?: return@LaunchedEffect + snackbarHost.currentSnackbarData?.dismiss() + val result = snackbarHost.showSnackbar( + message = "“${recent.task.title}” deleted", + actionLabel = "Undo", + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.undoDelete(recent) + else viewModel.clearRecentDelete() + } + LaunchedEffect(pendingAddIntent) { if (pendingAddIntent == AddMenuOption.TASK) { editingTaskId = null @@ -292,6 +307,7 @@ fun TaskListScreen( if (task.completedAt != null) viewModel.markUndone(task.id) else completeTaskWithUndo(task) }, + onSwipeDelete = { pendingDeleteTask = it }, isPinned = task.id == uiState.pinnedTaskId, highlightQuery = query ) @@ -363,6 +379,7 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, + onSwipeDelete = { pendingDeleteTask = it }, showCategory = false, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode, @@ -401,6 +418,7 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { completeTaskWithUndo(task) }, + onSwipeDelete = { pendingDeleteTask = it }, showCategory = !uiState.groupByCategory, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode, @@ -440,6 +458,7 @@ fun TaskListScreen( onTap = { overviewTask = it; showOverviewSheet = true }, onLongPress = { editingTaskId = it.id; showTaskSheet = true }, onToggleDone = { viewModel.markUndone(task.id) }, + onSwipeDelete = { pendingDeleteTask = it }, showCategory = !uiState.groupByCategory, showOwner = uiState.ownerFilter.showsOwner, zenMode = uiState.zenMode @@ -542,6 +561,23 @@ fun TaskListScreen( onDismiss = { viewModel.dismissPinChooser() } ) } + + pendingDeleteTask?.let { target -> + AlertDialog( + onDismissRequest = { pendingDeleteTask = null }, + title = { Text("Delete “${target.title}”?") }, + text = { Text("This removes the task and its reminders. You can undo straight after.") }, + confirmButton = { + TextButton(onClick = { + pendingDeleteTask = null + viewModel.deleteTaskWithUndo(target) + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeleteTask = null }) { Text("Cancel") } + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -552,6 +588,7 @@ private fun SwipeToCompleteCard( onTap: (TaskDto) -> Unit, onLongPress: (TaskDto) -> Unit, onToggleDone: () -> Unit, + onSwipeDelete: (TaskDto) -> Unit = {}, showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, @@ -564,23 +601,27 @@ private fun SwipeToCompleteCard( val isDone = task.completedAt != null val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.StartToEnd) { - onToggleDone() + when (value) { + // Swipe right marks done; swipe left asks to delete (a dialog confirms, + // then an Undo snackbar covers a slip). Neither actually dismisses the row. + SwipeToDismissBoxValue.StartToEnd -> onToggleDone() + SwipeToDismissBoxValue.EndToStart -> onSwipeDelete(task) + SwipeToDismissBoxValue.Settled -> Unit } 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 - // list doesn't silently tick a task off. + // Require a deliberate swipe most of the way across the card before either + // action registers, so a stray horizontal drag while scrolling the list + // doesn't silently tick a task off or start a delete. positionalThreshold = { it * 0.6f } ) SwipeToDismissBox( state = dismissState, enableDismissFromStartToEnd = true, - enableDismissFromEndToStart = false, + enableDismissFromEndToStart = true, backgroundContent = { - if (dismissState.dismissDirection != SwipeToDismissBoxValue.Settled) { - Box( + when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> Box( modifier = Modifier .fillMaxSize() .padding(horizontal = Dimens.cardInset) @@ -597,6 +638,24 @@ private fun SwipeToCompleteCard( color = MaterialTheme.colorScheme.onSecondaryContainer ) } + SwipeToDismissBoxValue.EndToStart -> Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Dimens.cardInset) + .background( + MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium + ), + contentAlignment = Alignment.CenterEnd + ) { + Text( + "Delete", + modifier = Modifier.padding(end = 24.dp), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + SwipeToDismissBoxValue.Settled -> Unit } } ) { 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 793ac74..959ed29 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 @@ -50,6 +50,16 @@ import javax.inject.Inject /** Label shown on a group header for tasks with no category. */ const val OTHER_CATEGORY_LABEL = "Other" +/** + * A just-deleted task held for the length of an Undo snackbar: enough to + * re-create it and the reminders that were attached to it. + */ +data class RecentTaskDelete( + val task: TaskDto, + val reminders: List, + val wasPinned: Boolean, +) + data class TaskUiState( val loading: Boolean = true, val error: String? = null, @@ -67,6 +77,8 @@ data class TaskUiState( val colourAxes: ChoreColourAxes = ChoreColourAxes(), /** Every reminder (memo) linked to a task, so a task can carry more than one. */ val reminders: List = emptyList(), + /** A task just deleted by swipe, awaiting its Undo snackbar. */ + val recentDelete: RecentTaskDelete? = null, val pinChooser: PinChooserState? = null, ) { /** @@ -445,6 +457,90 @@ class TaskListViewModel @Inject constructor( } } + /** + * Swipe-to-delete: removes the task and its reminders, but keeps a snapshot so + * the Undo snackbar can restore both. The delete itself is a real delete; Undo + * re-creates the task (a fresh id) with its due, notes and reminders intact. + */ + fun deleteTaskWithUndo(task: TaskDto) { + viewModelScope.launch { + runCatching { + val linked = reminderRepository.loadReminders().filter { it.taskId == task.id } + val wasPinned = _uiState.value.pinnedTaskId == task.id + alarmScheduler.cancelTask(task.id) + linked.forEach { reminder -> + alarmScheduler.cancelReminder(reminder.id) + reminderRepository.deleteReminder(reminder.id) + } + taskRepository.deleteTask(task.id) + if (wasPinned) pinnedItemStore.setPinned(null) + _uiState.update { it.copy(recentDelete = RecentTaskDelete(task, linked, wasPinned)) } + load() + WidgetUpdater.updateAll(appContext) + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + + /** Restores a task removed by [deleteTaskWithUndo], with its reminders. */ + fun undoDelete(recent: RecentTaskDelete) { + viewModelScope.launch { + runCatching { + val t = recent.task + val restored = taskRepository.addTask( + TaskInsert( + title = t.title, + notes = t.notes, + category = t.category, + owner = t.owner, + priority = t.priority, + dueDate = t.dueDate, + duePeriod = t.duePeriod, + reminderAt = t.reminderAt, + ) + ) + // addTask re-creates and schedules the reminder_at mirror memo, so skip it + // here; re-create every other reminder that was attached to the task. + recent.reminders + .filterNot { it.remindAt == t.reminderAt } + .forEach { r -> + val re = reminderRepository.addReminder( + ReminderInsert( + subject = r.subject, + remindAt = r.remindAt, + taskId = restored.id, + repeatDays = r.repeatDays, + sound = r.sound, + colour = r.colour, + icon = r.icon, + ) + ) + if (r.archivedAt == null && !r.reminded && r.completedAt == null) { + re.remindAtInstant()?.let { at -> + if (at.isAfter(Instant.now())) { + alarmScheduler.scheduleReminder(re.id, re.subject, at, restored.id) + } + } + } + } + t.completedAt?.let { done -> + runCatching { Instant.parse(done) }.getOrNull()?.let { taskRepository.markDone(restored.id, it) } + } + if (recent.wasPinned) { + pinnedItemStore.togglePinned(PinnedWidgetItem(PinnedItemType.TASK, restored.id)) + } + _uiState.update { it.copy(recentDelete = null) } + load() + WidgetUpdater.updateAll(appContext) + }.onFailure { e -> + _uiState.update { it.copy(error = e.userFacingMessage()) } + } + } + } + + fun clearRecentDelete() = _uiState.update { it.copy(recentDelete = null) } + /** Sort pill choice; applied immediately and persisted. */ fun setSort(order: SortOrder) { _uiState.update { it.copy(sort = order) } diff --git a/changelog/unreleased/task-swipe-delete.json b/changelog/unreleased/task-swipe-delete.json new file mode 100644 index 0000000..c15b418 --- /dev/null +++ b/changelog/unreleased/task-swipe-delete.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Swipe a task left to delete it. A prompt confirms, and an Undo snackbar restores the task and its reminders if you change your mind." + ] +} From e26da2a6e4a6cff1723a583bbea9b01423f89791 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:37:20 +0000 Subject: [PATCH 5/5] Tasks: a tap opens the task instead of marking it done The task card's left chip was a tap-to-complete toggle, so a short press on a task ticked it off rather than opening it, unlike a chore. Tasks now use the same plain icon chip as chores (a check once done), so tapping anywhere on the card opens the task overview. Completion stays on the swipe-right gesture and the overview's Mark done, so a tap never silently completes a task. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Sg2m9t6JspJ1MgpXgwCLTX --- .../com/mapgie/dash/ui/components/TaskCard.kt | 19 +++++++++---------- .../dash/ui/screens/tasks/TaskListScreen.kt | 1 - changelog/unreleased/task-tap-opens.json | 6 ++++++ 3 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/task-tap-opens.json diff --git a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt index 35a40ee..58a17b5 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt @@ -31,7 +31,7 @@ import com.mapgie.dash.data.model.TaskPriority import com.mapgie.dash.data.model.TaskUrgency import com.mapgie.dash.data.model.priorityEnum import com.mapgie.dash.data.model.urgency -import com.mapgie.dash.ui.components.core.DoneToggleChip +import com.mapgie.dash.ui.components.core.CardIconChip import com.mapgie.dash.ui.components.core.MetaCaption import com.mapgie.dash.ui.components.core.MetaLabel import com.mapgie.dash.ui.components.core.OwnerAvatar @@ -55,10 +55,12 @@ import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit /** - * The revised (turn 5a) list card for a task: urgency spine, a circular - * done-toggle chip carrying the category's Lucide [icon] on the urgency tint, - * title with an uppercase "CATEGORY · HIGH" caption beneath, and a single - * right-hand row of owner avatar then due badge. + * The revised (turn 5a) list card for a task: urgency spine, a circular icon + * chip carrying the category's Lucide [icon] (a check once done) on the urgency + * tint, title with an uppercase "CATEGORY · HIGH" caption beneath, and a single + * right-hand row of owner avatar then due badge. Tapping the card opens the task; + * completion is a swipe-right or the overview's Mark done, so a tap never + * silently ticks the task off. * * Colour follows Settings › Colours' two axes, exactly as the Chores card does: * the spine and due badge take [spineSwatch] (the category colour, badge text @@ -69,7 +71,6 @@ import java.time.temporal.ChronoUnit @Composable fun TaskCard( task: TaskDto, - onToggleDone: () -> Unit, icon: ImageVector, modifier: Modifier = Modifier, showCategory: Boolean = true, @@ -139,10 +140,8 @@ fun TaskCard( horizontalArrangement = Arrangement.spacedBy(9.dp), verticalAlignment = Alignment.CenterVertically ) { - DoneToggleChip( - isDone = isDone, - onToggle = onToggleDone, - icon = icon, + CardIconChip( + icon = if (isDone) LucideIcons.Check else icon, containerColor = chipContainer, contentColor = chipContent, ) 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 3b5bab0..ab3381c 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 @@ -661,7 +661,6 @@ private fun SwipeToCompleteCard( ) { TaskCard( task = task, - onToggleDone = onToggleDone, icon = icon, showCategory = showCategory, showOwner = showOwner, diff --git a/changelog/unreleased/task-tap-opens.json b/changelog/unreleased/task-tap-opens.json new file mode 100644 index 0000000..67b60a6 --- /dev/null +++ b/changelog/unreleased/task-tap-opens.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "changed": [ + "Tapping a task now opens it (the same overview as a chore) instead of marking it done. Mark a task done with a swipe right or the Mark done button." + ] +}