listless

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

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 }