// // ItineraryTableViewController.swift // SportsTime // // Native UITableViewController for fluid itinerary drag-and-drop reordering. // // ═══════════════════════════════════════════════════════════════════════════════ // ARCHITECTURE OVERVIEW // ═══════════════════════════════════════════════════════════════════════════════ // // WHY UITableViewController INSTEAD OF SWIFTUI? // ───────────────────────────────────────────── // SwiftUI's drag-and-drop APIs (`.draggable()`, `.dropDestination()`) have significant // limitations for complex reordering: // // 1. No real-time visual feedback during drag (item snaps only on drop) // 2. Limited control over drop target validation during drag // 3. Difficult to constrain items to valid drop zones while dragging // 4. ForEach with onMove doesn't support heterogeneous item types well // // UITableViewController provides: // - Native drag handles with familiar iOS reordering UX // - Real-time row shifting during drag via `targetIndexPathForMoveFromRowAt` // - Fine-grained control over valid drop positions // - Built-in animation and haptic feedback // // The trade-off: We use UIHostingConfiguration to render SwiftUI views in cells, // getting UIKit's interaction model with SwiftUI's declarative UI. // // ═══════════════════════════════════════════════════════════════════════════════ // DATA FLOW // ═══════════════════════════════════════════════════════════════════════════════ // // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ TripDetailView (SwiftUI) │ // │ - Owns trip data, custom items, travel day overrides │ // │ - Computes itinerary sections for non-table display │ // └──────────────────────────────────┬──────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ ItineraryTableViewWrapper (SwiftUI) │ // │ - UIViewControllerRepresentable bridge │ // │ - buildItineraryData() transforms trip → [ItineraryDayData] │ // │ - Computes travelValidRanges for each travel segment │ // │ - Passes callbacks for move/tap/delete events │ // └──────────────────────────────────┬──────────────────────────────────────────┘ // │ // ▼ // ┌─────────────────────────────────────────────────────────────────────────────┐ // │ ItineraryTableViewController (UIKit) │ // │ - reloadData() flattens [ItineraryDayData] → [ItineraryRowItem] │ // │ - Renders rows, handles drag-and-drop, fires callbacks │ // └─────────────────────────────────────────────────────────────────────────────┘ // // ═══════════════════════════════════════════════════════════════════════════════ // ROW ORDERING (FLATTENING STRATEGY) // ═══════════════════════════════════════════════════════════════════════════════ // // Each day is flattened into rows in this EXACT order: // // 1. TRAVEL (if arriving this day) - "Detroit → Milwaukee" card // 2. DAY HEADER + ADD BUTTON - "Day 3 · Sunday, Mar 8 + Add" // 3. GAMES - All games for this day (city label + cards) // 4. CUSTOM ITEMS - User-added items sorted by sortOrder // // NOTE: The Add button is EMBEDDED in the day header row (not a separate row). // This prevents items from being dragged between the header and Add button. // // Visual example: // ┌─────────────────────────────────────┐ // │ 🚗 Detroit → Milwaukee (327mi) │ ← Travel (arrives Day 3) // ├─────────────────────────────────────┤ // │ Day 3 · Sunday, Mar 8 + Add │ ← Day Header with Add button // ├─────────────────────────────────────┤ // │ Milwaukee │ ← Games (city + cards) // │ ┌─────────────────────────────────┐ │ // │ │ NBA: ORL @ MIL 8:00 PM │ │ // │ └─────────────────────────────────┘ │ // ├─────────────────────────────────────┤ // │ 🍽️ Waffle House │ ← Custom Item (sortOrder: 1.0) // │ 🍽️ Joe's BBQ │ ← Custom Item (sortOrder: 2.0) // └─────────────────────────────────────┘ // // ═══════════════════════════════════════════════════════════════════════════════ // REORDERABLE VS FIXED ITEMS // ═══════════════════════════════════════════════════════════════════════════════ // // REORDERABLE (user can drag): // - Travel segments (constrained to valid day range) // - Custom items (can move to any day, any position) // // FIXED (no drag handle): // - Day headers with Add button (structural anchors) // - Games (determined by trip planning, not user-movable) // // ═══════════════════════════════════════════════════════════════════════════════ // TRAVEL MOVEMENT CONSTRAINTS // ═══════════════════════════════════════════════════════════════════════════════ // // Travel segments have HARD constraints on which days they can occupy. // This prevents impossible itineraries (e.g., arriving before leaving). // // Valid range calculation (in ItineraryTableViewWrapper.buildItineraryData): // // For travel "Detroit → Milwaukee": // - EARLIEST day: day after last game in Detroit (must finish games first) // - LATEST day: day of first game in Milwaukee (must arrive for game) // // Example: If Detroit has games on Days 1-2, Milwaukee has game on Day 4: // - Valid range: Days 3-4 (can travel on Day 3 rest day, or Day 4 morning) // // During drag, `targetIndexPathForMoveFromRowAt` clamps the drop position // to keep travel within its valid range. User sees travel "snap" to valid // positions rather than being allowed in invalid spots. // // ═══════════════════════════════════════════════════════════════════════════════ // SORT ORDER SYSTEM // ═══════════════════════════════════════════════════════════════════════════════ // // Custom items use a Double sortOrder for positioning within a day. // This allows unlimited insertions without integer collisions. // // MIDPOINT INSERTION ALGORITHM (calculateSortOrder): // // When dropping between two items: // - Item A has sortOrder 1.0 // - Item B has sortOrder 2.0 // - Dropped item gets sortOrder 1.5 (midpoint) // // Edge cases: // - Dropping first: sortOrder = existing_first / 2.0 // - Dropping last: sortOrder = existing_last + 1.0 // - Empty day: sortOrder = 1.0 // // This allows unlimited reordering without needing to renumber all items. // Even after many insertions, precision remains sufficient (Double has ~15 // significant digits, so you'd need millions of insertions to cause issues). // // ═══════════════════════════════════════════════════════════════════════════════ // CALLBACKS TO PARENT // ═══════════════════════════════════════════════════════════════════════════════ // // onTravelMoved: ((String, Int) -> Void)? // - Called when user drops travel segment // - Parameters: travelId (stable identifier), newDay (1-indexed) // - Parent stores override in travelDayOverrides dictionary // // onCustomItemMoved: ((UUID, Int, Double) -> Void)? // - Called when user drops custom item // - Parameters: itemId, newDay (1-indexed), newSortOrder // - Parent updates item and syncs to CloudKit // // onCustomItemTapped: ((ItineraryItem) -> Void)? // - Called when user taps custom item row // - Parent presents edit sheet // // onCustomItemDeleted: ((ItineraryItem) -> Void)? // - Called from context menu delete action // - Parent deletes from CloudKit // // onAddButtonTapped: ((Int) -> Void)? // - Called when user taps Add button // - Parameter: day number (1-indexed) // - Parent presents AddItemSheet for that day // // ═══════════════════════════════════════════════════════════════════════════════ // KEY IMPLEMENTATION DETAILS // ═══════════════════════════════════════════════════════════════════════════════ // // 1. EDITING MODE ALWAYS ON // tableView.isEditing = true enables drag handles without delete buttons. // editingStyleForRowAt returns .none to hide delete controls. // // 2. HEADER SIZING // Table header (trip summary card) uses dynamic sizing. The // viewDidLayoutSubviews dance prevents infinite layout loops by tracking // lastHeaderHeight and using isAdjustingHeader guard. // // 3. TRAVEL DAY ASSOCIATION // Travel is conceptually "the travel that arrives on day N". Visually it // appears BEFORE day N's header. When finding which day a travel belongs // to, we look FORWARD to find the next dayHeader. // // 4. CONTEXT MENUS // Long-press on custom items shows Edit/Delete menu via UIKit context menus. // More reliable than SwiftUI's contextMenu in table cells. // // ═══════════════════════════════════════════════════════════════════════════════ // LIMITATIONS & KNOWN ISSUES // ═══════════════════════════════════════════════════════════════════════════════ // // 1. Games are not individually reorderable - they're bundled as one row per day. // This is intentional: game times are fixed, reordering would be confusing. // // 2. Travel segments can't be deleted - they're structural to the trip route. // User can only move them within valid day ranges. // // 3. sortOrder precision: After millions of midpoint insertions, precision // could theoretically degrade. In practice, this never happens. If needed, // a "normalize sort orders" function could reassign 1.0, 2.0, 3.0... // // 4. SwiftUI views in cells: UIHostingConfiguration works well but has slight // overhead. For very long trips (100+ days), consider cell recycling optimization. // // ═══════════════════════════════════════════════════════════════════════════════ // RELATED FILES // ═══════════════════════════════════════════════════════════════════════════════ // // - ItineraryTableViewWrapper.swift: SwiftUI bridge, data transformation // - TripDetailView.swift: Parent view, owns state, handles callbacks // - ItineraryItem.swift: Domain model with (day, sortOrder, kind) positioning // - ItineraryItemService.swift: CloudKit persistence for itinerary items // // ═══════════════════════════════════════════════════════════════════════════════ import UIKit import SwiftUI // MARK: - Data Models /// Intermediate structure passed from ItineraryTableViewWrapper. /// Contains one day's worth of data before flattening into rows. /// /// Note: `items` contains only custom items (add buttons are generated during flattening). /// `travelBefore` is the travel segment that ARRIVES on this day (visually before the header). struct ItineraryDayData: Identifiable { let id: Int // dayNumber (1-indexed) let dayNumber: Int let date: Date let games: [RichGame] var items: [ItineraryRowItem] // Custom items for this day (sorted by sortOrder) var travelBefore: TravelSegment? // Travel arriving this day (renders BEFORE day header) var isRestDay: Bool { games.isEmpty } } /// Represents a single row in the flattened table view. /// /// The enum cases map 1:1 to visual row types. Each has a stable `id` for diffing /// and an `isReorderable` flag controlling whether the drag handle appears. /// /// ID format examples: /// - dayHeader: "day:3" /// - games: "games:3" /// - travel: "travel:0:detroit->milwaukee" (index:lowercase, stable across sessions) /// - customItem: "item:550e8400-e29b-41d4-a716-446655440000" /// - addButton: "add:3" enum ItineraryRowItem: Identifiable, Equatable { case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor (includes Add button) case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range case customItem(ItineraryItem) // Reorderable: anywhere /// Stable identifier for table view diffing and external references. /// Travel IDs include segment index and are lowercase for consistency. var id: String { switch self { case .dayHeader(let dayNumber, _): return "day:\(dayNumber)" case .games(_, let dayNumber): return "games:\(dayNumber)" case .travel(let segment, let dayNumber): // Use segment UUID for unique diffing (segment index is not available here) return "travel:\(segment.id.uuidString):\(dayNumber)" case .customItem(let item): return "item:\(item.id.uuidString)" } } /// Controls whether UITableView shows the drag reorder handle. /// Day headers and games are structural - users can't move them. var isReorderable: Bool { switch self { case .dayHeader, .games: return false case .travel, .customItem: return true } } static func == (lhs: ItineraryRowItem, rhs: ItineraryRowItem) -> Bool { lhs.id == rhs.id } } // MARK: - Table View Controller final class ItineraryTableViewController: UITableViewController { // MARK: - Properties private(set) var flatItems: [ItineraryRowItem] = [] var travelValidRanges: [String: ClosedRange] = [:] // travelId -> valid day range var colorScheme: ColorScheme = .dark // Callbacks var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemTapped: ((ItineraryItem) -> Void)? var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number // Cell reuse identifiers private let dayHeaderCellId = "DayHeaderCell" private let gamesCellId = "GamesCell" private let travelCellId = "TravelCell" private let customItemCellId = "CustomItemCell" // Header sizing state - prevents infinite layout loops private var lastHeaderHeight: CGFloat = 0 private var isAdjustingHeader = false // MARK: - Constraint-Aware Drag State // // These properties track the current drag operation for constraint validation // and visual feedback. They're populated when drag starts and cleared when it ends. /// The constraint system for validating item positions private var constraints: ItineraryConstraints? /// All itinerary items (needed to build constraints) private var allItineraryItems: [ItineraryItem] = [] /// Canonical trip travel segment index keyed by TravelSegment UUID. private var travelSegmentIndices: [UUID: Int] = [:] /// Trip day count for constraints private var tripDayCount: Int = 0 /// The item currently being dragged (nil when no drag active) private var draggingItem: ItineraryRowItem? /// The day number the user is targeting during custom item drag (for stable positioning) private var dragTargetDay: Int? /// Row indices that are invalid drop targets for the current drag (for visual dimming) private var invalidRowIndices: Set = [] /// Row indices that ARE valid drop targets - pre-calculated at drag start for stability /// Using a sorted array enables O(log n) nearest-neighbor lookup private var validDropRows: [Int] = [] /// Valid destination rows in *proposed* coordinate space (after removing the source row). /// Precomputed at drag start by simulating the move and validating semantic constraints. private var validDestinationRowsProposed: [Int] = [] /// IDs of games that act as barriers for the current travel drag (for gold highlighting) private var barrierGameIds: Set = [] /// Track whether we're currently in a valid drop zone (for haptic feedback) private var isInValidZone: Bool = true /// Haptic feedback generator for drag interactions private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium) // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupTableView() } /// Configures the UITableView for our specific use case. /// /// Key decisions: /// - `isEditing = true`: Shows drag handles on reorderable rows. We keep this permanently on /// since the primary interaction IS reordering. Non-reorderable rows return false from /// `canMoveRowAt` so they don't show handles. /// - `allowsSelectionDuringEditing = true`: Normally, editing mode disables row selection. /// We need selection for tapping Add buttons and custom items, so we override this. /// - `separatorStyle = .none`: We use custom card styling per row type, so system separators /// would look out of place. /// - `estimatedRowHeight = 80`: Helps UITableView's internal calculations. The actual height /// is determined by UIHostingConfiguration's automatic sizing. /// - `contentInset.bottom = 40`: Adds padding at the bottom so the last row isn't flush /// against the screen edge when scrolled to the end. private func setupTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 80 tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 40, right: 0) // IMPORTANT: isEditing must be true for drag handles to appear. // This is different from "delete mode" editing - we disable delete controls // via editingStyleForRowAt returning .none. tableView.isEditing = true tableView.allowsSelectionDuringEditing = true // We use generic UITableViewCell with UIHostingConfiguration for content. // This gives us SwiftUI views with UIKit drag-and-drop. Different cell IDs // help with debugging but aren't strictly necessary since we fully replace // contentConfiguration each time. tableView.register(UITableViewCell.self, forCellReuseIdentifier: dayHeaderCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: gamesCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: travelCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: customItemCellId) } /// Installs a SwiftUI view as the table's header (appears above all rows). /// /// We wrap the view in a container because UITableView header sizing is notoriously /// tricky. The container provides a consistent Auto Layout context. The actual sizing /// is handled in `viewDidLayoutSubviews`. /// /// - Parameter view: A UIView (typically from UIHostingController wrapping SwiftUI content) func setTableHeader(_ view: UIView) { let containerView = UIView() containerView.backgroundColor = .clear containerView.addSubview(view) // Pin the content view to all edges of the container view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: containerView.topAnchor), view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) tableView.tableHeaderView = containerView } /// Handles dynamic header sizing. /// /// UITableView doesn't automatically size tableHeaderView - we must manually calculate /// the appropriate height and update the frame. This is called on every layout pass. /// /// **CRITICAL: Infinite Loop Prevention** /// Setting `tableView.tableHeaderView = headerView` triggers another layout pass. /// Without guards, this creates an infinite loop: /// layoutSubviews → set header → layoutSubviews → set header → ... /// /// We prevent this with two mechanisms: /// 1. `isAdjustingHeader` flag blocks re-entry during our update /// 2. `lastHeaderHeight` skips updates when height hasn't meaningfully changed /// /// The 1-point threshold handles floating-point jitter that can occur between passes. override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Guard 1: Don't re-enter while we're in the middle of adjusting guard !isAdjustingHeader else { return } guard let headerView = tableView.tableHeaderView else { return } guard tableView.bounds.width > 0 else { return } // Calculate the ideal header size given the current table width let targetSize = CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height) let size = headerView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) // Guard 2: Only update if height changed meaningfully (>1pt) // This prevents jitter from sub-point differences between passes let heightDelta = abs(headerView.frame.size.height - size.height) if heightDelta > 1 && abs(size.height - lastHeaderHeight) > 1 { isAdjustingHeader = true lastHeaderHeight = size.height headerView.frame.size.height = size.height // Re-assigning tableHeaderView forces UITableView to recalculate layout tableView.tableHeaderView = headerView isAdjustingHeader = false } } /// Transforms hierarchical day data into a flat row list and refreshes the table. /// /// Delegates to `ItineraryReorderingLogic.flattenDays` for the pure transformation, /// then updates the table view. /// /// - Parameters: /// - days: Array of ItineraryDayData from ItineraryTableViewWrapper /// - travelValidRanges: Dictionary mapping travel IDs to their valid day ranges /// - itineraryItems: All ItineraryItem models for building constraints func reloadData( days: [ItineraryDayData], travelValidRanges: [String: ClosedRange], itineraryItems: [ItineraryItem] = [], travelSegmentIndices: [UUID: Int] = [:] ) { self.travelValidRanges = travelValidRanges self.allItineraryItems = itineraryItems self.travelSegmentIndices = travelSegmentIndices self.tripDayCount = days.count // Rebuild constraints with new data self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems) // Use pure function for flattening flatItems = ItineraryReorderingLogic.flattenDays(days) { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } tableView.reloadData() } // MARK: - Row-to-Day Mapping Helpers (delegating to pure functions) /// Finds which day a row at the given index belongs to. private func dayNumber(forRow row: Int) -> Int { ItineraryReorderingLogic.dayNumber(in: flatItems, forRow: row) } /// Finds the row index of the day header for a specific day number. private func dayHeaderRow(forDay day: Int) -> Int? { ItineraryReorderingLogic.dayHeaderRow(in: flatItems, forDay: day) } /// Finds the row index of the travel segment arriving on a specific day. private func travelRow(forDay day: Int) -> Int? { ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day) } // MARK: - Marketing Video Auto-Scroll #if DEBUG private var displayLink: CADisplayLink? private var scrollStartTime: CFTimeInterval = 0 private var scrollDuration: CFTimeInterval = 6.0 private var scrollStartOffset: CGFloat = 0 private var scrollEndOffset: CGFloat = 0 func scrollToBottomAnimated(delay: TimeInterval = 1.5, duration: TimeInterval = 6.0) { guard UserDefaults.standard.bool(forKey: "marketingVideoMode") else { return } self.scrollDuration = duration DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } let maxOffset = self.tableView.contentSize.height - self.tableView.bounds.height + self.tableView.contentInset.bottom guard maxOffset > 0 else { return } self.scrollStartOffset = self.tableView.contentOffset.y self.scrollEndOffset = maxOffset self.scrollStartTime = CACurrentMediaTime() let link = CADisplayLink(target: self, selector: #selector(self.marketingScrollTick)) link.add(to: .main, forMode: .common) self.displayLink = link } } @objc private func marketingScrollTick() { let elapsed = CACurrentMediaTime() - scrollStartTime let t = min(elapsed / scrollDuration, 1.0) // Ease-in-out curve let eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t let newOffset = scrollStartOffset + (scrollEndOffset - scrollStartOffset) * eased tableView.contentOffset = CGPoint(x: 0, y: newOffset) if t >= 1.0 { displayLink?.invalidate() displayLink = nil } } #endif // MARK: - Drag State Management // // These methods handle the start, update, and end of drag operations, // managing visual feedback (dimming invalid zones, highlighting barrier games) // and haptic feedback. /// Called when a drag operation begins. /// /// Performs the following setup: /// 1. Stores the dragging item /// 2. Calculates invalid row indices based on constraints /// 3. Identifies barrier games for visual highlighting (travel items only) /// 4. Triggers pickup haptic feedback /// 5. Applies visual dimming to invalid zones private func beginDrag(at indexPath: IndexPath) { let item = flatItems[indexPath.row] draggingItem = item isInValidZone = true // Calculate invalid zones and barriers based on item type switch item { case .travel(let segment, _): calculateTravelDragZones(segment: segment) case .customItem(let itineraryItem): calculateCustomItemDragZones(item: itineraryItem) default: // Day headers and games shouldn't be dragged invalidRowIndices = [] barrierGameIds = [] } // Trigger pickup haptic feedbackGenerator.prepare() feedbackGenerator.impactOccurred(intensity: 0.7) // Apply visual feedback applyDragVisualFeedback() } /// Called when a drag operation ends (item dropped). /// /// Clears all drag state and removes visual feedback: /// 1. Clears dragging item reference /// 2. Clears invalid row indices and barrier game IDs /// 3. Triggers drop haptic feedback /// 4. Removes visual dimming and highlighting private func endDrag() { draggingItem = nil dragTargetDay = nil invalidRowIndices = [] validDropRows = [] validDestinationRowsProposed = [] barrierGameIds = [] isInValidZone = true // Trigger drop haptic feedbackGenerator.impactOccurred(intensity: 0.5) // Remove visual feedback removeDragVisualFeedback() } /// Calculates invalid zones for a travel segment drag. /// Delegates to pure function and applies results to instance state. private func calculateTravelDragZones(segment: TravelSegment) { let sourceRow = flatItems.firstIndex { item in if case .travel(let rowSegment, _) = item { return rowSegment.id == segment.id } return false } ?? 0 let zones = ItineraryReorderingLogic.calculateTravelDragZones( segment: segment, sourceRow: sourceRow, flatItems: flatItems, travelValidRanges: travelValidRanges, constraints: constraints, findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }, makeTravelItem: { [weak self] segment in let segIdx = self?.travelSegmentIndices[segment.id] return ItineraryItem( tripId: self?.allItineraryItems.first?.tripId ?? UUID(), day: 1, sortOrder: 0, kind: .travel( TravelInfo( segment: segment, segmentIndex: segIdx ) ) ) }, findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } ) invalidRowIndices = zones.invalidRowIndices validDropRows = zones.validDropRows barrierGameIds = zones.barrierGameIds } /// Calculates invalid zones for a custom item drag. /// Delegates to pure function and applies results to instance state. private func calculateCustomItemDragZones(item: ItineraryItem) { let sourceRow = flatItems.firstIndex { row in if case .customItem(let current) = row { return current.id == item.id } return false } ?? 0 let zones = ItineraryReorderingLogic.calculateCustomItemDragZones( item: item, sourceRow: sourceRow, flatItems: flatItems, constraints: constraints, findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } ) invalidRowIndices = zones.invalidRowIndices validDropRows = zones.validDropRows barrierGameIds = zones.barrierGameIds } /// Finds the ItineraryItem model for a travel segment. /// /// Searches through allItineraryItems to find a matching travel item. /// Prefers matching by segmentIndex for disambiguation of repeat city pairs. private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? { if let segIdx = travelSegmentIndices[segment.id] { if let exact = allItineraryItems.first(where: { item in guard case .travel(let info) = item.kind else { return false } return info.segmentIndex == segIdx }) { return exact } } // Find all matching travel items by city pair let matches = allItineraryItems.filter { item in guard case .travel(let info) = item.kind else { return false } return info.matches(segment: segment) } // If only one match, return it if matches.count <= 1 { return matches.first } if let segIdx = travelSegmentIndices[segment.id] { if let indexed = matches.first(where: { $0.travelInfo?.segmentIndex == segIdx }) { return indexed } } return matches.first(where: { $0.travelInfo?.segmentIndex != nil }) ?? matches.first } /// Applies visual feedback during drag. /// /// - Invalid zones: Dimmed with alpha 0.3 /// - Barrier games: Highlighted with gold border private func applyDragVisualFeedback() { for (index, _) in flatItems.enumerated() { guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue } if invalidRowIndices.contains(index) { // Dim invalid rows UIView.animate(withDuration: 0.2) { cell.contentView.alpha = 0.3 } } // Check if this row contains a barrier game if case .games(let games, _) = flatItems[index] { let gameIds = games.map { $0.game.id } let hasBarrier = gameIds.contains { barrierGameIds.contains($0) } if hasBarrier { // Apply gold border to barrier game cells UIView.animate(withDuration: 0.2) { cell.contentView.layer.borderWidth = 2 cell.contentView.layer.borderColor = UIColor.systemYellow.cgColor cell.contentView.layer.cornerRadius = 12 } } } } } /// Removes visual feedback after drag ends. private func removeDragVisualFeedback() { for (index, _) in flatItems.enumerated() { guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue } UIView.animate(withDuration: 0.2) { cell.contentView.alpha = 1.0 cell.contentView.layer.borderWidth = 0 cell.contentView.layer.borderColor = nil } } } /// Called during drag to check if the hover position changed validity. /// /// Triggers haptic feedback when transitioning between valid/invalid zones. private func checkZoneTransition(at proposedRow: Int) { let isValid = !invalidRowIndices.contains(proposedRow) if isValid != isInValidZone { isInValidZone = isValid if isValid { // Entering valid zone - soft haptic let lightGenerator = UIImpactFeedbackGenerator(style: .light) lightGenerator.impactOccurred() } else { // Entering invalid zone - error haptic let errorGenerator = UINotificationFeedbackGenerator() errorGenerator.notificationOccurred(.warning) } } } // MARK: - UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { // We use a single section with a flat list. This simplifies reordering logic // since we don't need to handle cross-section moves. return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return flatItems.count } /// Configures and returns the cell for a row. /// /// Each row type dispatches to a specific configure method. We use UIHostingConfiguration /// to embed SwiftUI views in UITableViewCells, getting the best of both worlds: /// - UIKit's mature drag-and-drop with real-time feedback /// - SwiftUI's declarative, easy-to-style views /// /// Note: We completely replace the cell's contentConfiguration each time. This means /// cell reuse is less important than in traditional UIKit - the SwiftUI view is /// recreated regardless. This is fine for our use case (typically <50 rows). override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item = flatItems[indexPath.row] switch item { case .dayHeader(let dayNumber, let date): let cell = tableView.dequeueReusableCell(withIdentifier: dayHeaderCellId, for: indexPath) configureDayHeaderCell(cell, dayNumber: dayNumber, date: date) return cell case .games(let games, _): let cell = tableView.dequeueReusableCell(withIdentifier: gamesCellId, for: indexPath) configureGamesCell(cell, games: games) return cell case .travel(let segment, _): let cell = tableView.dequeueReusableCell(withIdentifier: travelCellId, for: indexPath) configureTravelCell(cell, segment: segment) return cell case .customItem(let customItem): let cell = tableView.dequeueReusableCell(withIdentifier: customItemCellId, for: indexPath) configureCustomItemCell(cell, item: customItem) return cell } } // MARK: - Reordering (Drag and Drop) /// Controls which rows show the drag reorder handle. /// /// Returns `isReorderable` from the row item: /// - `true` for travel segments and custom items (user can move these) /// - `false` for day headers and games (structural items) override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { return flatItems[indexPath.row].isReorderable } /// Called AFTER the user lifts their finger to complete a drag. /// /// At this point, UITableView has already visually moved the row. Our job is to: /// 1. Update our data model (`flatItems`) to reflect the new position /// 2. Notify the parent view via callbacks so it can persist the change /// 3. Clear drag state and remove visual feedback /// /// **For travel segments:** We notify `onTravelMoved` with the travel ID and new day. /// The parent stores this in `travelDayOverrides` (not persisted to CloudKit). /// /// **For custom items:** We notify `onCustomItemMoved` with the item ID, new day, /// and calculated sortOrder. The parent updates the item and syncs to CloudKit. override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { let item = flatItems[sourceIndexPath.row] // End drag state and remove visual feedback endDrag() // Update our in-memory data model flatItems.remove(at: sourceIndexPath.row) flatItems.insert(item, at: destinationIndexPath.row) // Notify parent view of the change switch item { case .travel(let segment, _): // Travel is positioned within a day using sortOrder (can be before/after games) let destinationDay = dayNumber(forRow: destinationIndexPath.row) let sortOrder = calculateSortOrder(at: destinationIndexPath.row) let segIdx = travelSegmentIndices[segment.id] ?? findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0 let from = TravelInfo.normalizeCityName(segment.fromLocation.name) let to = TravelInfo.normalizeCityName(segment.toLocation.name) let travelId = "travel:\(segIdx):\(from)->\(to)" onTravelMoved?(travelId, destinationDay, sortOrder) case .customItem(let customItem): // Calculate the new day and sortOrder for the dropped position let destinationDay = dayNumber(forRow: destinationIndexPath.row) let sortOrder = calculateSortOrder(at: destinationIndexPath.row) // DEBUG: Log the final state after insertion #if DEBUG print("🎯 [Drop] source=\(sourceIndexPath.row) → dest=\(destinationIndexPath.row)") print("🎯 [Drop] flatItems around dest:") for i in max(0, destinationIndexPath.row - 2)...min(flatItems.count - 1, destinationIndexPath.row + 2) { let marker = i == destinationIndexPath.row ? "→" : " " print("🎯 [Drop] \(marker) [\(i)] \(flatItems[i])") } print("🎯 [Drop] Calculated day=\(destinationDay), sortOrder=\(sortOrder)") #endif onCustomItemMoved?(customItem.id, destinationDay, sortOrder) default: break // Day headers, games, add buttons can't be moved } } /// Determines which day a travel segment belongs to at a given row position. private func dayForTravelAt(row: Int) -> Int { ItineraryReorderingLogic.dayForTravelAt(row: row, in: flatItems) } /// Called DURING a drag to validate and potentially modify the drop position. /// /// Delegates constraint logic to pure functions, handles only UIKit-specific concerns: /// - Drag state initialization (first call) /// - Haptic/visual feedback /// - Converting pure function results to IndexPath /// /// - Parameters: /// - sourceIndexPath: Where the item is being dragged FROM /// - proposedDestinationIndexPath: Where the user is trying to drop /// - Returns: The actual destination (may differ from proposed) override func tableView( _ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath ) -> IndexPath { let sourceRow = sourceIndexPath.row let item = flatItems[sourceRow] // Drag start detection - initialize state and compute valid destinations if draggingItem == nil { beginDrag(at: sourceIndexPath) validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item) } // Clamp proposed row var proposedRow = proposedDestinationIndexPath.row proposedRow = min(max(1, proposedRow), max(0, flatItems.count - 1)) // Haptics / visuals checkZoneTransition(at: proposedRow) // Use pure function for target calculation let targetRow = ItineraryReorderingLogic.calculateTargetRow( proposedRow: proposedRow, validDestinationRows: validDestinationRowsProposed, sourceRow: sourceRow ) return IndexPath(row: targetRow, section: 0) } // MARK: - Drag Destination Precomputation (delegating to pure functions) /// Computes all valid destination rows in **proposed** coordinate space. /// Delegates to pure function with closures for model lookups. private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] { ItineraryReorderingLogic.computeValidDestinationRowsProposed( flatItems: flatItems, sourceRow: sourceRow, dragged: dragged, travelValidRanges: travelValidRanges, constraints: constraints, findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }, makeTravelItem: { [weak self] segment in let segIdx = self?.travelSegmentIndices[segment.id] return ItineraryItem( tripId: self?.allItineraryItems.first?.tripId ?? UUID(), day: 1, sortOrder: 0, kind: .travel(TravelInfo( segment: segment, segmentIndex: segIdx, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds )) ) }, findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } ) } // MARK: - Editing Style Configuration /// Returns `.none` to hide the red delete circle that normally appears in editing mode. /// /// We have `tableView.isEditing = true` to enable drag handles, but we don't want /// the standard "swipe to delete" UI. Returning `.none` hides delete controls. /// (Deletion is handled via context menu instead.) override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { return .none } /// Returns `false` to prevent cells from indenting when in editing mode. /// /// By default, editing mode indents cells to make room for delete controls. /// Since we're not using those controls, we disable indentation to keep /// our custom card layouts flush with the edges. override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { return false } // MARK: - Row Selection /// Handles taps on rows. /// /// We only respond to taps on: /// - **Custom items:** Opens edit sheet via `onCustomItemTapped` /// /// Day headers (with embedded Add button), games, and travel don't respond to row taps. /// The Add button within the day header handles its own tap via SwiftUI Button. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // Immediately deselect to remove highlight tableView.deselectRow(at: indexPath, animated: true) let item = flatItems[indexPath.row] switch item { case .customItem(let customItem): onCustomItemTapped?(customItem) default: break // Other row types don't respond to row taps } } // MARK: - Context Menu (Long Press) /// Provides context menu for long-press on custom items. /// /// Only custom items have a context menu (Edit/Delete). Other row types /// return nil, meaning no menu appears on long-press. /// /// We use UIKit context menus rather than SwiftUI's `.contextMenu()` because /// they're more reliable inside UITableViewCells and provide better preview behavior. override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let item = flatItems[indexPath.row] // Only custom items have context menus guard case .customItem(let customItem) = item else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in let editAction = UIAction(title: "Edit", image: UIImage(systemName: "pencil")) { [weak self] _ in self?.onCustomItemTapped?(customItem) } let deleteAction = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.onCustomItemDeleted?(customItem) } return UIMenu(title: "", children: [editAction, deleteAction]) } } // MARK: - Sort Order Calculation /// Calculates the sortOrder for an item dropped at the given row position. /// Delegates to pure function with closure for travel sortOrder lookup. private func calculateSortOrder(at row: Int) -> Double { ItineraryReorderingLogic.calculateSortOrder( in: flatItems, at: row, findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder } ) } // MARK: - Cell Configuration // // Each configure method sets up a UITableViewCell with: // 1. UIHostingConfiguration wrapping a SwiftUI view // 2. .margins(.all, 0) to remove default content margins // 3. .background(.clear) to let parent background show through // 4. cell.backgroundColor = .clear for the same reason // 5. cell.selectionStyle based on whether the row is tappable /// Day header cell - shows "Day N · Date" text with embedded Add button. /// The Add button handles its own tap via SwiftUI Button (not row selection). private func configureDayHeaderCell(_ cell: UITableViewCell, dayNumber: Int, date: Date) { cell.contentConfiguration = UIHostingConfiguration { DaySectionHeaderView( dayNumber: dayNumber, date: date, colorScheme: colorScheme, onAddTapped: { [weak self] in self?.onAddButtonTapped?(dayNumber) } ) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .none // Row itself isn't selectable; Add button handles taps } /// Games cell - shows city label and game cards for a day. /// Not selectable (games are informational, not editable). private func configureGamesCell(_ cell: UITableViewCell, games: [RichGame]) { cell.contentConfiguration = UIHostingConfiguration { GamesRowView(games: games, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .none } /// Travel cell - shows route card (from → to with distance/duration). /// Not selectable but IS draggable (handled by canMoveRowAt). private func configureTravelCell(_ cell: UITableViewCell, segment: TravelSegment) { cell.contentConfiguration = UIHostingConfiguration { TravelRowView(segment: segment, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .none } /// Custom item cell - shows user-added item with category icon. /// Selectable (opens edit sheet on tap) and draggable. private func configureCustomItemCell(_ cell: UITableViewCell, item: ItineraryItem) { cell.contentConfiguration = UIHostingConfiguration { CustomItemRowView(item: item, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .default // Shows highlight on tap cell.accessibilityIdentifier = "tripDetail.customItem" } } // MARK: - SwiftUI Row Views // // These views are embedded in UITableViewCells via UIHostingConfiguration. // They're simple, stateless views - all state management happens in the parent. // // Design notes: // - Use Theme colors for consistency with the rest of the app // - Pass colorScheme explicitly (UIHostingConfiguration doesn't inherit environment) // - Padding is handled by the views themselves (cell margins are set to 0) /// Day header row - displays "Day N · Date" with embedded Add button. /// /// The Add button is part of the header row to prevent items from being /// dragged between the header and the Add button. This ensures "Day N" and /// "+ Add" always stay together as an atomic unit. /// /// Layout: /// ``` /// Day 3 · Sunday, Mar 8 + Add /// ``` struct DaySectionHeaderView: View { let dayNumber: Int let date: Date let colorScheme: ColorScheme let onAddTapped: () -> Void private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "EEEE, MMM d" // "Sunday, Mar 8" return formatter.string(from: date) } var body: some View { HStack(alignment: .firstTextBaseline) { // Day label and date Text("Day \(dayNumber)") .font(.title3) .fontWeight(.bold) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("·") .foregroundStyle(Theme.textMuted(colorScheme)) Text(formattedDate) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() // Add button (right-aligned, bordered capsule for discoverability) Button(action: onAddTapped) { Label("Add", systemImage: "plus") .font(.subheadline.weight(.medium)) } .buttonStyle(.bordered) .buttonBorderShape(.capsule) .tint(Theme.warmOrange) .accessibilityLabel("Add item to Day \(dayNumber)") .accessibilityHint("Add restaurants, activities, or notes to this day") .accessibilityIdentifier("tripDetail.addItemButton") } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.lg) // More space above for section separation .padding(.bottom, Theme.Spacing.sm) } } /// Games row - displays all games for a day with city label. /// /// Games are bundled into a single row (not individually reorderable) because: /// 1. Game times are fixed by schedules - reordering would be meaningless /// 2. Users don't control which games are in the trip - that's the planning engine /// 3. Keeping them together reinforces "these are your games for this day" /// /// The city label appears above the game cards (e.g., "Milwaukee" above the Bucks game). struct GamesRowView: View { let games: [RichGame] let colorScheme: ColorScheme var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { // City label (derived from first game's stadium) // All games on same day are typically in the same city if let firstGame = games.first { Text(firstGame.stadium.city) .font(.subheadline) .foregroundStyle(Theme.warmOrange) .padding(.horizontal, Theme.Spacing.lg) } // Individual game cards ForEach(games, id: \.game.id) { richGame in GameRowCompact(richGame: richGame, colorScheme: colorScheme) } } .padding(.horizontal, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.xs) } } /// Individual game card within GamesRowView. /// /// Shows sport-colored accent bar, sport badge, matchup, stadium, and time. /// Uses elevated card background to distinguish from surrounding content. struct GameRowCompact: View { let richGame: RichGame let colorScheme: ColorScheme private var formattedTime: String { richGame.localGameTimeShort // Stadium local time } var body: some View { HStack(spacing: Theme.Spacing.md) { // Left accent bar in sport's color (NBA=orange, MLB=blue, etc.) RoundedRectangle(cornerRadius: 3) .fill(richGame.game.sport.color) .frame(width: 5) VStack(alignment: .leading, spacing: 6) { // Sport badge (e.g., "NBA") Text(richGame.game.sport.rawValue) .font(.caption) .fontWeight(.bold) .foregroundStyle(richGame.game.sport.color) // Matchup (e.g., "ORL @ MIL") Text(richGame.matchupDescription) .font(.body) .fontWeight(.semibold) .foregroundStyle(Theme.textPrimary(colorScheme)) // Stadium name with icon HStack(spacing: 6) { Image(systemName: "building.2") .font(.caption) .accessibilityHidden(true) Text(richGame.stadium.name) .font(.subheadline) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() // Game time (prominently displayed) Text(formattedTime) .font(.title3) .fontWeight(.medium) .foregroundStyle(Theme.warmOrange) // Map button to open stadium location Button { AppleMapsLauncher.openLocation( coordinate: richGame.stadium.coordinate, name: richGame.stadium.name ) } label: { Image(systemName: "map") .font(.body) .foregroundStyle(Theme.warmOrange) .padding(8) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel("Open \(richGame.stadium.name) in Maps") .accessibilityHint("Opens this stadium location in Apple Maps") } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } /// Travel row - displays a travel segment (driving between cities). /// /// Shows: /// - Car icon in orange circle /// - From city → To city vertically stacked /// - Distance and duration between them /// /// This row IS draggable (has drag handle) and can be moved within its valid /// day range. Moving travel updates where it appears in the itinerary but /// doesn't change the actual route. struct TravelRowView: View { let segment: TravelSegment let colorScheme: ColorScheme var body: some View { HStack(spacing: Theme.Spacing.md) { // Car icon in subtle orange circle ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: "car.fill") .font(.body) .foregroundStyle(Theme.warmOrange) } // Route details: From → (distance/duration) → To VStack(alignment: .leading, spacing: 4) { Text(segment.fromLocation.name) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) // Distance and duration on the connecting line HStack(spacing: 4) { Image(systemName: "arrow.down") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) Text("\(segment.formattedDistance) · \(segment.formattedDuration)") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } Text(segment.toLocation.name) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) } Spacer() // Map button to open driving directions if let fromCoord = segment.fromLocation.coordinate, let toCoord = segment.toLocation.coordinate { Button { AppleMapsLauncher.openDirections( from: fromCoord, fromName: segment.fromLocation.name, to: toCoord, toName: segment.toLocation.name ) } label: { Image(systemName: "map") .font(.body) .foregroundStyle(Theme.warmOrange) .padding(8) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel("Get directions from \(segment.fromLocation.name) to \(segment.toLocation.name)") .accessibilityHint("Opens this stadium location in Apple Maps") } } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, Theme.Spacing.lg) .padding(.vertical, Theme.Spacing.xs) } } /// Custom item row - displays a user-added itinerary item. /// /// Shows: /// - Category emoji icon (🍽️, 🎢, ⛽, etc.) /// - Title with optional map pin indicator /// - Address (if item has location) /// - Chevron indicating tappable /// /// Visual treatment uses a subtle orange tint to distinguish user-added items /// from trip-determined content (games, travel). /// /// This row is both tappable (opens edit sheet) and draggable. struct CustomItemRowView: View { let item: ItineraryItem let colorScheme: ColorScheme private var customInfo: CustomInfo? { item.customInfo } var body: some View { HStack(spacing: Theme.Spacing.sm) { // Category icon (emoji) if let info = customInfo { Text(info.icon) .font(.title3) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(info.title) .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) // Map pin indicator for items with coordinates if info.isMappable { Image(systemName: "mappin.circle.fill") .font(.caption) .foregroundStyle(Theme.warmOrange) .accessibilityHidden(true) } } // Address subtitle (shown only if present) if let address = info.address, !address.isEmpty { Text(address) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .lineLimit(1) } } } Spacer() // Map button for items with GPS coordinates if let info = customInfo, let coordinate = info.coordinate { Button { AppleMapsLauncher.openLocation( coordinate: coordinate, name: info.title ) } label: { Image(systemName: "map") .font(.subheadline) .foregroundStyle(Theme.warmOrange) .padding(6) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel("Open \(info.title) in Maps") .accessibilityHint("Opens this stadium location in Apple Maps") } // Chevron indicates this is tappable Image(systemName: "chevron.right") .foregroundStyle(.tertiary) .font(.caption) .accessibilityHidden(true) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) // Layered background: base card + orange tint .background { RoundedRectangle(cornerRadius: 8) .fill(Theme.cardBackground(colorScheme)) RoundedRectangle(cornerRadius: 8) .fill(Theme.warmOrange.opacity(0.1)) } // Subtle orange border for extra distinction .overlay { RoundedRectangle(cornerRadius: 8) .strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1) } .padding(.horizontal, Theme.Spacing.lg) .padding(.vertical, Theme.Spacing.xs) } }