listless

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

ItemListView+Logic.swift (22528B)


      1 import SwiftUI
      2 
      3 extension 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 }