Listless/Extensions/ItemListView+Logic.swift (22528B)
1import SwiftUI
2
3extension ItemListViewProtocol {
4
5
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
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
119
120
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
177
178
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
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
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
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
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
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
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
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}