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