← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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)
     }