ItemListView.swift (21054B)
1 import SwiftUI 2 import UniformTypeIdentifiers 3 4 struct ItemListView: View, ItemListViewProtocol { 5 struct InteractionStateData { 6 var dragState: DragState = .idle 7 var liftedItemID: UUID? 8 var draftPlacement: DraftItemPlacement? 9 var draftTitle: String = "" 10 } 11 12 @AppStorage("colorTheme") private var colorThemeRaw = 0 13 private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara } 14 15 @Environment(\.undoManager) var undoManager 16 @Environment(\.managedObjectContext) var managedObjectContext 17 18 let store: ItemStore 19 let windowCoordinator: WindowCoordinator 20 @ObservedObject var syncMonitor: CloudKitSyncMonitor 21 @FetchRequest( 22 sortDescriptors: [], 23 animation: .default 24 ) 25 var items: FetchedResults<ItemEntity> 26 @FocusState private var focusedFieldBinding: FocusField? 27 @State var fState = FocusStateData() 28 @State private var iState = InteractionStateData() 29 30 var focusedField: FocusField? { 31 get { fState.focusedField } 32 nonmutating set { 33 fState.focusedField = newValue 34 focusedFieldBinding = newValue 35 } 36 } 37 38 var dragState: DragState { 39 get { iState.dragState } 40 nonmutating set { iState.dragState = newValue } 41 } 42 43 var draftPlacement: DraftItemPlacement? { 44 get { iState.draftPlacement } 45 nonmutating set { iState.draftPlacement = newValue } 46 } 47 48 var draftTitle: String { 49 get { iState.draftTitle } 50 nonmutating set { iState.draftTitle = newValue } 51 } 52 53 var vStackSpacing: CGFloat { 0 } 54 var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty } 55 var selectedIndex: Int? { 56 guard let currentID = fState.selectedItemID else { return nil } 57 return activeItems.firstIndex(where: { $0.id == currentID }) 58 } 59 60 var canDeleteSelectionFromList: Bool { 61 !fState.selectedItemIDs.isEmpty && focusedField == .scrollView 62 } 63 64 var canMarkSelectionCompleted: Bool { 65 guard focusedField == .scrollView else { return false } 66 let selected = allItemsInDisplayOrder.filter { fState.isItemSelected($0.id) } 67 guard !selected.isEmpty else { return false } 68 let hasActive = selected.contains { !$0.isCompleted } 69 let hasCompleted = selected.contains { $0.isCompleted } 70 return !(hasActive && hasCompleted) 71 } 72 73 var markCompletedMenuTitle: String { 74 if fState.hasMultipleSelection { 75 let hasCompleted = completedItems.contains(where: { fState.isItemSelected($0.id) }) 76 return hasCompleted ? "Mark as Incomplete" : "Mark as Complete" 77 } 78 return completedItems.contains(where: { $0.id == fState.selectedItemID }) 79 ? "Mark as Incomplete" : "Mark as Complete" 80 } 81 82 var canMoveSelectionUp: Bool { 83 guard focusedField == .scrollView else { return false } 84 guard !fState.hasMultipleSelection else { return false } 85 guard let index = selectedIndex else { return false } 86 return index > 0 87 } 88 89 var canMoveSelectionDown: Bool { 90 guard focusedField == .scrollView else { return false } 91 guard !fState.hasMultipleSelection else { return false } 92 guard let index = selectedIndex else { return false } 93 return index < activeItems.count - 1 94 } 95 96 struct MenuState: Equatable { 97 let selectedItemIDs: Set<UUID> 98 let isScrollViewFocused: Bool 99 let activeItemCount: Int 100 let completedItemCount: Int 101 let selectedIndex: Int? 102 } 103 104 var windowCoordinatorTrigger: MenuState { 105 MenuState( 106 selectedItemIDs: fState.selectedItemIDs, 107 isScrollViewFocused: focusedField == .scrollView, 108 activeItemCount: activeItems.count, 109 completedItemCount: completedItems.count, 110 selectedIndex: selectedIndex 111 ) 112 } 113 114 func updateWindowCoordinator() { 115 let coord = windowCoordinator 116 coord.newItem = { createNewItem() } 117 coord.copySelectedItem = { 118 guard let itemID = fState.selectedItemID, 119 let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return } 120 let pasteboard = NSPasteboard.general 121 pasteboard.clearContents() 122 pasteboard.setString(item.title, forType: .string) 123 } 124 coord.cutSelectedItem = { 125 guard let itemID = fState.selectedItemID, 126 let item = allItemsInDisplayOrder.first(where: { $0.id == itemID }) else { return } 127 let pasteboard = NSPasteboard.general 128 pasteboard.clearContents() 129 pasteboard.setString(item.title, forType: .string) 130 deleteItem(itemID: itemID) 131 } 132 coord.pasteAfterSelectedItem = { 133 guard let itemID = fState.selectedItemID, 134 let string = NSPasteboard.general.string(forType: .string) else { return } 135 createItem(title: string, afterItemID: itemID) 136 } 137 coord.deleteSelectedItem = { _ = deleteSelectedItem() } 138 coord.moveSelectedItemUp = { moveSelectedItemUp() } 139 coord.moveSelectedItemDown = { moveSelectedItemDown() } 140 coord.markSelectedItemCompleted = { markSelectedItemCompleted() } 141 coord.selectAllItems = { 142 fState.selectAll(displayOrder: allItemsInDisplayOrder.map(\.id)) 143 } 144 coord.clearCompletedItems = { clearCompletedItems() } 145 let inNavMode = focusedField == .scrollView 146 let singleSelect = !fState.selectedItemIDs.isEmpty && !fState.hasMultipleSelection 147 coord.canSelectAllItems = inNavMode && !allItemsInDisplayOrder.isEmpty 148 coord.canCopySelectedItem = singleSelect && inNavMode 149 coord.canCutSelectedItem = singleSelect && inNavMode 150 coord.canPasteAfterSelectedItem = selectedIndex != nil && singleSelect && inNavMode 151 coord.canDeleteSelectedItem = canDeleteSelectionFromList 152 coord.canMoveSelectedItemUp = canMoveSelectionUp 153 coord.canMoveSelectedItemDown = canMoveSelectionDown 154 coord.canMarkSelectedItemCompleted = canMarkSelectionCompleted 155 coord.markCompletedTitle = markCompletedMenuTitle 156 coord.canClearCompletedItems = !completedItems.isEmpty 157 } 158 159 init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, windowCoordinator: WindowCoordinator) { 160 self.store = store 161 self.syncMonitor = syncMonitor 162 self.windowCoordinator = windowCoordinator 163 } 164 165 func isRowLifted(_ itemID: UUID) -> Bool { 166 iState.liftedItemID == itemID || draggedItemID == itemID 167 } 168 169 func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool = false) { 170 let itemID = draftID(for: placement) 171 draftPlacement = placement 172 fState.pendingFocus = .item(itemID) 173 focusedField = .item(itemID) 174 fState.selectedItemID = itemID 175 } 176 177 func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle _: Bool) { 178 if draftPlacement == placement { 179 draftPlacement = nil 180 } 181 draftTitle = "" 182 if fState.selectedItemID == draftID(for: placement) { 183 fState.selectedItemID = nil 184 } 185 // Resign AppKit first responder explicitly — SwiftUI's @FocusState 186 // and AppKit's responder chain are parallel systems, so setting 187 // focusedField alone may not dismiss the NSTextField. 188 NSApp.keyWindow?.makeFirstResponder(nil) 189 focusedField = nil 190 } 191 192 func didStartDrag() {} 193 194 var body: some View { 195 ScrollView { 196 ScrollViewReader { scrollProxy in 197 VStack(alignment: .leading, spacing: vStackSpacing) { 198 ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in 199 let itemID = item.id 200 ItemRowView( 201 item: item, 202 itemID: itemID, 203 index: index, 204 totalItems: displayActiveItems.count, 205 isSelected: fState.isItemSelected(itemID), 206 focusedField: $focusedFieldBinding, 207 onToggle: { handleSwipeComplete($0) }, 208 onTitleChange: { updateTitle(itemID: $0, title: $1) }, 209 onDelete: { deleteItem(itemID: $0) }, 210 onSelect: { 211 let modifiers = NSApp.currentEvent?.modifierFlags ?? [] 212 selectItem( 213 $0, 214 extendSelection: modifiers.contains(.shift), 215 toggleSelection: modifiers.contains(.command) 216 ) 217 }, 218 onStartEdit: { startEditing($0) }, 219 onEndEdit: { endEditing($0, shouldCreateNewItem: $1) }, 220 onPaste: { createItem(title: $0, afterItemID: itemID) } 221 ) 222 .itemDragGesture( 223 isActive: !item.isCompleted, 224 itemID: itemID, 225 onDragStart: { 226 iState.liftedItemID = nil 227 startDrag(itemID: itemID) 228 }, 229 onLift: { iState.liftedItemID = itemID }, 230 onLiftEnd: { 231 if iState.liftedItemID == itemID { iState.liftedItemID = nil } 232 if draggedItemID == itemID { clearDragState() } 233 } 234 ) 235 .scaleEffect(isRowLifted(itemID) ? 1.03 : 1.0) 236 .shadow( 237 color: isRowLifted(itemID) ? .black.opacity(0.2) : .clear, 238 radius: 8, y: 3 239 ) 240 .zIndex(isRowLifted(itemID) ? 1 : 0) 241 .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(itemID)) 242 .overlay { 243 if draggedItemID != nil && draggedItemID != itemID { 244 VStack(spacing: 0) { 245 // Top 1/6 - insert BEFORE 246 Color.clear 247 .frame(maxHeight: .infinity) 248 .layoutPriority(1) 249 .onDrop( 250 of: [UTType.text], 251 delegate: ItemReorderDropDelegate( 252 onTargeted: { updateVisualOrder(insertBefore: itemID) }, 253 onPerform: { commitCurrentDrag() } 254 ) 255 ) 256 257 // Middle 2/3 - insert based on direction 258 Color.clear 259 .frame(maxHeight: .infinity) 260 .layoutPriority(4) 261 .onDrop( 262 of: [UTType.text], 263 delegate: ItemReorderDropDelegate( 264 onTargeted: { updateVisualOrderSmart(relativeTo: itemID) }, 265 onPerform: { commitCurrentDrag() } 266 ) 267 ) 268 269 // Bottom 1/6 - insert AFTER 270 Color.clear 271 .frame(maxHeight: .infinity) 272 .layoutPriority(1) 273 .onDrop( 274 of: [UTType.text], 275 delegate: ItemReorderDropDelegate( 276 onTargeted: { updateVisualOrder(insertAfter: itemID) }, 277 onPerform: { commitCurrentDrag() } 278 ) 279 ) 280 } 281 } 282 } 283 } 284 285 if draftPlacement == .append { 286 let total = max(1, displayActiveItems.count + 1) 287 let index = displayActiveItems.count 288 let accentColor = cachedItemColor( 289 forIndex: index, total: total, theme: colorTheme 290 ) 291 let isSelected = fState.isItemSelected(draftAppendRowID) 292 HStack(alignment: .firstTextBaseline, spacing: 12) { 293 Image(systemName: "circle") 294 .foregroundStyle(.primary) 295 .font(.system(size: 17)) 296 .fontWeight(.thin) 297 .alignmentGuide(.firstTextBaseline) { d in 298 d[VerticalAlignment.center] + 5 299 } 300 301 ClickableTextField( 302 text: Binding( 303 get: { iState.draftTitle }, 304 set: { iState.draftTitle = $0 } 305 ), 306 isCompleted: false, 307 onEditingChanged: { editing, shouldCreateNewItem in 308 if editing { 309 beginDraftItemEditing(.append) 310 } else { 311 commitDraftItem( 312 shouldCreateNewItem: shouldCreateNewItem 313 ) 314 } 315 }, 316 itemID: draftAppendRowID 317 ) 318 .focused( 319 $focusedFieldBinding, 320 equals: .item(draftAppendRowID) 321 ) 322 .frame(maxWidth: .infinity, alignment: .leading) 323 } 324 .padding(.top, 4) 325 .padding(.vertical, 8) 326 .padding(.horizontal, 16) 327 .frame(maxWidth: .infinity, alignment: .leading) 328 .contentShape(Rectangle()) 329 .background( 330 isSelected 331 ? RoundedRectangle(cornerRadius: 6, style: .continuous) 332 .fill(Color(nsColor: .controlBackgroundColor)) 333 : nil 334 ) 335 .overlay(alignment: .leading) { 336 Rectangle() 337 .fill(accentColor) 338 .frame(width: 4) 339 .padding(.vertical, 1) 340 } 341 .overlay { 342 if isSelected { 343 RoundedRectangle(cornerRadius: 6, style: .continuous) 344 .strokeBorder(accentColor.opacity(0.40), lineWidth: 2) 345 } 346 } 347 .accessibilityIdentifier("draft-row-append") 348 .id(draftAppendRowID) 349 } 350 351 ForEach(completedItems) { item in 352 let itemID = item.id 353 ItemRowView( 354 item: item, 355 itemID: itemID, 356 isSelected: fState.isItemSelected(itemID), 357 focusedField: $focusedFieldBinding, 358 onToggle: { handleSwipeComplete($0) }, 359 onTitleChange: { updateTitle(itemID: $0, title: $1) }, 360 onDelete: { deleteItem(itemID: $0) }, 361 onSelect: { 362 selectItem( 363 $0, 364 extendSelection: NSEvent.modifierFlags.contains(.shift) 365 ) 366 } 367 ) 368 } 369 } 370 .frame(maxWidth: .infinity, alignment: .topLeading) 371 .onDrop( 372 of: [UTType.text], 373 delegate: ItemReorderDropDelegate( 374 onTargeted: {}, 375 onPerform: { commitCurrentDrag() } 376 ) 377 ) 378 .onChange(of: focusedFieldBinding) { _, newValue in 379 if case .item(let id) = (newValue ?? fState.focusedField), 380 draggedItemID == nil, 381 id != draftPrependRowID 382 { 383 withAnimation { 384 scrollProxy.scrollTo(id) 385 } 386 } 387 } 388 .onChange(of: fState.selectedItemID) { _, newID in 389 if let newID, draggedItemID == nil { 390 guard newID != draftPrependRowID else { return } 391 withAnimation { 392 scrollProxy.scrollTo(newID) 393 } 394 } 395 } 396 } 397 } 398 .onDrop( 399 of: [UTType.text], 400 delegate: ItemReorderDropDelegate( 401 onTargeted: {}, 402 onPerform: { commitCurrentDrag() } 403 ) 404 ) 405 .background { 406 BackgroundClickMonitor { 407 handleBackgroundTap() 408 } 409 } 410 .background(Color.outerBackground) 411 .overlay { 412 if isCompletelyEmpty && draftPlacement == nil { 413 Text("Click to create") 414 .font(.subheadline) 415 .foregroundStyle(.secondary) 416 .allowsHitTesting(false) 417 .accessibilityIdentifier("empty-state-label") 418 } 419 } 420 .focusable() 421 .focused($focusedFieldBinding, equals: .scrollView) 422 .focusEffectDisabled() 423 .accessibilityIdentifier("item-list-scrollview") 424 .keyboardNavigation([ 425 ShortcutKey(key: .upArrow): navigateUp, 426 ShortcutKey(key: .downArrow): navigateDown, 427 ShortcutKey(key: .upArrow, modifiers: .shift): navigateUpExtend, 428 ShortcutKey(key: .downArrow, modifiers: .shift): navigateDownExtend, 429 ShortcutKey(key: .home): navigateToFirst, 430 ShortcutKey(key: .end): navigateToLast, 431 ShortcutKey(key: .pageUp): navigatePageUp, 432 ShortcutKey(key: .pageDown): navigatePageDown, 433 ShortcutKey(key: .return): focusSelectedItem, 434 ]) 435 .onAppear { 436 if focusedFieldBinding == nil { 437 focusedFieldBinding = .scrollView 438 } 439 fState.focusedField = focusedFieldBinding 440 updateWindowCoordinator() 441 } 442 .onChange(of: focusedFieldBinding) { oldValue, newValue in 443 // Clear the per-window focus gate once we've landed on 444 // a non-nil value (reconciliation is done). Keep it set 445 // while nil so the redirect below doesn't open a window 446 // for AppKit's key-view loop. 447 if newValue != nil { 448 windowCoordinator.allowedFocusTarget = nil 449 } 450 fState.focusedField = newValue 451 handleFocusChange(from: oldValue, to: newValue) 452 453 if let pending = fState.pendingFocus, newValue != pending { 454 // Focus landed somewhere other than the intended 455 // target (or went nil). Set the allowed target so 456 // the text field can claim focus in 457 // viewDidMoveToWindow if it's not yet in the 458 // hierarchy, and redirect immediately in case it is. 459 windowCoordinator.allowedFocusTarget = pending 460 fState.pendingFocus = nil 461 focusedField = pending 462 } else if newValue == nil { 463 focusedField = .scrollView 464 } 465 466 updateWindowCoordinator() 467 } 468 .onChange(of: windowCoordinatorTrigger) { _, _ in updateWindowCoordinator() } 469 .onChange(of: undoManager, initial: true) { _, newValue in 470 managedObjectContext.undoManager = newValue 471 } 472 .toolbar { 473 platformToolbar 474 } 475 .safeAreaInset(edge: .bottom) { 476 syncErrorBanner 477 } 478 } 479 } 480 481 private struct ItemReorderDropDelegate: DropDelegate { 482 let onTargeted: () -> Void 483 let onPerform: () -> Bool 484 485 func validateDrop(info: DropInfo) -> Bool { 486 true 487 } 488 489 func dropEntered(info: DropInfo) { 490 onTargeted() 491 } 492 493 func dropUpdated(info: DropInfo) -> DropProposal? { 494 return DropProposal(operation: .move) 495 } 496 497 func performDrop(info: DropInfo) -> Bool { 498 onPerform() 499 } 500 }