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/components/TaskCard.kt b/app/src/main/java/com/mapgie/dash/ui/components/TaskCard.kt index 4eebe0e..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 @@ -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 @@ -26,12 +25,13 @@ 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 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 @@ -45,32 +45,40 @@ 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 java.time.Instant +import com.mapgie.dash.ui.theme.tintColor import java.time.LocalDate import java.time.ZoneId 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. * - * 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( task: TaskDto, - onToggleDone: () -> Unit, icon: ImageVector, modifier: Modifier = Modifier, showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, + spineSwatch: Swatch? = null, + iconSwatch: Swatch? = null, + reminderCount: Int = 0, 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() } @@ -126,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, ) @@ -161,20 +173,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) { @@ -199,7 +211,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 +223,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/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 99489ac..efec0fd 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,8 @@ 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.SwipeAction import com.mapgie.dash.data.model.SwipeDirection import com.mapgie.dash.data.model.SwipePair @@ -155,7 +157,7 @@ fun TaskListScreen( if (result == SnackbarResult.ActionPerformed) viewModel.archiveTask(task.id, false) } } - SwipeAction.DELETE -> viewModel.deleteTask(task.id) + SwipeAction.DELETE -> viewModel.deleteTaskWithUndo(task) SwipeAction.SNOOZE, SwipeAction.NONE -> Unit } } @@ -179,6 +181,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 @@ -310,12 +325,11 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, 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 = { - if (task.completedAt != null) viewModel.markUndone(task.id) - else completeTaskWithUndo(task) - }, swipe = uiState.swipe, onSwipe = { swipeTask(it, task) }, isPinned = task.id == uiState.pinnedTaskId, @@ -383,9 +397,11 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, 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) }, swipe = uiState.swipe, onSwipe = { swipeTask(it, task) }, showCategory = false, @@ -420,9 +436,11 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, 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) }, swipe = uiState.swipe, onSwipe = { swipeTask(it, task) }, showCategory = !uiState.groupByCategory, @@ -458,9 +476,11 @@ fun TaskListScreen( SwipeToCompleteCard( task = task, 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) }, swipe = uiState.swipe, onSwipe = { swipeTask(it, task) }, showCategory = !uiState.groupByCategory, @@ -497,7 +517,7 @@ fun TaskListScreen( categories = uiState.categories, onSave = { insert -> viewModel.addTask(insert) }, onUpdate = { update -> editingTaskId?.let { viewModel.updateTask(it, update) } }, - onDelete = { editingTaskId?.let { viewModel.deleteTask(it) } }, + onDelete = { editingTask?.let { viewModel.deleteTaskWithUndo(it) } }, onDismiss = { showTaskSheet = false; editingTaskId = null }, draft = remember(draftKey) { viewModel.taskDrafts.get(draftKey) }, onDraftChange = { viewModel.taskDrafts.put(draftKey, it) }, @@ -512,6 +532,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 @@ -561,6 +585,7 @@ fun TaskListScreen( onDismiss = { viewModel.dismissPinChooser() } ) } + } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -570,12 +595,14 @@ private fun SwipeToCompleteCard( icon: ImageVector, onTap: (TaskDto) -> Unit, onLongPress: (TaskDto) -> Unit, - onToggleDone: () -> Unit, swipe: SwipePair, onSwipe: (SwipeAction) -> Unit, showCategory: Boolean = true, showOwner: Boolean = true, zenMode: Boolean = false, + spineSwatch: Swatch? = null, + iconSwatch: Swatch? = null, + reminderCount: Int = 0, isPinned: Boolean = false, highlightQuery: String? = null ) { @@ -619,11 +646,13 @@ private fun SwipeToCompleteCard( ) { TaskCard( task = task, - onToggleDone = onToggleDone, icon = icon, showCategory = showCategory, showOwner = showOwner, zenMode = zenMode, + spineSwatch = spineSwatch, + iconSwatch = iconSwatch, + reminderCount = reminderCount, isPinned = isPinned, highlightQuery = highlightQuery, modifier = Modifier @@ -639,7 +668,7 @@ private fun SwipeToCompleteCard( AlertDialog( onDismissRequest = { showDeleteConfirm = false }, title = { Text("Delete task?") }, - text = { Text("“${task.title}” will be permanently removed.") }, + text = { Text("“${task.title}” and its reminders will be removed. You can undo straight after.") }, confirmButton = { TextButton(onClick = { showDeleteConfirm = false 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 de4d911..4f2f3ba 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.SwipePair import com.mapgie.dash.data.model.SwipeSubject import com.mapgie.dash.data.model.TaskDraft @@ -19,6 +21,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 @@ -48,6 +52,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, @@ -62,10 +76,33 @@ data class TaskUiState( val zenMode: Boolean = false, 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(), + /** A task just deleted by swipe, awaiting its Undo snackbar. */ + val recentDelete: RecentTaskDelete? = null, val pinChooser: PinChooserState? = null, /** Settings › Swipe actions for task cards. */ val swipe: SwipePair = SwipeSubject.TASKS.default, ) { + /** + * 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' + * "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 @@ -198,6 +235,7 @@ class TaskListViewModel @Inject constructor( hideThresholdDays = s.taskHideThresholdDays, zenMode = s.taskZenMode, sort = s.taskSort, + colourAxes = s.colourAxes, swipe = s.swipeActions.tasks, ) } @@ -214,6 +252,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() } @@ -283,17 +326,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 -> @@ -319,6 +366,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()) } } @@ -433,6 +494,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/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/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..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 @@ -1,13 +1,19 @@ 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.ReminderDto 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 +202,60 @@ 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)) + } + + // ── 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/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." + ] +} 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." + ] +} 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." + ] +} diff --git a/changelog/unreleased/task-swipe-delete.json b/changelog/unreleased/task-swipe-delete.json new file mode 100644 index 0000000..e136eec --- /dev/null +++ b/changelog/unreleased/task-swipe-delete.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Deleting a task (from a swipe or the edit sheet) now offers an Undo that restores the task and its reminders." + ] +} 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." + ] +}