ItemStore.swift (6920B)
1 import CoreData 2 import Foundation 3 4 enum ItemStoreError: LocalizedError { 5 case fetchFailed(Error) 6 case saveFailed(Error) 7 8 var errorDescription: String? { 9 switch self { 10 case .fetchFailed(let error): 11 return "Failed to fetch items: \(error.localizedDescription)" 12 case .saveFailed(let error): 13 return "Failed to save changes: \(error.localizedDescription)" 14 } 15 } 16 } 17 18 @MainActor 19 final class ItemStore { 20 private let persistenceController: PersistenceController 21 private var context: NSManagedObjectContext { 22 persistenceController.viewContext 23 } 24 25 init(persistenceController: PersistenceController = .shared) { 26 self.persistenceController = persistenceController 27 } 28 29 func fetchItems() throws -> [ItemEntity] { 30 do { 31 let activeRequest = ItemEntity.fetchRequest() 32 activeRequest.predicate = NSPredicate(format: "completedOrder == 0") 33 activeRequest.sortDescriptors = [ 34 NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true) 35 ] 36 37 let completedRequest = ItemEntity.fetchRequest() 38 completedRequest.predicate = NSPredicate(format: "completedOrder > 0") 39 completedRequest.sortDescriptors = [ 40 NSSortDescriptor(keyPath: \ItemEntity.completedOrder, ascending: false) 41 ] 42 43 let activeItems = try context.fetch(activeRequest) 44 let completedItems = try context.fetch(completedRequest) 45 46 return activeItems + completedItems 47 } catch { 48 throw ItemStoreError.fetchFailed(error) 49 } 50 } 51 52 @discardableResult 53 func createItem(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws 54 -> ItemEntity 55 { 56 // Compute sort order before inserting the new object so we don't need 57 // processPendingChanges() and the new item can't appear in our own query. 58 let resolvedSortOrder: Int64 59 if let sortOrder { 60 resolvedSortOrder = sortOrder 61 } else if atBeginning { 62 let minOrder = try minActiveSortOrder() ?? 0 63 resolvedSortOrder = minOrder - 1000 64 } else { 65 let maxOrder = try maxActiveSortOrder() ?? -1000 66 resolvedSortOrder = maxOrder + 1000 67 } 68 69 let item = ItemEntity(context: context) 70 item.title = title 71 item.sortOrder = resolvedSortOrder 72 73 return item 74 } 75 76 private func minActiveSortOrder() throws -> Int64? { 77 let request = ItemEntity.fetchRequest() 78 request.predicate = NSPredicate(format: "completedOrder == 0") 79 request.sortDescriptors = [ 80 NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: true) 81 ] 82 request.fetchLimit = 1 83 do { 84 return try context.fetch(request).first?.sortOrder 85 } catch { 86 throw ItemStoreError.fetchFailed(error) 87 } 88 } 89 90 private func maxActiveSortOrder() throws -> Int64? { 91 let request = ItemEntity.fetchRequest() 92 request.predicate = NSPredicate(format: "completedOrder == 0") 93 request.sortDescriptors = [ 94 NSSortDescriptor(keyPath: \ItemEntity.sortOrder, ascending: false) 95 ] 96 request.fetchLimit = 1 97 do { 98 return try context.fetch(request).first?.sortOrder 99 } catch { 100 throw ItemStoreError.fetchFailed(error) 101 } 102 } 103 104 func complete(itemID: UUID) throws { 105 guard let item = try findItem(id: itemID) else { return } 106 107 let request = ItemEntity.fetchRequest() 108 request.predicate = NSPredicate(format: "completedOrder > 0") 109 request.sortDescriptors = [ 110 NSSortDescriptor(keyPath: \ItemEntity.completedOrder, ascending: false) 111 ] 112 request.fetchLimit = 1 113 let maxOrder = (try context.fetch(request).first)?.completedOrder ?? 0 114 115 item.completedOrder = maxOrder + 1 116 try save() 117 } 118 119 func uncomplete(itemID: UUID) throws { 120 guard let item = try findItem(id: itemID) else { return } 121 let restoredSortOrder = item.sortOrder 122 let activeItems = try fetchItems().filter { !$0.isCompleted && $0.id != item.id } 123 let hasSortOrderConflict = activeItems.contains { $0.sortOrder == restoredSortOrder } 124 125 if hasSortOrderConflict { 126 let maxSortOrder = activeItems.map(\.sortOrder).max() ?? -1000 127 item.sortOrder = maxSortOrder + 1000 128 } 129 130 item.completedOrder = 0 131 try save() 132 } 133 134 func update(itemID: UUID, title: String) throws { 135 guard let item = try findItem(id: itemID) else { return } 136 item.title = title 137 try save() 138 } 139 140 func updateWithoutSaving(itemID: UUID, title: String) throws { 141 guard let item = try findItem(id: itemID) else { return } 142 item.title = title 143 // Don't save - will be saved when editing ends 144 } 145 146 func delete(itemID: UUID) throws { 147 guard let item = try findItem(id: itemID) else { return } 148 context.delete(item) 149 try save() 150 } 151 152 func deleteMultiple(itemIDs: [UUID]) throws { 153 for itemID in itemIDs { 154 guard let item = try findItem(id: itemID) else { continue } 155 context.delete(item) 156 } 157 try save() 158 } 159 160 func normalizeSortOrders() throws { 161 let activeItems = try fetchItems().filter { !$0.isCompleted } 162 .sorted { $0.sortOrder < $1.sortOrder } 163 164 for (index, item) in activeItems.enumerated() { 165 item.sortOrder = Int64(index) * 1000 166 } 167 168 try save() 169 } 170 171 func moveItem(itemID: UUID, toIndex: Int) throws { 172 let activeItems = try fetchItems().filter { !$0.isCompleted } 173 .sorted { $0.sortOrder < $1.sortOrder } 174 175 guard let currentIndex = activeItems.firstIndex(where: { $0.id == itemID }) else { return } 176 guard currentIndex != toIndex else { return } 177 178 var reordered = activeItems 179 let item = reordered.remove(at: currentIndex) 180 181 // Clamp toIndex to valid range [0, reordered.count] after removal 182 let insertIndex = max(0, min(toIndex, reordered.count)) 183 reordered.insert(item, at: insertIndex) 184 185 // Reassign sortOrder with gaps of 1000 186 for (index, item) in reordered.enumerated() { 187 item.sortOrder = Int64(index) * 1000 188 } 189 190 try save() 191 } 192 193 private func findItem(id: UUID) throws -> ItemEntity? { 194 let request = ItemEntity.fetchRequest() 195 request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 196 request.fetchLimit = 1 197 198 do { 199 return try context.fetch(request).first 200 } catch { 201 throw ItemStoreError.fetchFailed(error) 202 } 203 } 204 205 func save() throws { 206 try persistenceController.save() 207 } 208 }