listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit 33e450184c4460d41fab4926c06e4b01ac76c18e
parent f9fad530327ed21946a13c6a8604ace30c0f40e1
Author: Michael Camilleri <[email protected]>
Date:   Thu,  5 Mar 2026 06:09:34 +0900

Add undo toast

In the iOS version, this commit adds a 'toast' notification to make it
easy for the user to undo task-destructive operations (clearing
completed tasks, deleting tasks).

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MAGENTS.md | 4++--
MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Extensions/TaskListView+Logic.swift | 7+++++++
MListless/Models/TaskStore.swift | 9++++++++-
AListlessiOS/Extensions/TaskListView+Undo.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 31++++++++++++++++++++++++++-----
AListlessiOS/Views/UndoToast.swift | 33+++++++++++++++++++++++++++++++++
MTests/Support/TestHelpers.swift | 1+
8 files changed, 147 insertions(+), 8 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -46,10 +46,10 @@ - `Generated/BuildNumber.xcconfig` is gitignored; the scheme pre-action creates it before every build. ## Coding Style & Naming Conventions -- Use SwiftUI + Combine, indent four spaces, and prefer trailing commas in builders. +- Use SwiftUI + Observation (`@Observable`), indent four spaces, and prefer trailing commas in builders. - Models are nouns (`TaskItem`), views end with `View`, and services end with `Service`; keep async methods verb-first (`syncTasks()`). - Centralize state in `@Observable TaskStore` that wraps Core Data operations; mutations flow through intent methods like `complete(taskID:)`. -- `TaskStore.createTask(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the task before all existing active tasks. +- `TaskStore.createTask(title:atBeginning:)` accepts an `atBeginning` flag (default `false`); when `true` assigns `minSortOrder - 1000` to prepend the task before all existing active tasks. `createTask` does **not** save — callers must call `store.save()` explicitly when they want to persist. This keeps empty placeholder tasks (from background tap or Return) in-memory only until the user types, avoiding iCloud sync of transient objects. - Completed tasks display below active ones; never reorder or edit them in-place. - For selection state in ForEach contexts, use computed Bool values + callbacks rather than passing @Binding to children (avoids SwiftUI update issues). diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; }; 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; }; 19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; }; + 1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658295C1386BFF48CE3C2419 /* UndoToast.swift */; }; 1AA328A921EF8A7FDD03119A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; }; 269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */; }; 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */; }; @@ -43,6 +44,7 @@ 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; }; 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; }; 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; }; + 879608140FA8D7A32078C3CF /* TaskListView+Undo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */; }; 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; }; 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; }; 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; }; @@ -109,6 +111,7 @@ 5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; }; 632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; }; + 658295C1386BFF48CE3C2419 /* UndoToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UndoToast.swift; sourceTree = "<group>"; }; 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifier.swift; sourceTree = "<group>"; }; 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncMonitor.swift; sourceTree = "<group>"; }; 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; }; @@ -119,6 +122,7 @@ 75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+SyncUI.swift"; sourceTree = "<group>"; }; 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; + 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Undo.swift"; sourceTree = "<group>"; }; 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 9404C09EE1A4D91DFF338464 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; @@ -226,6 +230,7 @@ E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */, E51B8129962E5CC78ECDDC2B /* TaskListView.swift */, 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */, + 658295C1386BFF48CE3C2419 /* UndoToast.swift */, ); path = Views; sourceTree = "<group>"; @@ -250,6 +255,7 @@ 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */, 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */, CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */, + 8AACB5E40BEBA75E7FE8B930 /* TaskListView+Undo.swift */, ); path = Extensions; sourceTree = "<group>"; @@ -552,6 +558,7 @@ 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */, 4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */, 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */, + 879608140FA8D7A32078C3CF /* TaskListView+Undo.swift in Sources */, C169823665158AA347A63990 /* TaskListView.swift in Sources */, CC99A96BBC089C423F582E4F /* TaskListViewProtocol.swift in Sources */, 2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */, @@ -559,6 +566,7 @@ 182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */, 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */, 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */, + 1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift @@ -63,12 +63,15 @@ extension TaskListViewProtocol { func createNewTaskAtTop() -> UUID { clearDragState() do { + managedObjectContext.undoManager?.disableUndoRegistration() let task = try store.createTask(title: "", atBeginning: true) + managedObjectContext.undoManager?.enableUndoRegistration() pendingFocus = .task(task.id) focusedField = .task(task.id) selectedTaskID = task.id return task.id } catch { + managedObjectContext.undoManager?.enableUndoRegistration() presentStoreError(error) return UUID() } @@ -77,11 +80,14 @@ extension TaskListViewProtocol { func createNewTask() { clearDragState() do { + managedObjectContext.undoManager?.disableUndoRegistration() let task = try store.createTask(title: "") + managedObjectContext.undoManager?.enableUndoRegistration() pendingFocus = .task(task.id) focusedField = .task(task.id) selectedTaskID = task.id } catch { + managedObjectContext.undoManager?.enableUndoRegistration() presentStoreError(error) } } @@ -91,6 +97,7 @@ extension TaskListViewProtocol { do { let sortOrder = try sortOrderAfter(taskID: afterTaskID) let newTask = try store.createTask(title: title, sortOrder: sortOrder) + try store.save() selectedTaskID = newTask.id focusedField = .scrollView } catch { diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -68,7 +68,6 @@ final class TaskStore { } } - try save() return task } @@ -111,6 +110,14 @@ final class TaskStore { try save() } + func deleteMultiple(taskIDs: [UUID]) throws { + for taskID in taskIDs { + guard let task = try findTask(id: taskID) else { continue } + context.delete(task) + } + try save() + } + func normalizeSortOrders() throws { let activeTasks = try fetchTasks().filter { !$0.isCompleted } .sorted { $0.sortOrder < $1.sortOrder } diff --git a/ListlessiOS/Extensions/TaskListView+Undo.swift b/ListlessiOS/Extensions/TaskListView+Undo.swift @@ -0,0 +1,62 @@ +import SwiftUI + +extension TaskListView { + + func deleteTaskWithUndo(_ task: TaskItem) { + deleteTask(task) + showUndoToast(message: "Task deleted") + } + + func deleteSelectedTaskWithUndo() -> KeyPress.Result { + guard focusedField == .scrollView else { + return .ignored + } + guard let currentID = selectedTaskID else { + return .handled + } + guard let task = allTasksInDisplayOrder.first(where: { $0.id == currentID }) else { + return .handled + } + deleteTaskWithUndo(task) + return .handled + } + + func clearCompletedTasksWithUndo() { + let ids = completedTasks.map(\.id) + guard !ids.isEmpty else { return } + let count = ids.count + managedObjectContext.undoManager?.beginUndoGrouping() + do { + try store.deleteMultiple(taskIDs: ids) + } catch { + presentStoreError(error) + managedObjectContext.undoManager?.endUndoGrouping() + return + } + managedObjectContext.undoManager?.endUndoGrouping() + let noun = count == 1 ? "task" : "tasks" + showUndoToast(message: "\(count) completed \(noun) cleared") + } + + func showUndoToast(message: String) { + withAnimation { + undoToast = UndoToastData(id: UUID(), message: message) + } + } + + func performUndo() { + managedObjectContext.undoManager?.undo() + do { + try store.save() + } catch { + presentStoreError(error) + } + dismissUndoToast() + } + + func dismissUndoToast() { + withAnimation { + undoToast = nil + } + } +} diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -17,6 +17,7 @@ struct TaskListView: View, TaskListViewProtocol { var isShowingSettings = false var clearingTaskIDs: Set<UUID> = [] var rowFrames: [UUID: CGRect] = [:] + var undoToast: UndoToastData? = nil } struct TaskStateData { @@ -90,6 +91,11 @@ struct TaskListView: View, TaskListViewProtocol { nonmutating set { tState.refreshID = newValue } } + var undoToast: UndoToastData? { + get { iState.undoToast } + nonmutating set { iState.undoToast = newValue } + } + private var isDraggingStateBinding: Binding<Bool> { Binding( get: { iState.isDragging }, @@ -156,7 +162,7 @@ struct TaskListView: View, TaskListViewProtocol { func updateMenuCoordinator() { let coord = IOSMenuCoordinator.shared coord.newTask = { createNewTask() } - coord.deleteTask = { _ = deleteSelectedTask() } + coord.deleteTask = { _ = deleteSelectedTaskWithUndo() } coord.moveUp = { moveSelectedTaskUp() } coord.moveDown = { moveSelectedTaskDown() } coord.markCompleted = { markSelectedTaskCompleted() } @@ -207,7 +213,7 @@ struct TaskListView: View, TaskListViewProtocol { onDown: { _ = navigateDown() }, onSpace: { _ = toggleSelectedTask() }, onReturn: { _ = focusSelectedTask() }, - onDelete: { _ = deleteSelectedTask() } + onDelete: { _ = deleteSelectedTaskWithUndo() } ) } .onAppear { @@ -240,6 +246,21 @@ struct TaskListView: View, TaskListViewProtocol { .padding(.trailing, 12) } } + .overlay(alignment: .bottom) { + if let toast = iState.undoToast { + UndoToastView( + data: toast, + onUndo: { performUndo() }, + onDismiss: { dismissUndoToast() } + ) + } + } + .task(id: iState.undoToast?.id) { + guard iState.undoToast != nil else { return } + try? await Task.sleep(for: .seconds(7)) + guard !Task.isCancelled else { return } + dismissUndoToast() + } .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) { NavigationStack { SyncDiagnosticsView(syncMonitor: syncMonitor) @@ -306,7 +327,7 @@ struct TaskListView: View, TaskListViewProtocol { focusedField: $focusedFieldBinding, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteTask($0) }, + onDelete: { deleteTaskWithUndo($0) }, onSelect: { selectTask($0) }, onStartEdit: { startEditing($0) }, onEndEdit: { endEditing($0, shouldCreateNewTask: $1) } @@ -342,7 +363,7 @@ struct TaskListView: View, TaskListViewProtocol { focusedField: $focusedFieldBinding, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteTask($0) }, + onDelete: { deleteTaskWithUndo($0) }, onSelect: { selectTask($0) } ) .opacity(isBeingCleared ? 0 : 1) @@ -421,7 +442,7 @@ struct TaskListView: View, TaskListViewProtocol { iState.clearingTaskIDs = ids } completion: { iState.clearingTaskIDs = [] - clearCompletedTasks() + clearCompletedTasksWithUndo() } } ) diff --git a/ListlessiOS/Views/UndoToast.swift b/ListlessiOS/Views/UndoToast.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct UndoToastData: Equatable, Identifiable { + let id: UUID + let message: String +} + +struct UndoToastView: View { + let data: UndoToastData + let onUndo: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(data.message) + .font(.subheadline) + .foregroundStyle(.white) + Button(action: onUndo) { + Text("Undo") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.yellow) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(white: 0.2)) + ) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } +} diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -23,6 +23,7 @@ func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) throws -> ( for i in 0..<count { let title = titles?[safe: i] ?? "Task \(i + 1)" let task = try store.createTask(title: title) + try store.save() taskIDs.append(task.id) }