← All repositories

listless

A simple list app for Apple platforms

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


Listless/Extensions/ItemListView+Logic.swift (22528B)

1import SwiftUI
2
3extension ItemListViewProtocol {
4
5    // MARK: - Computed Properties
6
7    var activeItems: [ItemValue] {
8        Array(items.filter { !$0.isDeleted && !$0.isCompleted })
9            .sorted { $0.sortOrder < $1.sortOrder }
10            .map(ItemValue.init)
11    }
12
13    var displayActiveItems: [ItemValue] {
14        guard let visualOrder else {
15            return activeItems
16        }
17
18        return visualOrder.compactMap { id in
19            activeItems.first(where: { $0.id == id })
20        }
21    }
22
23    var completedItems: [ItemValue] {
24        Array(items.filter { !$0.isDeleted && $0.isCompleted })
25            .sorted { $0.completedOrder > $1.completedOrder }
26            .map(ItemValue.init)
27    }
28
29    var allItemsInDisplayOrder: [ItemValue] {
30        displayActiveItems + completedItems
31    }
32
33    var editingItemID: UUID? {
34        if case .item(let id) = focusedField {
35            return id
36        }
37        return nil
38    }
39
40    var draggedItemID: UUID? {
41        if case .dragging(let id, _) = dragState {
42            return id
43        }
44        return nil
45    }
46
47    var visualOrder: [UUID]? {
48        if case .dragging(_, let order) = dragState {
49            return order
50        }
51        return nil
52    }
53
54    func presentStoreError(_ error: Error) {
55        syncMonitor.ingest(error: error)
56    }
57
58    private func isLastActiveItem(_ itemID: UUID) -> Bool {
59        guard let lastItem = activeItems.last else { return false }
60        return lastItem.id == itemID
61    }
62
63    func draftID(for placement: DraftItemPlacement) -> UUID {
64        switch placement {
65        case .prepend:
66            draftPrependRowID
67        case .append:
68            draftAppendRowID
69        }
70    }
71
72    func draftPlacement(for itemID: UUID) -> DraftItemPlacement? {
73        switch itemID {
74        case draftPrependRowID:
75            .prepend
76        case draftAppendRowID:
77            .append
78        default:
79            nil
80        }
81    }
82
83    // MARK: - Item Creation
84
85    func createNewItemAtTop() -> UUID {
86        revealDraftItem(at: .prepend)
87        return draftPrependRowID
88    }
89
90    func createNewItem() {
91        revealDraftItem(at: .append, animated: true)
92    }
93
94    func revealDraftItem(at placement: DraftItemPlacement, animated: Bool = false) {
95        if draftPlacement != placement, draftPlacement != nil {
96            commitDraftItem()
97        }
98
99        clearDragState()
100        draftTitle = ""
101        revealDraftItemUI(at: placement, animated: animated)
102    }
103
104    func beginDraftItemEditing(_ placement: DraftItemPlacement) {
105        guard draftPlacement == placement else { return }
106        let itemID = draftID(for: placement)
107        fState.selectedItemID = itemID
108        if case .item(let id) = fState.pendingFocus, id == itemID {
109            fState.pendingFocus = nil
110        }
111    }
112
113    func commitDraftItem(shouldCreateNewItem: Bool = false) {
114        guard let placement = draftPlacement else { return }
115        let itemID = draftID(for: placement)
116        let title = draftTitle.trimmingCharacters(in: .whitespacesAndNewlines)
117
118        // Clear fState.pendingFocus before clearDraftItemUI so that the iOS
119        // onChange(of: focusedFieldBinding) nil-redirect doesn't re-focus
120        // the draft row via a stale fState.pendingFocus value.
121        if case .item(let id) = fState.pendingFocus, id == itemID {
122            fState.pendingFocus = nil
123        }
124
125        clearDraftItemUI(at: placement, hasTitle: !title.isEmpty)
126
127        if fState.selectedItemID == itemID {
128            fState.selectedItemID = nil
129        }
130
131        guard !title.isEmpty else { return }
132
133        do {
134            let newItem =
135                switch placement {
136                case .prepend:
137                    try store.createItem(title: title, atBeginning: true)
138                case .append:
139                    try store.createItem(title: title)
140                }
141            try store.save()
142            if placement == .append {
143                fState.selectedItemID = newItem.id
144            }
145        } catch {
146            presentStoreError(error)
147        }
148
149        if shouldCreateNewItem, placement == .append {
150            revealDraftItem(at: .append)
151        }
152    }
153
154    func createItem(title: String, afterItemID: UUID) {
155        clearDragState()
156        do {
157            let sortOrder = try sortOrderAfter(itemID: afterItemID)
158            let newItem = try store.createItem(title: title, sortOrder: sortOrder)
159            try store.save()
160            fState.selectedItemID = newItem.id
161            focusedField = .scrollView
162        } catch {
163            presentStoreError(error)
164        }
165    }
166
167    private func sortOrderAfter(itemID: UUID) throws -> Int64? {
168        guard let afterIndex = activeItems.firstIndex(where: { $0.id == itemID }) else {
169            return nil
170        }
171        let afterItem = activeItems[afterIndex]
172        if afterIndex + 1 < activeItems.count {
173            let nextItem = activeItems[afterIndex + 1]
174            let midpoint = (afterItem.sortOrder + nextItem.sortOrder) / 2
175            if midpoint == afterItem.sortOrder {
176                // Consecutive sort orders leave no room; re-normalise with 1000-unit gaps
177                // then recompute. Core Data's identity map ensures afterItem/nextItem reflect
178                // the updated values immediately after normalisation.
179                try store.normalizeSortOrders()
180                return (afterItem.sortOrder + nextItem.sortOrder) / 2
181            }
182            return midpoint
183        } else {
184            return afterItem.sortOrder + 1000
185        }
186    }
187
188    // MARK: - Interaction Handlers
189
190    func handleBackgroundTap() {
191        let isItemFocused = if case .item = focusedField { true } else { false }
192
193        if isItemFocused || fState.selectedItemID != nil {
194            fState.pendingFocus = nil
195            if draftPlacement != nil {
196                commitDraftItem()
197            }
198            fState.selectedItemID = nil
199            focusedField = nil
200        } else {
201            revealDraftItem(at: .append, animated: true)
202        }
203    }
204
205    func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
206        let oldID = itemID(from: oldValue)
207        let newID = itemID(from: newValue)
208
209        guard oldID != newID, let oldID else {
210            return
211        }
212
213        if draftPlacement(for: oldID) != nil {
214            return
215        }
216
217        deleteIfEmpty(itemID: oldID)
218    }
219
220    private func itemID(from field: FocusField?) -> UUID? {
221        guard case .item(let id) = field else { return nil }
222        return id
223    }
224
225    private func deleteIfEmpty(itemID: UUID) {
226        if case .item(let pendingItemID) = fState.pendingFocus, pendingItemID == itemID {
227            return
228        }
229
230        guard let entity = items.first(where: { $0.id == itemID }) else {
231            return
232        }
233        let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines)
234        guard trimmedTitle.isEmpty else { return }
235
236        managedObjectContext.undoManager?.removeAllActions(withTarget: entity)
237        managedObjectContext.undoManager?.disableUndoRegistration()
238        deleteItem(itemID: itemID)
239        managedObjectContext.undoManager?.enableUndoRegistration()
240    }
241
242    func updateTitle(itemID: UUID, title: String) {
243        do {
244            try store.updateWithoutSaving(itemID: itemID, title: title)
245        } catch {
246            presentStoreError(error)
247        }
248    }
249
250    func toggleCompletion(itemID: UUID, isCompleted: Bool) {
251        do {
252            if isCompleted {
253                try store.uncomplete(itemID: itemID)
254            } else {
255                try store.complete(itemID: itemID)
256            }
257        } catch {
258            presentStoreError(error)
259        }
260    }
261
262    func handleSwipeComplete(_ itemID: UUID) {
263        guard let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return }
264        toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
265    }
266
267    func handleSwipeDelete(_ itemID: UUID) {
268        deleteItem(itemID: itemID)
269    }
270
271    func selectItem(
272        _ itemID: UUID,
273        extendSelection: Bool = false,
274        toggleSelection: Bool = false
275    ) {
276        if toggleSelection {
277            fState.toggleSelection(
278                itemID: itemID,
279                displayOrder: allItemsInDisplayOrder.map(\.id)
280            )
281        } else if extendSelection && fState.selectedItemID != nil {
282            if fState.anchorItemID == nil {
283                fState.anchorItemID = fState.cursorItemID
284            }
285            fState.extendSelection(
286                to: itemID,
287                displayOrder: allItemsInDisplayOrder.map(\.id)
288            )
289        } else {
290            fState.selectedItemID = itemID
291        }
292    }
293
294    func deleteItem(itemID: UUID) {
295        do {
296            try store.delete(itemID: itemID)
297            fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
298        } catch {
299            presentStoreError(error)
300        }
301    }
302
303    func clearCompletedItems() {
304        for item in completedItems.reversed() {
305            do {
306                try store.delete(itemID: item.id)
307            } catch {
308                presentStoreError(error)
309            }
310        }
311        fState.pruneDeletedItems(displayOrder: allItemsInDisplayOrder.map(\.id))
312    }
313
314    // MARK: - Keyboard Navigation
315
316    func navigateUp() -> KeyPress.Result {
317        guard focusedField == .scrollView else {
318            return .ignored
319        }
320
321        guard let currentID = fState.selectedItemID else {
322            fState.selectedItemID = activeItems.last?.id
323            return .handled
324        }
325
326        let displayOrder = allItemsInDisplayOrder
327        guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
328            return .handled
329        }
330
331        if currentIndex > 0 {
332            fState.selectedItemID = displayOrder[currentIndex - 1].id
333        }
334        return .handled
335    }
336
337    func navigateDown() -> KeyPress.Result {
338        guard focusedField == .scrollView else {
339            return .ignored
340        }
341
342        guard let currentID = fState.selectedItemID else {
343            fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
344            return .handled
345        }
346
347        let displayOrder = allItemsInDisplayOrder
348        guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
349            return .handled
350        }
351
352        if currentIndex < displayOrder.count - 1 {
353            fState.selectedItemID = displayOrder[currentIndex + 1].id
354        }
355        return .handled
356    }
357
358    func navigateToFirst() -> KeyPress.Result {
359        guard focusedField == .scrollView else {
360            return .ignored
361        }
362
363        let displayOrder = allItemsInDisplayOrder
364        guard let first = displayOrder.first else {
365            return .handled
366        }
367        fState.selectedItemID = first.id
368        return .handled
369    }
370
371    func navigateToLast() -> KeyPress.Result {
372        guard focusedField == .scrollView else {
373            return .ignored
374        }
375
376        let displayOrder = allItemsInDisplayOrder
377        guard let last = displayOrder.last else {
378            return .handled
379        }
380        fState.selectedItemID = last.id
381        return .handled
382    }
383
384    func navigatePageUp() -> KeyPress.Result {
385        guard focusedField == .scrollView else {
386            return .ignored
387        }
388
389        let displayOrder = allItemsInDisplayOrder
390        guard !displayOrder.isEmpty else { return .handled }
391
392        guard let currentID = fState.selectedItemID,
393            let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID })
394        else {
395            fState.selectedItemID = displayOrder.first?.id
396            return .handled
397        }
398
399        let targetIndex = max(0, currentIndex - pageNavigationSize)
400        fState.selectedItemID = displayOrder[targetIndex].id
401        return .handled
402    }
403
404    func navigatePageDown() -> KeyPress.Result {
405        guard focusedField == .scrollView else {
406            return .ignored
407        }
408
409        let displayOrder = allItemsInDisplayOrder
410        guard !displayOrder.isEmpty else { return .handled }
411
412        guard let currentID = fState.selectedItemID,
413            let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID })
414        else {
415            fState.selectedItemID = displayOrder.first?.id
416            return .handled
417        }
418
419        let targetIndex = min(displayOrder.count - 1, currentIndex + pageNavigationSize)
420        fState.selectedItemID = displayOrder[targetIndex].id
421        return .handled
422    }
423
424    func navigateUpExtend() -> KeyPress.Result {
425        guard focusedField == .scrollView else {
426            return .ignored
427        }
428
429        let displayOrder = allItemsInDisplayOrder.map(\.id)
430
431        // If nothing is selected yet, start single-select at the bottom.
432        guard let cursorID = fState.cursorItemID else {
433            fState.selectedItemID = activeItems.last?.id
434            return .handled
435        }
436
437        guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
438            cursorIndex > 0
439        else {
440            return .handled
441        }
442
443        let targetID = displayOrder[cursorIndex - 1]
444        // On the first extend, the anchor is wherever the cursor is.
445        if !fState.hasMultipleSelection {
446            fState.anchorItemID = cursorID
447        }
448        fState.extendSelection(to: targetID, displayOrder: displayOrder)
449        return .handled
450    }
451
452    func navigateDownExtend() -> KeyPress.Result {
453        guard focusedField == .scrollView else {
454            return .ignored
455        }
456
457        let displayOrder = allItemsInDisplayOrder.map(\.id)
458
459        guard let cursorID = fState.cursorItemID else {
460            fState.selectedItemID = activeItems.first?.id ?? completedItems.first?.id
461            return .handled
462        }
463
464        guard let cursorIndex = displayOrder.firstIndex(of: cursorID),
465            cursorIndex < displayOrder.count - 1
466        else {
467            return .handled
468        }
469
470        let targetID = displayOrder[cursorIndex + 1]
471        if !fState.hasMultipleSelection {
472            fState.anchorItemID = cursorID
473        }
474        fState.extendSelection(to: targetID, displayOrder: displayOrder)
475        return .handled
476    }
477
478    func toggleSelectedItem() -> KeyPress.Result {
479        guard focusedField == .scrollView else { return .ignored }
480        let ids = fState.selectedItemIDs
481        guard !ids.isEmpty else { return .handled }
482        let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
483        guard !itemsToToggle.isEmpty else { return .handled }
484        let hasActive = itemsToToggle.contains { !$0.isCompleted }
485        let hasCompleted = itemsToToggle.contains { $0.isCompleted }
486        guard !(hasActive && hasCompleted) else { return .handled }
487        for item in itemsToToggle {
488            toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
489        }
490        return .handled
491    }
492
493    func focusSelectedItem() -> KeyPress.Result {
494        guard focusedField == .scrollView else { return .ignored }
495        guard !fState.hasMultipleSelection else { return .handled }
496        guard let currentID = fState.selectedItemID else { return .handled }
497        guard let item = allItemsInDisplayOrder.first(where: { $0.id == currentID }) else {
498            return .handled
499        }
500        guard !item.isCompleted else { return .handled }
501        startEditing(currentID)
502        return .handled
503    }
504
505    func deleteSelectedItem() -> KeyPress.Result {
506        guard focusedField == .scrollView else {
507            return .ignored
508        }
509        let ids = fState.selectedItemIDs
510        guard !ids.isEmpty else { return .handled }
511        let displayOrder = allItemsInDisplayOrder
512        let idsToDelete = displayOrder.filter { ids.contains($0.id) }.map(\.id)
513        guard !idsToDelete.isEmpty else { return .handled }
514
515        // Find the next item after the last selected one to move selection to.
516        let idsToDeleteSet = Set(idsToDelete)
517        let lastSelectedIndex = displayOrder.lastIndex(where: { idsToDeleteSet.contains($0.id) })
518        let nextItem = lastSelectedIndex.flatMap { idx in
519            displayOrder.dropFirst(idx + 1).first(where: { !idsToDeleteSet.contains($0.id) })
520        }
521
522        fState.selectedItemID = nil
523        for itemID in idsToDelete {
524            deleteItem(itemID: itemID)
525        }
526        if let nextItem {
527            fState.selectedItemID = nextItem.id
528        }
529        return .handled
530    }
531
532    func moveSelectedItemUp() {
533        guard focusedField == .scrollView else { return }
534        guard let currentID = fState.selectedItemID else { return }
535        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else {
536            return
537        }
538        guard currentIndex > 0 else { return }
539
540        do {
541            try store.moveItem(itemID: currentID, toIndex: currentIndex - 1)
542        } catch {
543            presentStoreError(error)
544        }
545    }
546
547    func moveSelectedItemDown() {
548        guard focusedField == .scrollView else { return }
549        guard let currentID = fState.selectedItemID else { return }
550        guard let currentIndex = activeItems.firstIndex(where: { $0.id == currentID }) else {
551            return
552        }
553        guard currentIndex < activeItems.count - 1 else { return }
554
555        do {
556            try store.moveItem(itemID: currentID, toIndex: currentIndex + 1)
557        } catch {
558            presentStoreError(error)
559        }
560    }
561
562    func markSelectedItemCompleted() {
563        guard focusedField == .scrollView else { return }
564        let ids = fState.selectedItemIDs
565        guard !ids.isEmpty else { return }
566        let itemsToToggle = allItemsInDisplayOrder.filter { ids.contains($0.id) }
567        for item in itemsToToggle {
568            toggleCompletion(itemID: item.id, isCompleted: item.isCompleted)
569        }
570    }
571
572    // MARK: - Focus Management
573
574    func focusTextField(_ itemID: UUID) {
575        focusedField = .item(itemID)
576    }
577
578    func startEditing(_ itemID: UUID) {
579        fState.selectedItemID = itemID
580        focusedField = .item(itemID)
581        fState.pendingFocus = nil
582    }
583
584    func endEditing(_ itemID: UUID, shouldCreateNewItem: Bool) {
585        if draftPlacement(for: itemID) != nil {
586            commitDraftItem(shouldCreateNewItem: shouldCreateNewItem)
587            return
588        }
589
590        do {
591            try store.save()
592        } catch {
593            presentStoreError(error)
594        }
595
596        let wasLastActiveItem = isLastActiveItem(itemID)
597        let willBeDeleted = shouldDeleteIfEmpty(itemID: itemID)
598
599        if willBeDeleted {
600            fState.selectedItemID = nil
601            deleteIfEmpty(itemID: itemID)
602        } else if wasLastActiveItem && shouldCreateNewItem {
603            revealDraftItem(at: .append)
604        } else if shouldCreateNewItem {
605            focusedField = .scrollView
606        }
607    }
608
609    private func shouldDeleteIfEmpty(itemID: UUID) -> Bool {
610        guard let entity = items.first(where: { $0.id == itemID }) else {
611            return false
612        }
613        let trimmedTitle = entity.title.trimmingCharacters(in: .whitespacesAndNewlines)
614        return trimmedTitle.isEmpty
615    }
616
617    // MARK: - Drag and Drop
618
619    func startDrag(itemID: UUID) {
620        guard case .idle = dragState else { return }
621        withAnimation(.easeOut(duration: 0.15)) {
622            dragState = .dragging(id: itemID, order: activeItems.map(\.id))
623        }
624        didStartDrag()
625    }
626
627    func updateVisualOrder(insertBefore targetID: UUID) {
628        guard let draggedID = draggedItemID,
629            let order = visualOrder
630        else { return }
631
632        var newOrder = order.filter { $0 != draggedID }
633        if let targetIndex = newOrder.firstIndex(of: targetID) {
634            newOrder.insert(draggedID, at: targetIndex)
635        }
636
637        if newOrder != order {
638            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
639                setDragOrder(newOrder)
640            }
641        }
642    }
643
644    func updateVisualOrder(insertAfter targetID: UUID) {
645        guard let draggedID = draggedItemID,
646            let order = visualOrder
647        else { return }
648
649        var newOrder = order.filter { $0 != draggedID }
650        if let targetIndex = newOrder.firstIndex(of: targetID) {
651            newOrder.insert(draggedID, at: targetIndex + 1)
652        }
653
654        if newOrder != order {
655            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
656                setDragOrder(newOrder)
657            }
658        }
659    }
660
661    func updateVisualOrderSmart(relativeTo targetID: UUID) {
662        guard let draggedID = draggedItemID,
663            let order = visualOrder
664        else { return }
665
666        guard let draggedIndex = order.firstIndex(of: draggedID),
667            let targetIndex = order.firstIndex(of: targetID)
668        else { return }
669
670        if draggedIndex < targetIndex {
671            updateVisualOrder(insertAfter: targetID)
672        } else {
673            updateVisualOrder(insertBefore: targetID)
674        }
675    }
676
677    func updateVisualOrder(insertAtEnd: Bool) {
678        guard let draggedID = draggedItemID,
679            let order = visualOrder
680        else { return }
681
682        var newOrder = order.filter { $0 != draggedID }
683        newOrder.append(draggedID)
684
685        if newOrder != order {
686            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
687                setDragOrder(newOrder)
688            }
689        }
690    }
691
692    func commitCurrentDrag() -> Bool {
693        guard let droppedUUID = draggedItemID,
694            let order = visualOrder,
695            let finalIndex = order.firstIndex(of: droppedUUID)
696        else {
697            clearDragState()
698            return false
699        }
700
701        do {
702            try store.moveItem(itemID: droppedUUID, toIndex: finalIndex)
703            clearDragState()
704        } catch {
705            withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
706                clearDragState()
707            }
708            presentStoreError(error)
709        }
710
711        return true
712    }
713
714    func setDragOrder(_ order: [UUID]) {
715        guard case .dragging(let id, _) = dragState else { return }
716        dragState = .dragging(id: id, order: order)
717    }
718
719    func clearDragState() {
720        dragState = .idle
721    }
722}