ListlessiOS/Views/ItemListView.swift (25169B)
1import SwiftUI
2import UIKit
3
4struct ItemListView: View, ItemListViewProtocol {
5 class LayoutStorage {
6 var draggedRowWidth: CGFloat = 0
7 var draggedRowFrame: CGRect = .zero
8 var contentBottomY: CGFloat = 0
9 var lastAutoScrollTime: CFTimeInterval = 0
10 }
11
12 struct InteractionStateData {
13 var dragState: DragState = .idle
14 var draftCount: Int = 0
15 var isShowingSyncDiagnostics = false
16 var isShowingSettings = false
17 var clearingItemIDs: Set<UUID> = []
18 var undoToast: UndoToastData? = nil
19 var isSwiping: Bool = false
20 var isShowingRenameAlert = false
21 var isShowingDeleteAllAlert = false
22 var renameText: String = ""
23 var draftPlacement: DraftItemPlacement?
24 var draftTitle: String = ""
25 var fetchWorkaround: Int = 0
26
27 var isShowingOverlay: Bool {
28 isShowingSettings || isShowingSyncDiagnostics || isShowingRenameAlert
29 }
30 }
31
32 struct PullStateData {
33 var pullToCreate = PullToCreateState()
34 var pullUpOffset: CGFloat = 0
35
36 var headerHeight: CGFloat = 60
37 }
38
39 @AppStorage("listName") var listName = "Items"
40 @AppStorage("colorTheme") private var colorThemeRaw = 0
41 @AppStorage("hapticsEnabled") private var hapticsEnabled = true
42 @AppStorage("showFPSOverlay") private var showFPSOverlay = false
43 private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
44 @Environment(\.undoManager) var undoManager
45 @Environment(\.managedObjectContext) var managedObjectContext
46
47 let store: ItemStore
48 @ObservedObject var syncMonitor: CloudKitSyncMonitor
49 @FetchRequest(
50 sortDescriptors: [],
51 animation: .default
52 )
53 var items: FetchedResults<ItemEntity>
54 @FocusState private var focusedFieldBinding: FocusField?
55 @State var fState = FocusStateData()
56 @State var iState = InteractionStateData()
57 @State var pState = PullStateData()
58 @State var isDragging = false
59 @State var layoutStorage = LayoutStorage()
60 @State var scrollPosition = ScrollPosition()
61 @State private var showTutorialHint = false
62
63 var focusedField: FocusField? {
64 get { fState.focusedField }
65 nonmutating set {
66 fState.focusedField = newValue
67 focusedFieldBinding = newValue
68 }
69 }
70
71 var dragState: DragState {
72 get { iState.dragState }
73 nonmutating set { iState.dragState = newValue }
74 }
75
76 var draftPlacement: DraftItemPlacement? {
77 get { iState.draftPlacement }
78 nonmutating set {
79 if newValue != nil, iState.draftPlacement == nil {
80 iState.draftCount += 1
81 }
82 iState.draftPlacement = newValue
83 }
84 }
85
86 var draftTitle: String {
87 get { iState.draftTitle }
88 nonmutating set { iState.draftTitle = newValue }
89 }
90
91 private var isPrependDraftVisible: Bool {
92 draftPlacement == .prepend
93 }
94
95 private var isAppendDraftVisible: Bool {
96 draftPlacement == .append
97 }
98
99
100 private var selectedIndex: Int? {
101 guard let currentID = fState.selectedItemID else { return nil }
102 return activeItems.firstIndex(where: { $0.id == currentID })
103 }
104
105 private var canMoveSelectionUp: Bool {
106 guard focusedField == .scrollView else { return false }
107 guard let index = selectedIndex else { return false }
108 return index > 0
109 }
110
111 private var canMoveSelectionDown: Bool {
112 guard focusedField == .scrollView else { return false }
113 guard let index = selectedIndex else { return false }
114 return index < activeItems.count - 1
115 }
116
117 private struct MenuState: Equatable {
118 let selectedItemID: UUID?
119 let isScrollViewFocused: Bool
120 let activeItemCount: Int
121 let completedItemCount: Int
122 let selectedIndex: Int?
123 }
124
125 private var menuCoordinatorTrigger: MenuState {
126 MenuState(
127 selectedItemID: fState.selectedItemID,
128 isScrollViewFocused: focusedField == .scrollView,
129 activeItemCount: activeItems.count,
130 completedItemCount: completedItems.count,
131 selectedIndex: selectedIndex
132 )
133 }
134
135 func updateMenuCoordinator() {
136 let coord = IOSMenuCoordinator.shared
137 coord.newItem = { createNewItem() }
138 coord.deleteItem = { _ = deleteSelectedItemWithUndo() }
139 coord.moveUp = { moveSelectedItemUp() }
140 coord.moveDown = { moveSelectedItemDown() }
141 coord.markCompleted = { markSelectedItemCompleted() }
142 coord.navigatePageUp = { _ = navigatePageUp() }
143 coord.navigatePageDown = { _ = navigatePageDown() }
144 coord.navigateToFirst = { _ = navigateToFirst() }
145 coord.navigateToLast = { _ = navigateToLast() }
146 let inNavMode = focusedField == .scrollView
147 coord.canDelete = fState.selectedItemID != nil && inNavMode
148 coord.canMoveUp = canMoveSelectionUp
149 coord.canMoveDown = canMoveSelectionDown
150 coord.canMarkCompleted = fState.selectedItemID != nil && inNavMode
151 coord.markCompletedTitle = completedItems.contains(where: { $0.id == fState.selectedItemID })
152 ? "Mark as Incomplete" : "Mark as Complete"
153 }
154
155 var vStackSpacing: CGFloat { 0 }
156 var rowGap: CGFloat { 12 }
157 var pullCreateThreshold: CGFloat { 70 }
158 var flickThreshold: CGFloat { 500 }
159 var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
160
161 var onFinishTutorial: (() -> Void)?
162 var isTutorial: Bool { onFinishTutorial != nil }
163
164 init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, onFinishTutorial: (() -> Void)? = nil) {
165 self.store = store
166 self.syncMonitor = syncMonitor
167 self.onFinishTutorial = onFinishTutorial
168 }
169
170 func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool) {
171 let itemID = draftID(for: placement)
172 if animated {
173 withAnimation { draftPlacement = placement }
174 } else {
175 draftPlacement = placement
176 }
177 var transaction = Transaction()
178 transaction.disablesAnimations = true
179 withTransaction(transaction) {
180 fState.pendingFocus = .item(itemID)
181 focusedField = .item(itemID)
182 fState.selectedItemID = itemID
183 }
184 }
185
186 func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
187 let clear: () -> Void = {
188 if draftPlacement == placement {
189 draftPlacement = nil
190 }
191 draftTitle = ""
192 if fState.selectedItemID == draftID(for: placement) {
193 fState.selectedItemID = nil
194 }
195
196 guard placement == .prepend else { return }
197
198 var state = pState.pullToCreate
199 state.isInsertionPending = false
200 state.indicatorOffset = 0
201 pState.pullToCreate = state
202 }
203
204 if placement == .prepend, !hasTitle {
205 withAnimation(.spring(response: 0.24, dampingFraction: 1.0)) {
206 clear()
207 }
208 } else if placement == .prepend {
209 var transaction = Transaction(animation: nil)
210 transaction.disablesAnimations = true
211 withTransaction(transaction) {
212 clear()
213 }
214 } else if !hasTitle {
215 withAnimation { clear() }
216 } else {
217 clear()
218 }
219
220 if placement == .prepend || !hasTitle {
221 focusedField = nil
222 }
223 }
224
225 func didStartDrag() {
226 isDragging = true
227 if hapticsEnabled {
228 let generator = UIImpactFeedbackGenerator(style: .light)
229 generator.impactOccurred()
230 }
231 }
232
233 func showSyncDiagnostics() {
234 iState.isShowingSyncDiagnostics = true
235 }
236
237 func showSettings() {
238 iState.isShowingSettings = true
239 }
240
241 func showRenameAlert() {
242 iState.renameText = listName
243 iState.isShowingRenameAlert = true
244 }
245
246 private func dragScaleEffect() -> CGFloat {
247 let liftPoints: CGFloat = 20
248 let width = layoutStorage.draggedRowWidth
249 guard width > 0 else { return 1.05 }
250 return (width + liftPoints) / width
251 }
252
253
254
255
256 @ViewBuilder var pullToCreateIndicatorRow: some View {
257 let pullOffset = pState.pullToCreate.pullOffset
258 let indicatorHeight = PullToCreateIndicator.indicatorHeight
259 let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset(
260 threshold: pullCreateThreshold
261 )
262 let frameHeight: CGFloat = isPrependDraftVisible
263 ? 0
264 : min(pullOffset, indicatorHeight + rowGap)
265 let opacity: Double = if isPrependDraftVisible || pullOffset <= 0 {
266 0
267 } else if displayActiveItems.isEmpty {
268 min(1, pullOffset / indicatorHeight)
269 } else {
270 1
271 }
272 PullToCreateIndicator(
273 pullOffset: max(0, indicatorDisplayOffset),
274 threshold: pullCreateThreshold
275 )
276 .frame(
277 height: frameHeight,
278 alignment: .top
279 )
280 .offset(
281 y: displayActiveItems.isEmpty
282 ? -indicatorHeight * (1 - min(1, pullOffset / indicatorHeight))
283 : 0
284 )
285 .opacity(opacity)
286 }
287
288
289
290 @ViewBuilder private var draftPrependRow: some View {
291 DraftRowView(
292 accentColor: itemColor(
293 forIndex: 0, total: max(1, displayActiveItems.count + 1), theme: colorTheme
294 ),
295 isSelected: fState.selectedItemID == draftPrependRowID,
296 draftID: draftPrependRowID,
297 title: $iState.draftTitle,
298 onEditingChanged: { editing, _ in
299 DispatchQueue.main.async {
300 if editing {
301 beginDraftItemEditing(.prepend)
302 } else {
303 commitDraftItem()
304 }
305 }
306 },
307 returnKeyType: .done,
308 accessibilityIdentifier: "draft-row-prepend",
309 focusedField: $focusedFieldBinding
310 )
311 }
312
313 @ViewBuilder private var draftAppendRow: some View {
314 if isAppendDraftVisible {
315 DraftRowView(
316 accentColor: itemColor(
317 forIndex: displayActiveItems.count,
318 total: max(1, displayActiveItems.count + 1),
319 theme: colorTheme
320 ),
321 isSelected: fState.selectedItemID == draftAppendRowID,
322 draftID: draftAppendRowID,
323 title: $iState.draftTitle,
324 onEditingChanged: { editing, shouldCreateNewItem in
325 DispatchQueue.main.async {
326 if editing {
327 beginDraftItemEditing(.append)
328 } else {
329 commitDraftItem(
330 shouldCreateNewItem: shouldCreateNewItem
331 )
332 }
333 }
334 },
335 returnKeyType: draftTitle.trimmingCharacters(
336 in: .whitespacesAndNewlines
337 ).isEmpty ? .done : .next,
338 accessibilityIdentifier: "draft-row-append",
339 focusedField: $focusedFieldBinding
340 )
341 .padding(.bottom, rowGap)
342 .id(draftAppendRowID)
343 }
344 }
345
346 @ViewBuilder private var itemRows: some View {
347 let _ = iState.fetchWorkaround
348 let draftOffset = isPrependDraftVisible ? 1 : 0
349 let draftTotal = draftPlacement != nil ? 1 : 0
350 ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
351 let itemID = item.id
352 ItemRowView(
353 item: item,
354 itemID: itemID,
355 index: index + draftOffset,
356 totalItems: displayActiveItems.count + draftTotal,
357 isSelected: fState.selectedItemID == itemID,
358 isDragging: $isDragging,
359 isSwiping: $iState.isSwiping,
360 isLastActiveItem: index == displayActiveItems.count - 1,
361 focusedField: $focusedFieldBinding,
362 onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
363 onTitleChange: { updateTitle(itemID: $0, title: $1) },
364 onDelete: { deleteItemWithUndo(itemID: $0) },
365 onSelect: { selectItem($0) },
366 onStartEdit: { startEditing($0) },
367 onEndEdit: {
368 if fState.selectedItemID == $0 {
369 fState.selectedItemID = nil
370 }
371 endEditing($0, shouldCreateNewItem: $1)
372 }
373 )
374 .scaleEffect(draggedItemID == itemID ? dragScaleEffect() : 1.0)
375 .shadow(
376 color: draggedItemID == itemID ? .black.opacity(0.3) : .clear,
377 radius: 12, y: 4
378 )
379 .itemDragGesture(
380 isActive: !item.isCompleted && focusedFieldBinding != .item(itemID),
381 itemID: itemID,
382 onDragStart: { width in
383 layoutStorage.draggedRowWidth = width
384 startDrag(itemID: itemID)
385 },
386 onDragChanged: { point in
387 handleIOSDragChanged(itemID: itemID, point: point)
388 },
389 onDragEnded: { commitIOSDrag() }
390 )
391 .background {
392 if draggedItemID == itemID {
393 Color.clear
394 .onGeometryChange(for: CGRect.self) { proxy in
395 proxy.frame(in: .global)
396 } action: { frame in
397 layoutStorage.draggedRowFrame = frame
398 }
399 }
400 }
401 .padding(.bottom, rowGap)
402 .zIndex(draggedItemID == itemID ? 2 : 1)
403 .id(itemID)
404 }
405
406 draftAppendRow
407 .zIndex(3)
408 .overlay(alignment: .top) {
409 if showTutorialHint {
410 Text("Submit empty to remove")
411 .font(.body)
412 .foregroundStyle(.black)
413 .padding(.horizontal, 20)
414 .padding(.vertical, 14)
415 .background(
416 RoundedRectangle(cornerRadius: 12)
417 .fill(Color(red: 1.0, green: 0.84, blue: 0.04))
418 )
419 .shadow(color: .black.opacity(0.15), radius: 8, y: 2)
420 .offset(y: -56)
421 .transition(.move(edge: .bottom).combined(with: .opacity))
422 }
423 }
424
425 ForEach(completedItems) { item in
426 let itemID = item.id
427 let isBeingCleared = iState.clearingItemIDs.contains(itemID)
428 ItemRowView(
429 item: item,
430 itemID: itemID,
431 isSelected: fState.selectedItemID == itemID,
432 isSwiping: $iState.isSwiping,
433 focusedField: $focusedFieldBinding,
434 onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
435 onTitleChange: { updateTitle(itemID: $0, title: $1) },
436 onDelete: { deleteItemWithUndo(itemID: $0) },
437 onSelect: { selectItem($0) }
438 )
439 .opacity(isBeingCleared ? 0 : 1)
440 .offset(y: isBeingCleared ? 40 : 0)
441 .padding(.bottom, rowGap)
442 .id(itemID)
443 }
444 }
445
446 var body: some View {
447 itemScrollView
448 .overlay(alignment: .topLeading) {
449 if showFPSOverlay {
450 FPSOverlay()
451 .padding(.top, -16)
452 .padding(.leading, 8)
453 .allowsHitTesting(false)
454 }
455 }
456 .simultaneousGesture(
457 SpatialTapGesture(coordinateSpace: .global).onEnded { value in
458 guard value.location.y > layoutStorage.contentBottomY else { return }
459 handleBackgroundTap()
460 }
461 )
462 .accessibilityIdentifier("item-list-scrollview")
463 .background {
464 let isEditing = if case .item = focusedFieldBinding { true } else { false }
465 KeyCommandBridge(
466 isActive: !isEditing && !iState.isShowingOverlay,
467 onUp: { _ = navigateUp() },
468 onDown: { _ = navigateDown() },
469 onSpace: { _ = toggleSelectedItem() },
470 onReturn: { _ = focusSelectedItem() },
471 onDelete: { _ = deleteSelectedItemWithUndo() },
472 onHome: { _ = navigateToFirst() },
473 onEnd: { _ = navigateToLast() },
474 onPageUp: { _ = navigatePageUp() },
475 onPageDown: { _ = navigatePageDown() }
476 )
477 }
478 .onAppear {
479 fState.focusedField = .scrollView
480 updateMenuCoordinator()
481 if ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SHOW_SETTINGS") {
482 iState.isShowingSettings = true
483 }
484 }
485 .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
486 .onChange(of: undoManager, initial: true) { _, newValue in
487 managedObjectContext.undoManager = newValue
488 }
489 .toolbar {
490 platformToolbar
491 }
492 .safeAreaInset(edge: .bottom) {
493 syncErrorBanner
494 }
495 .overlay(alignment: .bottom) {
496 if let toast = iState.undoToast {
497 UndoToastView(
498 data: toast,
499 onUndo: { performUndo() },
500 onDismiss: { dismissUndoToast() }
501 )
502 }
503 }
504 .task(id: iState.undoToast?.id) {
505 guard iState.undoToast != nil else { return }
506 try? await Task.sleep(for: .seconds(7))
507 guard !Task.isCancelled else { return }
508 dismissUndoToast()
509 }
510 .onChange(of: iState.draftPlacement) { _, newValue in
511 if isTutorial, newValue == .append {
512 withAnimation {
513 showTutorialHint = true
514 }
515 }
516 }
517 .task(id: showTutorialHint) {
518 guard showTutorialHint else { return }
519 try? await Task.sleep(for: .seconds(4))
520 guard !Task.isCancelled else { return }
521 withAnimation {
522 showTutorialHint = false
523 }
524 }
525 .sheet(isPresented: $iState.isShowingSyncDiagnostics) {
526 NavigationStack {
527 SyncDiagnosticsView(syncMonitor: syncMonitor)
528 .toolbar {
529 ToolbarItem(placement: .topBarLeading) {
530 Button("Close") { iState.isShowingSyncDiagnostics = false }
531 }
532 }
533 }
534 }
535 .sheet(isPresented: $iState.isShowingSettings) {
536 SettingsView(syncMonitor: syncMonitor)
537 }
538 .alert("Rename List", isPresented: $iState.isShowingRenameAlert) {
539 TextField("List name", text: $iState.renameText)
540 Button("Cancel", role: .cancel) {}
541 Button("Rename") {
542 let trimmed = iState.renameText
543 .trimmingCharacters(in: .whitespacesAndNewlines)
544 if !trimmed.isEmpty {
545 listName = trimmed
546 }
547 }
548 .keyboardShortcut(.defaultAction)
549 }
550 .alert("Delete All", isPresented: $iState.isShowingDeleteAllAlert) {
551 Button("Cancel", role: .cancel) {}
552 Button("Delete All", role: .destructive) {
553 deleteAllItemsWithUndo()
554 }
555 } message: {
556 Text("Are you sure you want to delete all items? You can undo this action.")
557 }
558 }
559
560 private var itemScrollView: some View {
561 ZStack(alignment: .top) {
562 ScrollView {
563 VStack(alignment: .leading, spacing: vStackSpacing) {
564 navigationHeader
565 .padding(.bottom, 12)
566 pullToCreateIndicatorRow
567 if isPrependDraftVisible {
568 draftPrependRow
569 .padding(.bottom, rowGap)
570 }
571 itemRows
572 }
573 .frame(maxWidth: .infinity, alignment: .topLeading)
574 .onGeometryChange(for: CGFloat.self) {
575 $0.frame(in: .global).maxY
576 } action: {
577 layoutStorage.contentBottomY = $0
578 }
579 .padding(.trailing, 16)
580 .padding(.vertical, 12)
581 .onChange(of: focusedFieldBinding) { oldValue, newValue in
582 fState.focusedField = newValue
583 handleFocusChange(from: oldValue, to: newValue)
584
585 if newValue == nil,
586 !iState.isShowingOverlay
587 {
588 if let pending = fState.pendingFocus {
589 focusedFieldBinding = pending
590 fState.focusedField = pending
591 fState.pendingFocus = nil
592 } else {
593 focusedFieldBinding = .scrollView
594 fState.focusedField = .scrollView
595 }
596 }
597
598 if case .item(let id) = (newValue ?? fState.focusedField),
599 draggedItemID == nil,
600 id != draftPrependRowID
601 {
602 withAnimation {
603 scrollPosition.scrollTo(id: id)
604 }
605 }
606 }
607 .onChange(of: fState.selectedItemID) { _, newID in
608 if let newID, draggedItemID == nil {
609 guard newID != draftPrependRowID else { return }
610 withAnimation {
611 scrollPosition.scrollTo(id: newID)
612 }
613 }
614 }
615 }
616 .scrollPosition($scrollPosition)
617 .scrollDisabled(draggedItemID != nil || iState.isSwiping)
618 .scrollBounceBehavior(.always)
619 .contentMargins(.bottom, 20)
620 .background {
621 Color.outerBackground.ignoresSafeArea()
622 }
623 .overlay {
624 if isCompletelyEmpty && draftPlacement == nil {
625 Text("Pull down to create")
626 .font(ItemRowMetrics.hintSUI)
627 .foregroundStyle(.secondary)
628 .offset(y: pState.pullToCreate.pullOffset)
629 .allowsHitTesting(false)
630 }
631 }
632 .overlay(alignment: .bottom) {
633 pullToClearIndicatorRow
634 }
635 .pullGestures(
636 pullToCreate: $pState.pullToCreate,
637 pullUpOffset: $pState.pullUpOffset,
638 isEditing: focusedField != nil && focusedField != .scrollView,
639 hasCompletedItems: !completedItems.isEmpty,
640 pullCreateThreshold: pullCreateThreshold,
641 flickThreshold: flickThreshold,
642 pullClearThreshold: pullClearThreshold,
643 onCreateItemAtTop: { revealPhantomRow() },
644 onClearCompleted: {
645 let ids = Set(completedItems.map(\.id))
646 withAnimation(.easeIn(duration: 0.35)) {
647 iState.clearingItemIDs = ids
648 } completion: {
649 iState.clearingItemIDs = []
650 clearCompletedItemsWithUndo()
651 }
652 }
653 )
654 .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0)
655
656 }
657 }
658}
659
660