listless

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

commit 2b98cca2466f4b78e34f8d7535615809db558b45
parent f9f00df839ad3190df2c5cec036f77658307701b
Author: Michael Camilleri <[email protected]>
Date:   Tue, 31 Mar 2026 14:52:53 +0900

Introduce ItemValue snapshot type to prevent Core Data crashes

Before this commit, accessing @NSManaged properties on an invalidated
NSManagedObject (e.g. after a CloudKit sync delete on another device) crashes
because the ObjC-to-Swift bridge force-unwraps nil. This affected any ForEach
body that held a reference to an ItemEntity during a render cycle.

This commit adds an ItemValue struct that snapshots the view-relevant fields of
ItemEntity. The computed properties in ItemListView+Logic.swift now return
an array of ItemValue structs and all view code (iOS, macOS, watchOS) operates
on this value type instead of NSManagedObject. ItemEntity.id is also changed
from @NSManaged to a safe computed property using `primitiveValue(forKey:)` as a
belt-and-suspenders guard.

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

Diffstat:
MListless.xcodeproj/project.pbxproj | 8++++++++
MListless/Extensions/ItemListView+Logic.swift | 86+++++++++++++++++++++++++++++++++++++++++--------------------------------------
MListless/Models/ItemEntity.swift | 9+++++++--
MListless/Models/ItemStore.swift | 13++++++++++---
AListless/Models/ItemValue.swift | 17+++++++++++++++++
MListlessMac/Views/ItemListView.swift | 28++++++++++++++--------------
MListlessMac/Views/ItemRowView.swift | 32+++++++++++++-------------------
MListlessWatch/Views/ItemListView.swift | 5+++--
MListlessWatch/Views/ItemRowView.swift | 4++--
MListlessiOS/Extensions/ItemListView+Undo.swift | 8++++----
MListlessiOS/Views/ItemListView.swift | 12++++++------
MListlessiOS/Views/ItemRowView.swift | 26+++++++++++++-------------
MTests/UI/ListlessiOSUITests.swift | 2+-
13 files changed, 143 insertions(+), 107 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE264D30C7692457B92E518 /* KeyValueSyncBridge.swift */; }; 2F5309630692E89276CC3149 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */; }; 3383645AE13E9C3AAECFBD0B /* ItemListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23060125DF03EE84F3AED8CB /* ItemListView+PullToClear.swift */; }; + 345C3D4D9A82B9AE0C8CB153 /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; }; 37AEF10712B3325BF9BC72E4 /* BackgroundClickMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */; }; 3EDB6A9A30B4226C15E7F44D /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */; }; 4263E6472020564AA702D117 /* ItemCardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF4BADA48F30C5F471EA910 /* ItemCardModifier.swift */; }; @@ -44,11 +45,13 @@ 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; }; 5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */; }; + 60FB7A1F3B2F037C655E10DB /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; }; 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; 620D9398218A88B7E4C2331C /* ItemStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FCE9FC4EFD26C9016389B7 /* ItemStoreEdgeCaseTests.swift */; }; 63C08E89303BD17601271D2C /* ItemListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E209A0014EE95F2BC300CE42 /* ItemListView+SyncUI.swift */; }; 65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE7F4637B4F4C1FF4BE160B /* ListlessWatchApp.swift */; }; 6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; + 73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; }; 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; }; 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; }; 785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; }; @@ -226,6 +229,7 @@ C611E04943F1D82D6F975592 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; C6812E535A24C599C28F9278 /* Listless watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; C71466C5CD1A5BA984352F8D /* Listless iOS Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Listless iOS Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + C7EB9F584EF43678536F5FDE /* ItemValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemValue.swift; sourceTree = "<group>"; }; C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; }; @@ -387,6 +391,7 @@ 74255E6B6C40899E9B17D927 /* .gitkeep */, 138DCA35ED82A745E4745175 /* ItemEntity.swift */, 2B8EC97702E7B218A03B7898 /* ItemStore.swift */, + C7EB9F584EF43678536F5FDE /* ItemValue.swift */, C093494053E6C348F245D4EC /* Listless.xcdatamodeld */, ); path = Models; @@ -776,6 +781,7 @@ BDA8D53342F745B27B72B242 /* ItemRowDragGesture.swift in Sources */, 194CAF0FBC308BE96CE8AA7B /* ItemRowView.swift in Sources */, D24133A6C0105FE8E4528EF2 /* ItemStore.swift in Sources */, + 73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */, 2CCB5FE0084742D018E52A3D /* KeyValueSyncBridge.swift in Sources */, 172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */, 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */, @@ -798,6 +804,7 @@ 2F5309630692E89276CC3149 /* ItemListView.swift in Sources */, 8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */, 47D9272442A5F15B324D3DAC /* ItemStore.swift in Sources */, + 345C3D4D9A82B9AE0C8CB153 /* ItemValue.swift in Sources */, 264BD64C1DD30376E8BDAF79 /* KeyValueSyncBridge.swift in Sources */, 6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */, 65E97DE8C190E9E9B71EC356 /* ListlessWatchApp.swift in Sources */, @@ -837,6 +844,7 @@ B0F8B09E2AB38C8C4AF74C10 /* ItemRowSwipeGesture.swift in Sources */, DB0CBB6C3EE56406AF86FDE3 /* ItemRowView.swift in Sources */, 790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */, + 60FB7A1F3B2F037C655E10DB /* ItemValue.swift in Sources */, 12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */, 19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */, F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */, diff --git a/Listless/Extensions/ItemListView+Logic.swift b/Listless/Extensions/ItemListView+Logic.swift @@ -4,12 +4,13 @@ extension ItemListViewProtocol { // MARK: - Computed Properties - var activeItems: [ItemEntity] { + var activeItems: [ItemValue] { Array(items.filter { !$0.isDeleted && !$0.isCompleted }) .sorted { $0.sortOrder < $1.sortOrder } + .map(ItemValue.init) } - var displayActiveItems: [ItemEntity] { + var displayActiveItems: [ItemValue] { guard let visualOrder else { return activeItems } @@ -19,12 +20,13 @@ extension ItemListViewProtocol { } } - var completedItems: [ItemEntity] { + var completedItems: [ItemValue] { Array(items.filter { !$0.isDeleted && $0.isCompleted }) .sorted { $0.completedOrder > $1.completedOrder } + .map(ItemValue.init) } - var allItemsInDisplayOrder: [ItemEntity] { + var allItemsInDisplayOrder: [ItemValue] { displayActiveItems + completedItems } @@ -129,15 +131,16 @@ extension ItemListViewProtocol { guard !title.isEmpty else { return } do { - let item = switch placement { - case .prepend: - try store.createItem(title: title, atBeginning: true) - case .append: - try store.createItem(title: title) - } + let newItem = + switch placement { + case .prepend: + try store.createItem(title: title, atBeginning: true) + case .append: + try store.createItem(title: title) + } try store.save() if placement == .append { - fState.selectedItemID = item.id + fState.selectedItemID = newItem.id } } catch { presentStoreError(error) @@ -224,33 +227,32 @@ extension ItemListViewProtocol { return } - guard let item = items.first(where: { $0.id == itemID }) else { + guard let entity = items.first(where: { $0.id == itemID }) else { return } - let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmedTitle.isEmpty else { return } - managedObjectContext.undoManager?.removeAllActions(withTarget: item) + managedObjectContext.undoManager?.removeAllActions(withTarget: entity) managedObjectContext.undoManager?.disableUndoRegistration() - deleteItem(item) + deleteItem(itemID: itemID) managedObjectContext.undoManager?.enableUndoRegistration() } - func updateTitle(_ item: ItemEntity, _ title: String) { - guard item.title != title else { return } + func updateTitle(itemID: UUID, title: String) { do { - try store.updateWithoutSaving(itemID: item.id, title: title) + try store.updateWithoutSaving(itemID: itemID, title: title) } catch { presentStoreError(error) } } - func toggleCompletion(_ item: ItemEntity) { + func toggleCompletion(itemID: UUID, isCompleted: Bool) { do { - if item.isCompleted { - try store.uncomplete(itemID: item.id) + if isCompleted { + try store.uncomplete(itemID: itemID) } else { - try store.complete(itemID: item.id) + try store.complete(itemID: itemID) } } catch { presentStoreError(error) @@ -258,13 +260,12 @@ extension ItemListViewProtocol { } func handleSwipeComplete(_ itemID: UUID) { - guard let item = items.first(where: { $0.id == itemID }) else { return } - toggleCompletion(item) + guard let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return } + toggleCompletion(itemID: item.id, isCompleted: item.isCompleted) } func handleSwipeDelete(_ itemID: UUID) { - guard let item = items.first(where: { $0.id == itemID }) else { return } - deleteItem(item) + deleteItem(itemID: itemID) } func selectItem( @@ -290,9 +291,7 @@ extension ItemListViewProtocol { } } - func deleteItem(_ item: ItemEntity) { - guard !item.isDeleted else { return } - let itemID = item.id + func deleteItem(itemID: UUID) { do { try store.delete(itemID: itemID) if fState.selectedItemID == itemID { @@ -421,7 +420,7 @@ extension ItemListViewProtocol { let hasCompleted = itemsToToggle.contains { $0.isCompleted } guard !(hasActive && hasCompleted) else { return .handled } for item in itemsToToggle { - toggleCompletion(item) + toggleCompletion(itemID: item.id, isCompleted: item.isCompleted) } return .handled } @@ -445,18 +444,19 @@ extension ItemListViewProtocol { let ids = fState.selectedItemIDs guard !ids.isEmpty else { return .handled } let displayOrder = allItemsInDisplayOrder - let itemsToDelete = displayOrder.filter { ids.contains($0.id) } - guard !itemsToDelete.isEmpty else { return .handled } + let idsToDelete = displayOrder.filter { ids.contains($0.id) }.map(\.id) + guard !idsToDelete.isEmpty else { return .handled } // Find the next item after the last selected one to move selection to. - let lastSelectedIndex = displayOrder.lastIndex(where: { ids.contains($0.id) }) + let idsToDeleteSet = Set(idsToDelete) + let lastSelectedIndex = displayOrder.lastIndex(where: { idsToDeleteSet.contains($0.id) }) let nextItem = lastSelectedIndex.flatMap { idx in - displayOrder.dropFirst(idx + 1).first(where: { !ids.contains($0.id) }) + displayOrder.dropFirst(idx + 1).first(where: { !idsToDeleteSet.contains($0.id) }) } fState.selectedItemID = nil - for item in itemsToDelete { - deleteItem(item) + for itemID in idsToDelete { + deleteItem(itemID: itemID) } if let nextItem { fState.selectedItemID = nextItem.id @@ -467,7 +467,9 @@ extension ItemListViewProtocol { func moveSelectedItemUp() { guard focusedField == .scrollView else { return } guard let currentID = fState.selectedItemID else { return } - guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return } + guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { + return + } guard currentIndex > 0 else { return } do { @@ -480,7 +482,9 @@ extension ItemListViewProtocol { func moveSelectedItemDown() { guard focusedField == .scrollView else { return } guard let currentID = fState.selectedItemID else { return } - guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { return } + guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else { + return + } guard currentIndex < activeItems.count - 1 else { return } do { @@ -496,7 +500,7 @@ extension ItemListViewProtocol { guard !ids.isEmpty else { return } let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) } for item in itemsToToggle { - toggleCompletion(item) + toggleCompletion(itemID: item.id, isCompleted: item.isCompleted) } } @@ -538,10 +542,10 @@ extension ItemListViewProtocol { } private func shouldDeleteIfEmpty(itemID: UUID) -> Bool { - guard let item = items.first(where: { $0.id == itemID }) else { + guard let entity = items.first(where: { $0.id == itemID }) else { return false } - let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedTitle.isEmpty } diff --git a/Listless/Models/ItemEntity.swift b/Listless/Models/ItemEntity.swift @@ -3,12 +3,14 @@ import Foundation @objc(TaskItem) public class ItemEntity: NSManagedObject, Identifiable { + private static let invalidatedID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + private enum Keys { private static func key<Value>(_ keyPath: KeyPath<ItemEntity, Value>) -> String { NSExpression(forKeyPath: keyPath).keyPath } - static let id = key(\ItemEntity.id as KeyPath<ItemEntity, UUID>) + static let id = "id" static let title = key(\ItemEntity.title) static let createdAt = key(\ItemEntity.createdAt) static let updatedAt = key(\ItemEntity.updatedAt) @@ -16,7 +18,10 @@ public class ItemEntity: NSManagedObject, Identifiable { static let completedOrder = key(\ItemEntity.completedOrder) } - @NSManaged public var id: UUID + public var id: UUID { + (primitiveValue(forKey: Keys.id) as? UUID) ?? Self.invalidatedID + } + @NSManaged public var title: String @NSManaged public var createdAt: Date @NSManaged public var updatedAt: Date diff --git a/Listless/Models/ItemStore.swift b/Listless/Models/ItemStore.swift @@ -49,7 +49,10 @@ final class ItemStore { } } - func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> ItemEntity { + @discardableResult + func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws + -> ItemEntity + { // Compute sort order before inserting the new object so we don't need // processPendingChanges() and the new item can't appear in our own query. let resolvedSortOrder: Int64 @@ -73,7 +76,9 @@ final class ItemStore { private func minActiveSortOrder() throws -> Int64? { let request = ItemEntity.fetchRequest() request.predicate = NSPredicate(format: "completedOrder == 0") - request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true)] + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true) + ] request.fetchLimit = 1 do { return try context.fetch(request).first?.sortOrder @@ -85,7 +90,9 @@ final class ItemStore { private func maxActiveSortOrder() throws -> Int64? { let request = ItemEntity.fetchRequest() request.predicate = NSPredicate(format: "completedOrder == 0") - request.sortDescriptors = [NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false)] + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false) + ] request.fetchLimit = 1 do { return try context.fetch(request).first?.sortOrder diff --git a/Listless/Models/ItemValue.swift b/Listless/Models/ItemValue.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ItemValue: Identifiable { + let id: UUID + var title: String + var isCompleted: Bool + var sortOrder: Int64 + var completedOrder: Int64 + + init(_ entity: ItemEntity) { + self.id = entity.id + self.title = entity.title + self.isCompleted = entity.isCompleted + self.sortOrder = entity.sortOrder + self.completedOrder = entity.completedOrder + } +} diff --git a/ListlessMac/Views/ItemListView.swift b/ListlessMac/Views/ItemListView.swift @@ -116,18 +116,18 @@ struct ItemListView: View, ItemListViewProtocol { coord.newItem = { createNewItem() } coord.copySelectedItem = { guard let itemID = fState.selectedItemID, - let item = items.first(where: { $0.id == itemID }) else { return } + let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(item.title, forType: .string) } coord.cutSelectedItem = { guard let itemID = fState.selectedItemID, - let item = items.first(where: { $0.id == itemID }) else { return } + let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(item.title, forType: .string) - deleteItem(item) + deleteItem(itemID: itemID) } coord.pasteAfterSelectedItem = { guard let itemID = fState.selectedItemID, @@ -204,9 +204,9 @@ struct ItemListView: View, ItemListViewProtocol { totalItems: displayActiveItems.count, isSelected: fState.isItemSelected(itemID), focusedField: $focusedFieldBinding, - onToggle: { toggleCompletion($0) }, - onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteItem($0) }, + onToggle: { handleSwipeComplete($0) }, + onTitleChange: { updateTitle(itemID: $0, title: $1) }, + onDelete: { deleteItem(itemID: $0) }, onSelect: { let modifiers = NSApp.currentEvent?.modifierFlags ?? [] selectItem( @@ -221,15 +221,15 @@ struct ItemListView: View, ItemListViewProtocol { ) .itemDragGesture( isActive: !item.isCompleted, - itemID: item.id, + itemID: itemID, onDragStart: { iState.liftedItemID = nil - startDrag(itemID: item.id) + startDrag(itemID: itemID) }, - onLift: { iState.liftedItemID = item.id }, + onLift: { iState.liftedItemID = itemID }, onLiftEnd: { - if iState.liftedItemID == item.id { iState.liftedItemID = nil } - if draggedItemID == item.id { clearDragState() } + if iState.liftedItemID == itemID { iState.liftedItemID = nil } + if draggedItemID == itemID { clearDragState() } } ) .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0) @@ -355,9 +355,9 @@ struct ItemListView: View, ItemListViewProtocol { itemID: itemID, isSelected: fState.isItemSelected(itemID), focusedField: $focusedFieldBinding, - onToggle: { toggleCompletion($0) }, - onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteItem($0) }, + onToggle: { handleSwipeComplete($0) }, + onTitleChange: { updateTitle(itemID: $0, title: $1) }, + onDelete: { deleteItem(itemID: $0) }, onSelect: { selectItem( $0, diff --git a/ListlessMac/Views/ItemRowView.swift b/ListlessMac/Views/ItemRowView.swift @@ -1,14 +1,14 @@ import SwiftUI struct ItemRowView: View { - let item: ItemEntity + let item: ItemValue let itemID: UUID let index: Int let totalItems: Int let isSelected: Bool - let onToggle: (ItemEntity) -> Void - let onTitleChange: (ItemEntity, String) -> Void - let onDelete: (ItemEntity) -> Void + let onToggle: (UUID) -> Void + let onTitleChange: (UUID, String) -> Void + let onDelete: (UUID) -> Void let onSelect: (UUID) -> Void let onStartEdit: (UUID) -> Void let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void @@ -37,15 +37,15 @@ struct ItemRowView: View { } init( - item: ItemEntity, + item: ItemValue, itemID: UUID, index: Int = 0, totalItems: Int = 1, isSelected: Bool, focusedField: FocusState<FocusField?>.Binding, - onToggle: @escaping (ItemEntity) -> Void, - onTitleChange: @escaping (ItemEntity, String) -> Void, - onDelete: @escaping (ItemEntity) -> Void, + onToggle: @escaping (UUID) -> Void, + onTitleChange: @escaping (UUID, String) -> Void, + onDelete: @escaping (UUID) -> Void, onSelect: @escaping (UUID) -> Void, onStartEdit: @escaping (UUID) -> Void = { _ in }, onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }, @@ -69,7 +69,7 @@ struct ItemRowView: View { var body: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { Button { - onToggle(item) + onToggle(itemID) } label: { Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle") .foregroundStyle(item.isCompleted ? .secondary : .primary) @@ -97,7 +97,7 @@ struct ItemRowView: View { itemID: itemID, onContentChange: { newTitle in guard !item.isCompleted else { return } - onTitleChange(item, newTitle) + onTitleChange(itemID, newTitle) } ) .focused($focusedField, equals: .item(itemID)) @@ -139,7 +139,7 @@ struct ItemRowView: View { } .contextMenu { Button(item.isCompleted ? "Mark as Incomplete" : "Mark as Complete") { - onToggle(item) + onToggle(itemID) } Divider() Button("Cut") { @@ -154,11 +154,10 @@ struct ItemRowView: View { .disabled(item.isCompleted) Divider() Button("Delete", role: .destructive) { - onDelete(item) + onDelete(itemID) } } .onChange(of: item.title) { _, newValue in - // Keep editingTitle in sync with item.title when not editing if !isCurrentlyEditing { editingTitle = newValue } @@ -173,7 +172,6 @@ struct ItemRowView: View { cachedAccentColor = computeAccentColor() } .onAppear { - // Initialize editingTitle and cache accent color (computed once) editingTitle = item.title cachedAccentColor = computeAccentColor() } @@ -191,13 +189,9 @@ struct ItemRowView: View { } } - // These context menu actions only run in navigation mode — when a row is - // being edited, the NSTextField field editor is first responder and its - // native context menu handles Cut/Copy/Paste for text directly. - private func cutToPasteboard() { copyToPasteboard() - onDelete(item) + onDelete(itemID) } private func copyToPasteboard() { diff --git a/ListlessWatch/Views/ItemListView.swift b/ListlessWatch/Views/ItemListView.swift @@ -18,9 +18,10 @@ struct ItemListView: View { private var items: FetchedResults<ItemEntity> var body: some View { - let activeItems = items.filter { !$0.isCompleted } + let activeItems = items.filter { !$0.isCompleted }.map(ItemValue.init) let completedItems = items.filter { $0.isCompleted } .sorted { $0.completedOrder > $1.completedOrder } + .map(ItemValue.init) NavigationStack { Group { @@ -62,7 +63,7 @@ struct ItemListView: View { } } - private func toggleItem(_ item: ItemEntity) { + private func toggleItem(_ item: ItemValue) { do { if item.isCompleted { try store.uncomplete(itemID: item.id) diff --git a/ListlessWatch/Views/ItemRowView.swift b/ListlessWatch/Views/ItemRowView.swift @@ -1,11 +1,11 @@ import SwiftUI struct ItemRowView: View { - let item: ItemEntity + let item: ItemValue let index: Int let totalActive: Int let colorTheme: ColorTheme - let onToggle: (ItemEntity) -> Void + let onToggle: (ItemValue) -> Void var body: some View { Button { diff --git a/ListlessiOS/Extensions/ItemListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift @@ -2,8 +2,8 @@ import SwiftUI extension ItemListView { - func deleteItemWithUndo(_ item: ItemEntity) { - deleteItem(item) + func deleteItemWithUndo(itemID: UUID) { + deleteItem(itemID: itemID) showUndoToast(message: "Item deleted") } @@ -14,10 +14,10 @@ extension ItemListView { guard let currentID = fState.selectedItemID else { return .handled } - guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else { + guard allItemsInDisplayOrder.contains(where: { $0.id == currentID }) else { return .handled } - deleteItemWithUndo(item) + deleteItemWithUndo(itemID: currentID) return .handled } diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift @@ -355,9 +355,9 @@ struct ItemListView: View, ItemListViewProtocol { isSwiping: $iState.isSwiping, isLastActiveItem: index == displayActiveItems.count - 1, focusedField: $focusedFieldBinding, - onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } }, - onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteItemWithUndo($0) }, + onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } }, + onTitleChange: { updateTitle(itemID: $0, title: $1) }, + onDelete: { deleteItemWithUndo(itemID: $0) }, onSelect: { selectItem($0) }, onStartEdit: { startEditing($0) }, onEndEdit: { @@ -427,9 +427,9 @@ struct ItemListView: View, ItemListViewProtocol { isSelected: fState.selectedItemID == itemID, isSwiping: $iState.isSwiping, focusedField: $focusedFieldBinding, - onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } }, - onTitleChange: { updateTitle($0, $1) }, - onDelete: { deleteItemWithUndo($0) }, + onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } }, + onTitleChange: { updateTitle(itemID: $0, title: $1) }, + onDelete: { deleteItemWithUndo(itemID: $0) }, onSelect: { selectItem($0) } ) .opacity(isBeingCleared ? 0 : 1) diff --git a/ListlessiOS/Views/ItemRowView.swift b/ListlessiOS/Views/ItemRowView.swift @@ -1,16 +1,16 @@ import SwiftUI struct ItemRowView: View { - let item: ItemEntity + let item: ItemValue let itemID: UUID let index: Int let totalItems: Int let isSelected: Bool @Binding var isDragging: Bool @Binding var isSwiping: Bool - let onToggle: (ItemEntity) -> Void - let onTitleChange: (ItemEntity, String) -> Void - let onDelete: (ItemEntity) -> Void + let onToggle: (UUID) -> Void + let onTitleChange: (UUID, String) -> Void + let onDelete: (UUID) -> Void let onSelect: (UUID) -> Void let isLastActiveItem: Bool let onStartEdit: (UUID) -> Void @@ -28,7 +28,7 @@ struct ItemRowView: View { @State private var cachedAccentColor: Color = .clear init( - item: ItemEntity, + item: ItemValue, itemID: UUID, index: Int = 0, totalItems: Int = 1, @@ -37,9 +37,9 @@ struct ItemRowView: View { isSwiping: Binding<Bool> = .constant(false), isLastActiveItem: Bool = false, focusedField: FocusState<FocusField?>.Binding, - onToggle: @escaping (ItemEntity) -> Void, - onTitleChange: @escaping (ItemEntity, String) -> Void, - onDelete: @escaping (ItemEntity) -> Void, + onToggle: @escaping (UUID) -> Void, + onTitleChange: @escaping (UUID, String) -> Void, + onDelete: @escaping (UUID) -> Void, onSelect: @escaping (UUID) -> Void, onStartEdit: @escaping (UUID) -> Void = { _ in }, onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in } @@ -64,7 +64,7 @@ struct ItemRowView: View { var body: some View { HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) { Button { - onToggle(item) + onToggle(itemID) } label: { // When a right-swipe is past the threshold, preview the toggled state let previewCompleted = isSwipeTriggered && swipeDirection == .right @@ -98,7 +98,7 @@ struct ItemRowView: View { returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done, onContentChange: { newTitle in guard !item.isCompleted else { return } - onTitleChange(item, newTitle) + onTitleChange(itemID, newTitle) }, uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)", initialCursorPoint: tapPoint @@ -140,7 +140,7 @@ struct ItemRowView: View { return } if item.isCompleted { - withAnimation { onToggle(item) } + withAnimation { onToggle(itemID) } } else { tapPoint = nil onSelect(itemID) @@ -181,8 +181,8 @@ struct ItemRowView: View { swipeDirection: $swipeDirection, isTriggered: $isSwipeTriggered, completeColor: cachedAccentColor, - onComplete: { onToggle(item) }, - onDelete: { onDelete(item) } + onComplete: { onToggle(itemID) }, + onDelete: { onDelete(itemID) } ) .onChange(of: isDragging) { _, newValue in if newValue { diff --git a/Tests/UI/ListlessiOSUITests.swift b/Tests/UI/ListlessiOSUITests.swift @@ -149,7 +149,7 @@ final class ListlessiOSUITests: XCTestCase { func testUncompleteItem() { createItem("Finish report") exitEditingMode() - usleep(300_000) // Wait for draft row reveal animation to settle + usleep(500_000) // Wait for draft row reveal animation to settle itemCheckbox(at: 0).tap()