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