// // 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: ((CustomItineraryItem) -> Void)? // - Called when user taps custom item row // - Parent presents edit sheet // // onCustomItemDeleted: ((CustomItineraryItem) -> 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 // - CustomItineraryItem.swift: Domain model with (day, sortOrder) positioning // - CustomItemService.swift: CloudKit persistence for custom 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:detroit->milwaukee" (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(CustomItineraryItem) // Reorderable: anywhere /// Stable identifier for table view diffing and external references. /// Travel IDs are lowercase to ensure consistency across sessions. var id: String { switch self { case .dayHeader(let dayNumber, _): return "day:\(dayNumber)" case .games(_, let dayNumber): return "games:\(dayNumber)" case .travel(let segment, _): // Lowercase ensures stable ID regardless of display capitalization return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" 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 var flatItems: [ItineraryRowItem] = [] var travelValidRanges: [String: ClosedRange] = [:] // travelId -> valid day range var colorScheme: ColorScheme = .dark // Callbacks var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemTapped: ((CustomItineraryItem) -> Void)? var onCustomItemDeleted: ((CustomItineraryItem) -> 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: - 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. /// /// This is the core data transformation method. It takes structured `[ItineraryDayData]` /// from the wrapper and flattens it into `[ItineraryRowItem]` for UITableView display. /// /// **Flattening Algorithm:** /// For each day, rows are added in this exact order: /// 1. Travel (if arriving this day) - appears visually BEFORE the day header /// 2. Day header (with Add button) - "Day N · Date" + tappable Add button /// 3. Games - all games for this day (grouped as one row) /// 4. Custom items - user-added items, already sorted by sortOrder /// /// **Why this order matters:** /// - Travel before header creates visual grouping: "you travel, then you're on day N" /// - Add button is part of header row (can't drag items between header and Add) /// - Games before custom items preserves the "trip-determined, then user-added" hierarchy /// /// - Parameters: /// - days: Array of ItineraryDayData from ItineraryTableViewWrapper /// - travelValidRanges: Dictionary mapping travel IDs to their valid day ranges func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange]) { self.travelValidRanges = travelValidRanges flatItems = [] for day in days { // 1. Travel that arrives on this day (renders BEFORE the day header) // Example: "Detroit → Milwaukee" appears above "Day 3" header if let travel = day.travelBefore { flatItems.append(.travel(travel, dayNumber: day.dayNumber)) } // 2. Day header with Add button (structural anchor - cannot be moved or deleted) // Add button is embedded in the header to prevent items being dragged between them flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) // 3. Games for this day (bundled as one row, not individually reorderable) // Games are determined by the trip planning engine, not user-movable if !day.games.isEmpty { flatItems.append(.games(day.games, dayNumber: day.dayNumber)) } // 4. Custom items (user-added, already sorted by sortOrder in day.items) // We filter because day.items may contain other row types from wrapper for item in day.items { if case .customItem = item { flatItems.append(item) } } } tableView.reloadData() } // MARK: - Row-to-Day Mapping Helpers /// Finds which day a row at the given index belongs to. /// /// Scans backwards from the row to find either: /// - A `.dayHeader` → that's the day /// - A `.travel` → uses the dayNumber stored in the travel item /// /// This is used when a custom item is dropped to determine its new day. private func dayNumber(forRow row: Int) -> Int { for i in stride(from: row, through: 0, by: -1) { if case .dayHeader(let dayNum, _) = flatItems[i] { return dayNum } // Travel stores its destination day, so if we hit travel first, // we're conceptually still in that travel's destination day if case .travel(_, let dayNum) = flatItems[i] { return dayNum } } return 1 // Fallback to day 1 if no header found (shouldn't happen) } /// Finds the row index of the day header for a specific day number. /// Returns nil if no header exists for that day (shouldn't happen in valid data). private func dayHeaderRow(forDay day: Int) -> Int? { for (index, item) in flatItems.enumerated() { if case .dayHeader(let dayNum, _) = item, dayNum == day { return index } } return nil } /// Finds the row index of the travel segment arriving on a specific day. /// Returns nil if no travel arrives on that day. private func travelRow(forDay day: Int) -> Int? { for (index, item) in flatItems.enumerated() { if case .travel(_, let dayNum) = item, dayNum == day { return index } } return nil } // 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 /// /// **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] // 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's "day" is the day it arrives on (the next day header after its position) let newDay = dayForTravelAt(row: destinationIndexPath.row) let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" onTravelMoved?(travelId, newDay) 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) 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. /// /// Travel conceptually "arrives on" a day - it appears BEFORE that day's header. /// So we scan FORWARD from the travel's position to find the next day header. /// /// Example: /// ``` /// [0] Travel: Detroit → Milwaukee ← If travel is here... /// [1] Day 3 header ← ...it belongs to Day 3 /// ``` private func dayForTravelAt(row: Int) -> Int { // Scan forward to find the day header this travel precedes for i in row.. IndexPath { let item = flatItems[sourceIndexPath.row] var proposedRow = proposedDestinationIndexPath.row // Global constraint: can't move to position 0 (before all content) if proposedRow == 0 { proposedRow = 1 } // Ensure within bounds proposedRow = min(proposedRow, flatItems.count - 1) switch item { case .travel(let segment, _): // TRAVEL CONSTRAINT LOGIC // Travel can only be on certain days (must finish games in departure city, // must arrive by game time in destination city) let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" guard let validRange = travelValidRanges[travelId] else { print("⚠️ No valid range for travel: \(travelId)") return proposedDestinationIndexPath } // Figure out which day the user is trying to drop onto var proposedDay = dayForTravelAtProposed(row: proposedRow, excluding: sourceIndexPath.row) print("🎯 Drag travel: proposedRow=\(proposedRow), proposedDay=\(proposedDay), validRange=\(validRange)") // Clamp to valid range - this is what creates the "snap" effect if proposedDay < validRange.lowerBound { print("🎯 Clamping up: \(proposedDay) -> \(validRange.lowerBound)") proposedDay = validRange.lowerBound } else if proposedDay > validRange.upperBound { print("🎯 Clamping down: \(proposedDay) -> \(validRange.upperBound)") proposedDay = validRange.upperBound } // Convert day number back to row position (right before that day's header) if let headerRow = dayHeaderRow(forDay: proposedDay) { var targetRow = headerRow // Account for the fact that the source row will be removed first // This is a UITableView quirk - the destination index is calculated // as if the source has already been removed if sourceIndexPath.row < headerRow { targetRow -= 1 } print("🎯 Final target: day=\(proposedDay), headerRow=\(headerRow), targetRow=\(targetRow)") return IndexPath(row: max(0, targetRow), section: 0) } return proposedDestinationIndexPath case .customItem: // CUSTOM ITEM CONSTRAINT LOGIC // Custom items are flexible - they can go anywhere within the itinerary, // but we prevent dropping in places that would be confusing // Don't drop ON a day header - go after it instead if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] { return IndexPath(row: proposedRow + 1, section: 0) } // Don't drop before travel at the start of a day // (would visually appear before the travel card, which is confusing) if proposedRow < flatItems.count, case .travel = flatItems[proposedRow] { // Find the day header after this travel and drop after the header for i in proposedRow.. Int { // Scan forward, skipping the item being moved for i in row.. 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. /// /// Uses **midpoint insertion** algorithm to avoid renumbering existing items: /// /// ``` /// Existing items: A (sortOrder: 1.0) B (sortOrder: 2.0) /// Drop between: A ← DROP HERE → B /// New sortOrder: 1.5 (midpoint of 1.0 and 2.0) /// ``` /// /// **Edge cases:** /// - First item in empty day: sortOrder = 1.0 /// - After last item: sortOrder = last + 1.0 /// - Before first item: sortOrder = first / 2.0 /// /// **Precision:** Double has ~15 significant digits. Even with millions of midpoint /// insertions, precision remains sufficient. Example worst case: /// - 50 insertions between 1.0 and 2.0: sortOrder ≈ 1.0000000000000009 /// - Still distinguishable and orderable /// /// **Scanning logic:** We scan backwards and forwards from the drop position /// to find adjacent custom items, stopping at day boundaries (headers, travel). private func calculateSortOrder(at row: Int) -> Double { var prevSortOrder: Double? var nextSortOrder: Double? // SCAN BACKWARDS to find previous custom item in this day for i in stride(from: row - 1, through: 0, by: -1) { switch flatItems[i] { case .customItem(let item): // Found a custom item - use its sortOrder prevSortOrder = item.sortOrder case .dayHeader, .travel: // Hit a day boundary - no previous custom item in this day break case .games: // Skip non-custom, non-boundary items continue } // Stop scanning once we found an item or hit a boundary if prevSortOrder != nil { break } if case .dayHeader = flatItems[i] { break } if case .travel = flatItems[i] { break } } // SCAN FORWARDS to find next custom item in this day for i in row.. 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) Button(action: onAddTapped) { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "plus.circle.fill") .foregroundStyle(Theme.warmOrange.opacity(0.6)) Text("Add") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } } .buttonStyle(.plain) } .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 { let formatter = DateFormatter() formatter.dateFormat = "h:mm a" // "8:00 PM" return formatter.string(from: richGame.game.dateTime) } 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) Text(richGame.stadium.name) .font(.subheadline) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() // Game time (prominently displayed) Text(formattedTime) .font(.title3) .fontWeight(.medium) .foregroundStyle(Theme.warmOrange) } .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() } .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: CustomItineraryItem let colorScheme: ColorScheme var body: some View { HStack(spacing: Theme.Spacing.sm) { // Category icon (emoji) Text(item.category.icon) .font(.title3) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(item.title) .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) // Map pin indicator for items with coordinates if item.isMappable { Image(systemName: "mappin.circle.fill") .font(.caption) .foregroundStyle(Theme.warmOrange) } } // Address subtitle (shown only if present) if let address = item.address, !address.isEmpty { Text(address) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .lineLimit(1) } } Spacer() // Chevron indicates this is tappable Image(systemName: "chevron.right") .foregroundStyle(.tertiary) .font(.caption) } .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) } }