ItemListView.swift (25169B)
1 import SwiftUI 2 import UIKit 3 4 struct 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 /// Combined indicator and phantom entry row sharing the same VStack slot. 254 /// The phantom's UITextView is created while the indicator is visible 255 /// (during the pull), so it's ready when the user releases. 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 /// The draft row content styled to match a item row. Controlled by the 289 /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility. 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