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 }