← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


Listless/Models/ItemStore.swift (6920B)

1import CoreData
2import Foundation
3
4enum 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
19final 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}