diff --git a/Scripts/.claude/settings.local.json b/Scripts/.claude/settings.local.json index 6998ca7..b5df25f 100644 --- a/Scripts/.claude/settings.local.json +++ b/Scripts/.claude/settings.local.json @@ -2,7 +2,12 @@ "permissions": { "allow": [ "Skill(superpowers:brainstorming)", - "Skill(superpowers:writing-plans)" + "Skill(superpowers:writing-plans)", + "Skill(superpowers:using-git-worktrees)", + "Skill(superpowers:subagent-driven-development)", + "Bash(git add:*)", + "Bash(git commit:*)", + "WebSearch" ] } } diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index b09f569..865eff3 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -231,7 +231,7 @@ struct ItineraryDayData: Identifiable { 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 } } @@ -251,7 +251,7 @@ enum ItineraryRowItem: Identifiable, Equatable { 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 are lowercase to ensure consistency across sessions. var id: String { @@ -267,7 +267,7 @@ enum ItineraryRowItem: Identifiable, Equatable { 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 { @@ -278,7 +278,7 @@ enum ItineraryRowItem: Identifiable, Equatable { return true } } - + static func == (lhs: ItineraryRowItem, rhs: ItineraryRowItem) -> Bool { lhs.id == rhs.id } @@ -287,73 +287,78 @@ enum ItineraryRowItem: Identifiable, Equatable { // 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 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] = [] - + /// 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: @@ -374,13 +379,13 @@ final class ItineraryTableViewController: UITableViewController { 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 @@ -390,7 +395,7 @@ final class ItineraryTableViewController: UITableViewController { 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 @@ -402,7 +407,7 @@ final class ItineraryTableViewController: UITableViewController { let containerView = UIView() containerView.backgroundColor = .clear containerView.addSubview(view) - + // Pin the content view to all edges of the container view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ @@ -411,10 +416,10 @@ final class ItineraryTableViewController: UITableViewController { 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 @@ -432,16 +437,16 @@ final class ItineraryTableViewController: UITableViewController { /// 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) @@ -454,7 +459,7 @@ final class ItineraryTableViewController: UITableViewController { 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]` @@ -484,43 +489,63 @@ final class ItineraryTableViewController: UITableViewController { self.travelValidRanges = travelValidRanges self.allItineraryItems = itineraryItems self.tripDayCount = days.count - + // Rebuild constraints with new data self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems) - + 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 + + // 3. Movable items (travel + custom) split around games boundary. + // Convention: sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games. + var beforeGames: [ItineraryRowItem] = [] + var afterGames: [ItineraryRowItem] = [] + + for row in day.items { + let so: Double? + switch row { + case .customItem(let item): + so = item.sortOrder + case .travel(let segment, _): + // Travel sortOrder is stored in itineraryItems (kind: .travel) + so = findItineraryItem(for: segment)?.sortOrder + default: + so = nil + } + + guard let sortOrder = so else { continue } + if sortOrder < 0 { + beforeGames.append(row) + } else { + afterGames.append(row) + } + } + + flatItems.append(contentsOf: beforeGames) + + // 4. Games for this day (bundled as one row, not individually reorderable) 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) - } - } + + flatItems.append(contentsOf: afterGames) } - + 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: @@ -533,15 +558,10 @@ final class ItineraryTableViewController: UITableViewController { 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) + return 1 } - + /// 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? { @@ -552,7 +572,7 @@ final class ItineraryTableViewController: UITableViewController { } 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? { @@ -563,13 +583,13 @@ final class ItineraryTableViewController: UITableViewController { } return nil } - + // 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: @@ -582,7 +602,7 @@ final class ItineraryTableViewController: UITableViewController { let item = flatItems[indexPath.row] draggingItem = item isInValidZone = true - + // Calculate invalid zones and barriers based on item type switch item { case .travel(let segment, _): @@ -594,15 +614,15 @@ final class ItineraryTableViewController: UITableViewController { 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: @@ -615,16 +635,17 @@ final class ItineraryTableViewController: UITableViewController { 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. /// /// Travel items have hard constraints: @@ -634,7 +655,7 @@ final class ItineraryTableViewController: UITableViewController { /// Invalid zones are any rows outside the valid day range. private func calculateTravelDragZones(segment: TravelSegment) { let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" - + // Get valid day range from pre-calculated ranges guard let validRange = travelValidRanges[travelId] else { invalidRowIndices = [] @@ -642,12 +663,12 @@ final class ItineraryTableViewController: UITableViewController { barrierGameIds = [] return } - + // Calculate invalid and valid row indices based on day range // Pre-calculate ALL valid positions for stable drag behavior var invalidRows = Set() var validRows: [Int] = [] - + for (index, rowItem) in flatItems.enumerated() { let dayNum: Int switch rowItem { @@ -660,17 +681,17 @@ final class ItineraryTableViewController: UITableViewController { case .customItem(let item): dayNum = item.day } - + if validRange.contains(dayNum) { validRows.append(index) } else { invalidRows.insert(index) } } - + invalidRowIndices = invalidRows validDropRows = validRows // Already sorted since we iterate in order - + // Find barrier games using ItineraryConstraints if let travelItem = findItineraryItem(for: segment), let constraints = constraints { @@ -680,7 +701,7 @@ final class ItineraryTableViewController: UITableViewController { barrierGameIds = [] } } - + /// Calculates invalid zones for a custom item drag. /// /// Custom items can go on any day, but we mark certain positions as @@ -690,7 +711,7 @@ final class ItineraryTableViewController: UITableViewController { // Pre-calculate ALL valid row indices for stable drag behavior var invalidRows = Set() var validRows: [Int] = [] - + for (index, rowItem) in flatItems.enumerated() { if case .dayHeader = rowItem { invalidRows.insert(index) @@ -699,12 +720,12 @@ final class ItineraryTableViewController: UITableViewController { validRows.append(index) } } - + invalidRowIndices = invalidRows validDropRows = validRows // Already sorted since we iterate in order barrierGameIds = [] // No barrier highlighting for custom items } - + /// Finds the ItineraryItem model for a travel segment. /// /// Searches through allItineraryItems to find a matching travel item @@ -713,10 +734,10 @@ final class ItineraryTableViewController: UITableViewController { return allItineraryItems.first { item in guard case .travel(let info) = item.kind else { return false } return info.fromCity.lowercased() == segment.fromLocation.name.lowercased() - && info.toCity.lowercased() == segment.toLocation.name.lowercased() + && info.toCity.lowercased() == segment.toLocation.name.lowercased() } } - + /// Applies visual feedback during drag. /// /// - Invalid zones: Dimmed with alpha 0.3 @@ -724,14 +745,14 @@ final class ItineraryTableViewController: UITableViewController { 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 } @@ -747,12 +768,12 @@ final class ItineraryTableViewController: UITableViewController { } } } - + /// 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 @@ -760,13 +781,13 @@ final class ItineraryTableViewController: UITableViewController { } } } - + /// 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 { @@ -780,19 +801,19 @@ final class ItineraryTableViewController: UITableViewController { } } } - + // 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 @@ -805,32 +826,32 @@ final class ItineraryTableViewController: UITableViewController { /// 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: @@ -839,7 +860,7 @@ final class ItineraryTableViewController: UITableViewController { 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: @@ -854,27 +875,28 @@ final class ItineraryTableViewController: UITableViewController { /// 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's "day" is the day it arrives on (the next day header after its position) - let newDay = dayForTravelAt(row: destinationIndexPath.row) + // 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 travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" - onTravelMoved?(travelId, newDay) - + 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 print("🎯 [Drop] source=\(sourceIndexPath.row) β†’ dest=\(destinationIndexPath.row)") print("🎯 [Drop] flatItems around dest:") @@ -883,14 +905,14 @@ final class ItineraryTableViewController: UITableViewController { print("🎯 [Drop] \(marker) [\(i)] \(flatItems[i])") } print("🎯 [Drop] Calculated day=\(destinationDay), sortOrder=\(sortOrder)") - + 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. @@ -916,7 +938,7 @@ final class ItineraryTableViewController: UITableViewController { } return 1 // Ultimate fallback } - + /// Called DURING a drag to validate and potentially modify the drop position. /// /// This is the core drag constraint logic. UITableView calls this continuously @@ -943,98 +965,245 @@ final class ItineraryTableViewController: UITableViewController { /// - 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 item = flatItems[sourceIndexPath.row] - var proposedRow = proposedDestinationIndexPath.row - - // DRAG START DETECTION - // The first call to this method indicates drag has started. - // Initialize drag state, calculate valid/invalid zones, and trigger pickup haptic. + override func tableView( + _ tableView: UITableView, + targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, + toProposedIndexPath proposedDestinationIndexPath: IndexPath + ) -> IndexPath { + + let sourceRow = sourceIndexPath.row + let item = flatItems[sourceRow] + + // Drag start detection if draggingItem == nil { beginDrag(at: sourceIndexPath) + validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item) } - - // 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) - - // Check for zone transition and trigger haptic feedback + + var proposedRow = proposedDestinationIndexPath.row + + // Avoid absolute top (keeps UX sane) + if proposedRow <= 0 { proposedRow = 1 } + + proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1)) + + // Haptics / visuals checkZoneTransition(at: proposedRow) - - switch item { - case .travel, .customItem: - // UNIFIED CONSTRAINT LOGIC using pre-calculated validDropRows - // This eliminates bouncing by using a simple lookup instead of recalculating - return snapToValidRow(proposedRow) - - default: - // Fixed items (shouldn't reach here since canMoveRowAt returns false) + + // If already valid, allow it. + if validDestinationRowsProposed.contains(proposedRow) { + return IndexPath(row: proposedRow, section: 0) + } + + // Snap to nearest valid destination (proposed coordinate space) + guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else { return sourceIndexPath } + return IndexPath(row: snapped, section: 0) } - - /// Snaps a proposed row to the nearest valid drop position. - /// - /// Uses pre-calculated `validDropRows` for O(log n) lookup. - /// If the proposed row is already valid, returns it immediately (prevents bouncing). - /// Otherwise, finds the nearest valid row using binary search. - private func snapToValidRow(_ proposedRow: Int) -> IndexPath { - // Fast path: if proposed row is valid, return it immediately - // This is the key to preventing bouncing - no recalculation needed - if validDropRows.contains(proposedRow) { - return IndexPath(row: proposedRow, section: 0) - } - - // Proposed row is invalid - find the nearest valid row - guard !validDropRows.isEmpty else { - return IndexPath(row: proposedRow, section: 0) - } - - // Binary search for insertion point + + // MARK: - Drag Destination Precomputation (semantic day + sortOrder) + + /// Nearest value in a sorted Int array to the target (binary search). + private func nearestValue(in sorted: [Int], to target: Int) -> Int? { + guard !sorted.isEmpty else { return nil } var low = 0 - var high = validDropRows.count - + var high = sorted.count while low < high { let mid = (low + high) / 2 - if validDropRows[mid] < proposedRow { - low = mid + 1 - } else { - high = mid + if sorted[mid] < target { low = mid + 1 } else { high = mid } + } + let after = (low < sorted.count) ? sorted[low] : nil + let before = (low > 0) ? sorted[low - 1] : nil + + switch (before, after) { + case let (b?, a?): + return (target - b) <= (a - target) ? b : a + case let (b?, nil): + return b + case let (nil, a?): + return a + default: + return nil + } + } + + /// Computes all valid destination rows in **proposed** coordinate space (UIKit's coordinate space during drag). + /// We simulate the move and validate using semantic constraints: (day, sortOrder). + private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] { + // Proposed rows are in the array AFTER removing the source row. + let maxProposed = max(0, flatItems.count - 1) + guard maxProposed > 0 else { return [] } + + switch dragged { + case .customItem: + // Custom items can go basically anywhere (including before headers = "between days"). + // Keep row 0 blocked. + return Array(1...maxProposed) + + case .travel(let segment, _): + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + let validDayRange = travelValidRanges[travelId] + + // Use existing itinerary model if available (for constraints) + let model: ItineraryItem = findItineraryItem(for: segment) ?? ItineraryItem( + tripId: allItineraryItems.first?.tripId ?? UUID(), + day: 1, + sortOrder: 0, + kind: .travel(TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds)) + ) + + guard let constraints else { + // If no constraint engine, allow all rows (except 0) + return Array(1...maxProposed) + } + + var valid: [Int] = [] + valid.reserveCapacity(maxProposed) + + for proposedRow in 1...maxProposed { + let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow) + let destRowInSim = simulated.destinationRowInNewArray + let day = dayNumber(in: simulated.items, forRow: destRowInSim) + if let r = validDayRange, !r.contains(day) { + continue + } + let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim) + if constraints.isValidPosition(for: model, day: day, sortOrder: sortOrder) { + valid.append(proposedRow) + } + } + + return valid + + default: + return [] + } + } + + private struct SimulatedMove { + let items: [ItineraryRowItem] + let destinationRowInNewArray: Int + } + + /// Simulate UITableView move semantics: remove at sourceRow from ORIGINAL array, then insert at destinationProposedRow + /// in the NEW array (post-removal coordinate space). + private func simulateMove(original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int) -> SimulatedMove { + var items = original + let moving = items.remove(at: sourceRow) + let clampedDest = min(max(0, destinationProposedRow), items.count) + items.insert(moving, at: clampedDest) + return SimulatedMove(items: items, destinationRowInNewArray: clampedDest) + } + + /// Day number lookup within an arbitrary flat array (used during simulation). + private func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int { + guard !items.isEmpty else { return 1 } + let clamped = min(max(0, row), items.count - 1) + for i in stride(from: clamped, through: 0, by: -1) { + if case .dayHeader(let dayNum, _) = items[i] { + return dayNum + } + } + return 1 + } + + /// Calculates sortOrder for insertion at a row within an arbitrary flat array. + /// Uses the same convention as the main function: + /// - sortOrder < 0 => above games + /// - sortOrder >= 0 => below games + private func calculateSortOrder(in items: [ItineraryRowItem], at row: Int) -> Double { + let day = dayNumber(in: items, forRow: row) + + // Find games row for this day in the provided items + var gamesRow: Int? = nil + for i in 0.. day { + break + } + } + + let isBeforeGames = (gamesRow != nil && row <= gamesRow!) + + func movableSortOrder(_ idx: Int) -> Double? { + guard idx >= 0 && idx < items.count else { return nil } + switch items[idx] { + case .customItem(let item): + return item.sortOrder + case .travel(let segment, _): + return findItineraryItem(for: segment)?.sortOrder + default: + return nil + } + } + + func scanBackward(from start: Int) -> Double? { + var i = start + while i >= 0 { + if case .dayHeader(let d, _) = items[i], d != day { break } + if case .dayHeader = items[i] { break } + if case .games(_, let d) = items[i], d == day { break } + if let v = movableSortOrder(i) { + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i -= 1 + } + return nil + } + + func scanForward(from start: Int) -> Double? { + var i = start + while i < items.count { + if case .dayHeader(let d, _) = items[i], d != day { break } + if case .dayHeader = items[i] { break } + if case .games(_, let d) = items[i], d == day { break } + if let v = movableSortOrder(i) { + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i += 1 + } + return nil + } + + if isBeforeGames { + let prev = scanBackward(from: row - 1) + let next = scanForward(from: row) + + let upperBound: Double = 0.0 + switch (prev, next) { + case (nil, nil): + return -1.0 + case (let p?, nil): + return (p + upperBound) / 2.0 + case (nil, let n?): + return n / 2.0 + case (let p?, let n?): + return (p + n) / 2.0 + } + } else { + let prev = scanBackward(from: row - 1) ?? 0.0 + let next = scanForward(from: row) + + switch next { + case nil: + return (prev == 0.0) ? 1.0 : (prev + 1.0) + case let n?: + return (prev + n) / 2.0 } } - - // low is now the insertion point - check neighbors to find nearest - let before = low > 0 ? validDropRows[low - 1] : nil - let after = low < validDropRows.count ? validDropRows[low] : nil - - let nearest: Int - if let b = before, let a = after { - // Both neighbors exist - pick the closer one - nearest = (proposedRow - b) <= (a - proposedRow) ? b : a - } else if let b = before { - nearest = b - } else if let a = after { - nearest = a - } else { - nearest = proposedRow // Fallback (shouldn't happen) - } - - return IndexPath(row: nearest, section: 0) } - - /// Calculates which day a travel segment would belong to if dropped at a proposed position. - /// - /// Similar to `dayForTravelAt`, but used during the drag (before the move completes). - /// Must exclude the item being dragged from the scan, since it will be removed - /// from its current position. - /// - /// - Parameters: - /// - row: The proposed drop position - /// - excluding: The source row to skip (the item being dragged) private func dayForTravelAtProposed(row: Int, excluding: Int) -> 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. @@ -1072,9 +1241,9 @@ final class ItineraryTableViewController: UITableViewController { override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { return false } - + // MARK: - Row Selection - + /// Handles taps on rows. /// /// We only respond to taps on: @@ -1085,20 +1254,20 @@ final class ItineraryTableViewController: UITableViewController { 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 @@ -1108,27 +1277,27 @@ final class ItineraryTableViewController: UITableViewController { /// 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: @@ -1152,60 +1321,98 @@ final class ItineraryTableViewController: UITableViewController { /// **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 + let day = dayNumber(forRow: row) + + // Find games row for this day (if any) + var gamesRow: Int? = nil + for i in 0.. day { break - case .games: - continue } - if nextSortOrder != nil { break } - if case .dayHeader = flatItems[i] { break } - if case .travel = flatItems[i] { break } } - - // CALCULATE sortOrder based on what we found - switch (prevSortOrder, nextSortOrder) { - case (nil, nil): - // No adjacent items - first item in this day - return 1.0 - case (let prev?, nil): - // After the last item - add 1.0 to create spacing - return prev + 1.0 - case (nil, let next?): - // Before the first item - halve to stay positive - return next / 2.0 - case (let prev?, let next?): - // Between two items - use exact midpoint - return (prev + next) / 2.0 + + let isBeforeGames = (gamesRow != nil && row <= gamesRow!) + + func movableSortOrder(_ idx: Int) -> Double? { + guard idx >= 0 && idx < flatItems.count else { return nil } + switch flatItems[idx] { + case .customItem(let item): + return item.sortOrder + case .travel(let segment, _): + return findItineraryItem(for: segment)?.sortOrder + default: + return nil + } + } + + func scanBackward(from start: Int) -> Double? { + var i = start + while i >= 0 { + if case .dayHeader(let d, _) = flatItems[i], d != day { break } + if case .dayHeader = flatItems[i] { break } + if case .games(_, let d) = flatItems[i], d == day { break } + if let v = movableSortOrder(i) { + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i -= 1 + } + return nil + } + + func scanForward(from start: Int) -> Double? { + var i = start + while i < flatItems.count { + if case .dayHeader(let d, _) = flatItems[i], d != day { break } + if case .dayHeader = flatItems[i] { break } + if case .games(_, let d) = flatItems[i], d == day { break } + if let v = movableSortOrder(i) { + if isBeforeGames { + if v < 0 { return v } + } else { + if v >= 0 { return v } + } + } + i += 1 + } + return nil + } + + if isBeforeGames { + let prev = scanBackward(from: row - 1) + let next = scanForward(from: row) + + let upperBound: Double = 0.0 // games boundary + switch (prev, next) { + case (nil, nil): + return -1.0 + case (let p?, nil): + return (p + upperBound) / 2.0 + case (nil, let n?): + return n / 2.0 + case (let p?, let n?): + return (p + n) / 2.0 + } + } else { + let prev = scanBackward(from: row - 1) ?? 0.0 + let next = scanForward(from: row) + + switch next { + case nil: + return (prev == 0.0) ? 1.0 : (prev + 1.0) + case let n?: + return (prev + n) / 2.0 + } } } - + // MARK: - Cell Configuration // // Each configure method sets up a UITableViewCell with: @@ -1214,7 +1421,7 @@ final class ItineraryTableViewController: UITableViewController { // 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) { @@ -1230,11 +1437,11 @@ final class ItineraryTableViewController: UITableViewController { } .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]) { @@ -1243,11 +1450,11 @@ final class ItineraryTableViewController: UITableViewController { } .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) { @@ -1256,11 +1463,11 @@ final class ItineraryTableViewController: UITableViewController { } .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) { @@ -1269,7 +1476,7 @@ final class ItineraryTableViewController: UITableViewController { } .margins(.all, 0) .background(.clear) - + cell.backgroundColor = .clear cell.selectionStyle = .default // Shows highlight on tap } @@ -1300,13 +1507,13 @@ struct DaySectionHeaderView: View { 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 @@ -1314,16 +1521,16 @@ struct DaySectionHeaderView: View { .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) { @@ -1353,7 +1560,7 @@ struct DaySectionHeaderView: View { 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) @@ -1364,7 +1571,7 @@ struct GamesRowView: View { .foregroundStyle(Theme.warmOrange) .padding(.horizontal, Theme.Spacing.lg) } - + // Individual game cards ForEach(games, id: \.game.id) { richGame in GameRowCompact(richGame: richGame, colorScheme: colorScheme) @@ -1382,33 +1589,33 @@ struct GamesRowView: View { 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") @@ -1418,9 +1625,9 @@ struct GameRowCompact: View { } .foregroundStyle(Theme.textSecondary(colorScheme)) } - + Spacer() - + // Game time (prominently displayed) Text(formattedTime) .font(.title3) @@ -1446,7 +1653,7 @@ struct GameRowCompact: View { struct TravelRowView: View { let segment: TravelSegment let colorScheme: ColorScheme - + var body: some View { HStack(spacing: Theme.Spacing.md) { // Car icon in subtle orange circle @@ -1454,36 +1661,36 @@ struct TravelRowView: View { 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) @@ -1509,25 +1716,25 @@ struct TravelRowView: View { 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") @@ -1535,7 +1742,7 @@ struct CustomItemRowView: View { .foregroundStyle(Theme.warmOrange) } } - + // Address subtitle (shown only if present) if let address = info.address, !address.isEmpty { Text(address) @@ -1545,9 +1752,9 @@ struct CustomItemRowView: View { } } } - + Spacer() - + // Chevron indicates this is tappable Image(systemName: "chevron.right") .foregroundStyle(.tertiary) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index 8bd5687..ae8791d 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -9,27 +9,27 @@ import SwiftUI struct ItineraryTableViewWrapper: UIViewControllerRepresentable { @Environment(\.colorScheme) private var colorScheme - + let trip: Trip let games: [RichGame] let itineraryItems: [ItineraryItem] - let travelDayOverrides: [String: Int] + let travelOverrides: [String: TravelOverride] let headerContent: HeaderContent - + // Callbacks - var onTravelMoved: ((String, Int) -> Void)? + var onTravelMoved: ((String, Int, Double) -> Void)? var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemTapped: ((ItineraryItem) -> Void)? var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number - + init( trip: Trip, games: [RichGame], itineraryItems: [ItineraryItem], - travelDayOverrides: [String: Int], + travelOverrides: [String: TravelOverride], @ViewBuilder headerContent: () -> HeaderContent, - onTravelMoved: ((String, Int) -> Void)? = nil, + onTravelMoved: ((String, Int, Double) -> Void)? = nil, onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil, onCustomItemTapped: ((ItineraryItem) -> Void)? = nil, onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil, @@ -38,7 +38,7 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent self.trip = trip self.games = games self.itineraryItems = itineraryItems - self.travelDayOverrides = travelDayOverrides + self.travelOverrides = travelOverrides self.headerContent = headerContent() self.onTravelMoved = onTravelMoved self.onCustomItemMoved = onCustomItemMoved @@ -46,15 +46,15 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent self.onCustomItemDeleted = onCustomItemDeleted self.onAddButtonTapped = onAddButtonTapped } - + func makeCoordinator() -> Coordinator { Coordinator() } - + class Coordinator { var headerHostingController: UIHostingController? } - + func makeUIViewController(context: Context) -> ItineraryTableViewController { let controller = ItineraryTableViewController(style: .plain) controller.colorScheme = colorScheme @@ -63,14 +63,14 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent controller.onCustomItemTapped = onCustomItemTapped controller.onCustomItemDeleted = onCustomItemDeleted controller.onAddButtonTapped = onAddButtonTapped - + // Set header with proper sizing let hostingController = UIHostingController(rootView: headerContent) hostingController.view.backgroundColor = .clear - + // Store in coordinator for later updates context.coordinator.headerHostingController = hostingController - + // Pre-size the header view hostingController.view.translatesAutoresizingMaskIntoConstraints = false let targetWidth = UIScreen.main.bounds.width @@ -78,16 +78,16 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent let size = hostingController.view.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) hostingController.view.frame = CGRect(origin: .zero, size: CGSize(width: targetWidth, height: max(size.height, 450))) hostingController.view.translatesAutoresizingMaskIntoConstraints = true - + controller.setTableHeader(hostingController.view) - + // Load initial data - let (days, validRanges) = buildItineraryData() - controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems) - + let (days, validRanges, allItemsForConstraints) = buildItineraryData() + controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) + return controller } - + func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) { controller.colorScheme = colorScheme controller.onTravelMoved = onTravelMoved @@ -95,125 +95,194 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent controller.onCustomItemTapped = onCustomItemTapped controller.onCustomItemDeleted = onCustomItemDeleted controller.onAddButtonTapped = onAddButtonTapped - + // Update header content by updating the hosting controller's rootView // This avoids recreating the view hierarchy and prevents infinite loops context.coordinator.headerHostingController?.rootView = headerContent - - let (days, validRanges) = buildItineraryData() - controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems) + + let (days, validRanges, allItemsForConstraints) = buildItineraryData() + controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints) } - + // MARK: - Build Itinerary Data - - private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange]) { + + private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange], [ItineraryItem]) { let tripDays = calculateTripDays() var travelValidRanges: [String: ClosedRange] = [:] - - // Pre-calculate travel segment placements - var travelByDay: [Int: TravelSegment] = [:] - + + // Build travel as semantic items with (day, sortOrder) + var travelItems: [ItineraryItem] = [] + travelItems.reserveCapacity(trip.travelSegments.count) + + func cityFromGameId(_ gameId: String) -> String? { + let comps = gameId.components(separatedBy: "-") + guard comps.count >= 2 else { return nil } + return comps[1] + } + + func gamesIn(city: String, day: Int) -> [ItineraryItem] { + itineraryItems.filter { item in + guard item.day == day else { return false } + guard case .game(let gid) = item.kind else { return false } + guard let c = cityFromGameId(gid) else { return false } + return cityMatches(c, searchCity: city) + } + } + for segment in trip.travelSegments { let travelId = stableTravelAnchorId(segment) let fromCity = segment.fromLocation.name let toCity = segment.toLocation.name - - // Calculate valid range - // Travel "on day N" appears BEFORE day N's header - // So minDay must be AFTER the last game day in departure city - let lastGameInFromCity = findLastGameDay(in: fromCity, tripDays: tripDays) - let firstGameInToCity = findFirstGameDay(in: toCity, tripDays: tripDays) - let minDay = max(lastGameInFromCity + 1, 1) // Day AFTER last game in from city - let maxDay = min(firstGameInToCity, tripDays.count) // Can arrive same day as first game - let validRange = minDay <= maxDay ? minDay...maxDay : maxDay...maxDay - + + // VALID RANGE: + // - Earliest: day of last from-city game (travel can happen AFTER that game) + // - Latest: day of first to-city game (travel can happen BEFORE that game) + let lastFromGameDay = findLastGameDay(in: fromCity, tripDays: tripDays) + let firstToGameDay = findFirstGameDay(in: toCity, tripDays: tripDays) + + let minDay = max(lastFromGameDay == 0 ? 1 : lastFromGameDay, 1) + let maxDay = min(firstToGameDay == 0 ? tripDays.count : firstToGameDay, tripDays.count) + + let validRange = (minDay <= maxDay) ? (minDay...maxDay) : (maxDay...maxDay) travelValidRanges[travelId] = validRange - - // Calculate default day - let defaultDay: Int - if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count { - defaultDay = lastGameInFromCity + 1 - } else if lastGameInFromCity > 0 { - defaultDay = lastGameInFromCity + + // Placement (override if valid) + let placement: TravelOverride + if let override = travelOverrides[travelId], validRange.contains(override.day) { + placement = override } else { - defaultDay = 1 - } + // Default day: minDay. Default sortOrder depends on whether it's an edge game day. + let day = minDay - // Use override if valid, otherwise use default - if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) { - travelByDay[overrideDay] = segment - } else { - let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) - travelByDay[clampedDefault] = segment + // If we're on the last-from-game day, default to AFTER those games. + let fromGames = gamesIn(city: fromCity, day: day) + let maxFrom = fromGames.map { $0.sortOrder }.max() ?? 0.0 + var sortOrder = maxFrom + 1.0 + + // If we're on the first-to-game day (and it's the same chosen day), default to BEFORE those games. + let toGames = gamesIn(city: toCity, day: day) + if !toGames.isEmpty { + let minTo = toGames.map { $0.sortOrder }.min() ?? 0.0 + sortOrder = minTo - 1.0 + } + + placement = TravelOverride(day: day, sortOrder: sortOrder) } + + let travelItem = ItineraryItem( + tripId: trip.id, + day: placement.day, + sortOrder: placement.sortOrder, + kind: .travel( + TravelInfo( + fromCity: fromCity, + toCity: toCity, + distanceMeters: segment.distanceMeters, + durationSeconds: segment.durationSeconds + ) + ) + ) + travelItems.append(travelItem) } - + // Build day data var days: [ItineraryDayData] = [] - + days.reserveCapacity(tripDays.count) + for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate) - var items: [ItineraryRowItem] = [] - - // Travel before this day (travel is stored on the destination day) - let travelBefore: TravelSegment? = travelByDay[dayNum] - - // Custom items for this day - filter by day and custom kind, sort by sortOrder - // Note: Add button is now embedded in the day header row (not a separate item) + + var rows: [ItineraryRowItem] = [] + + // Custom items for this day let customItemsForDay = itineraryItems .filter { $0.day == dayNum && $0.isCustom } .sorted { $0.sortOrder < $1.sortOrder } - for item in customItemsForDay { - items.append(ItineraryRowItem.customItem(item)) + rows.append(.customItem(item)) } - + + // Travel items for this day (as rows). Ordering comes from sortOrder via controller lookup. + let travelsForDay = travelItems + .filter { $0.day == dayNum } + .sorted { $0.sortOrder < $1.sortOrder } + for travel in travelsForDay { + // Find the segment matching this travel + if let info = travel.travelInfo, + let seg = trip.travelSegments.first(where: { + $0.fromLocation.name.lowercased() == info.fromCity.lowercased() + && $0.toLocation.name.lowercased() == info.toCity.lowercased() + }) { + rows.append(.travel(seg, dayNumber: dayNum)) + } + } + + // Sort rows by semantic sortOrder (custom uses its own; travel via travelItems) + rows.sort { a, b in + func so(_ r: ItineraryRowItem) -> Double { + switch r { + case .customItem(let it): return it.sortOrder + case .travel(let seg, _): + let id = stableTravelAnchorId(seg) + return (travelOverrides[id]?.sortOrder) + ?? (travelItems.first(where: { ti in + guard case .travel(let inf) = ti.kind else { return false } + return inf.fromCity.lowercased() == seg.fromLocation.name.lowercased() + && inf.toCity.lowercased() == seg.toLocation.name.lowercased() + })?.sortOrder ?? 0.0) + default: + return 0.0 + } + } + return so(a) < so(b) + } + let dayData = ItineraryDayData( id: dayNum, dayNumber: dayNum, date: dayDate, games: gamesOnDay, - items: items, - travelBefore: travelBefore + items: rows, + travelBefore: nil ) days.append(dayData) } - - return (days, travelValidRanges) + + return (days, travelValidRanges, itineraryItems + travelItems) } - + // MARK: - Helper Methods - + private func calculateTripDays() -> [Date] { let start = trip.startDate let end = trip.endDate - + var days: [Date] = [] var current = Calendar.current.startOfDay(for: start) let endDay = Calendar.current.startOfDay(for: end) - + while current <= endDay { days.append(current) current = Calendar.current.date(byAdding: .day, value: 1, to: current) ?? current } - + return days } - + private func gamesOn(date: Date) -> [RichGame] { let calendar = Calendar.current return games.filter { calendar.isDate($0.game.dateTime, inSameDayAs: date) } .sorted { $0.game.dateTime < $1.game.dateTime } } - + private func stableTravelAnchorId(_ segment: TravelSegment) -> String { "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" } - + private func findLastGameDay(in city: String, tripDays: [Date]) -> Int { var lastDay = 0 - + for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate) @@ -223,7 +292,7 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent } return lastDay } - + private func findFirstGameDay(in city: String, tripDays: [Date]) -> Int { for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 @@ -234,33 +303,33 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent } return tripDays.count } - + /// Fuzzy city matching - handles "Salt Lake City" vs "Salt Lake" etc. private func cityMatches(_ stadiumCity: String, searchCity: String) -> Bool { let stadiumLower = stadiumCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) let searchLower = searchCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) - + // Exact match if stadiumLower == searchLower { return true } - + // One contains the other if stadiumLower.contains(searchLower) || searchLower.contains(stadiumLower) { return true } - + // Word-based matching (handles "Salt Lake" matching "Salt Lake City") let stadiumWords = Set(stadiumLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty }) let searchWords = Set(searchLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty }) - + if !searchWords.isEmpty && searchWords.isSubset(of: stadiumWords) { return true } if !stadiumWords.isEmpty && stadiumWords.isSubset(of: searchWords) { return true } - + return false } } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index f4b5f6f..9b1c825 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -42,7 +42,7 @@ struct TripDetailView: View { @State private var draggedItem: ItineraryItem? @State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var dropTargetId: String? // Track which drop zone is being hovered - @State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number + @State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder private let exportService = ExportService() private let dataProvider = AppDataProvider.shared @@ -81,98 +81,82 @@ struct TripDetailView: View { } var body: some View { - mainContent - .background(Theme.backgroundGradient(colorScheme)) - .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - ShareButton(trip: trip, style: .icon) - .foregroundStyle(Theme.warmOrange) + bodyContent + } - Button { - if StoreManager.shared.isPro { - Task { - await exportPDF() - } - } else { - showProPaywall = true - } - } label: { - HStack(spacing: 2) { - Image(systemName: "doc.fill") - if !StoreManager.shared.isPro { - ProBadge() - } - } - .foregroundStyle(Theme.warmOrange) + @ViewBuilder + private var bodyContent: some View { + mainContent + .background(Theme.backgroundGradient(colorScheme)) + .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) + .toolbar { toolbarContent } + .modifier(SheetModifiers( + showExportSheet: $showExportSheet, + exportURL: exportURL, + showProPaywall: $showProPaywall, + addItemAnchor: $addItemAnchor, + editingItem: $editingItem, + tripId: trip.id, + saveItineraryItem: saveItineraryItem + )) + .onAppear { checkIfSaved() } + .task { + await loadGamesIfNeeded() + if allowCustomItems { + await loadItineraryItems() + await setupSubscription() } } - } - .sheet(isPresented: $showExportSheet) { - if let url = exportURL { - ShareSheet(items: [url]) + .onDisappear { subscriptionCancellable?.cancel() } + .onChange(of: itineraryItems) { _, newItems in + handleItineraryItemsChange(newItems) } - } - .sheet(isPresented: $showProPaywall) { - PaywallView() - } - .sheet(item: $addItemAnchor) { anchor in - AddItemSheet( - tripId: trip.id, - day: anchor.day, - existingItem: nil - ) { item in - Task { await saveItineraryItem(item) } + .onChange(of: travelOverrides.count) { _, _ in + draggedTravelId = nil + dropTargetId = nil } - } - .sheet(item: $editingItem) { item in - AddItemSheet( - tripId: trip.id, - day: item.day, - existingItem: item - ) { updatedItem in - Task { await saveItineraryItem(updatedItem) } + .overlay { + if isExporting { exportProgressOverlay } } - } - .onAppear { - checkIfSaved() - } - .task { - await loadGamesIfNeeded() - if allowCustomItems { - await loadItineraryItems() - await setupSubscription() - } - } - .onDisappear { - subscriptionCancellable?.cancel() - } - .onChange(of: itineraryItems) { _, newItems in - // Clear drag state after items update (move completed) - draggedItem = nil - dropTargetId = nil - // Recalculate routes when custom items change (mappable items affect route) - print("πŸ—ΊοΈ [MapUpdate] itineraryItems changed, count: \(newItems.count)") - for item in newItems { - if item.isCustom, let info = item.customInfo, info.isMappable { - print("πŸ—ΊοΈ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .primaryAction) { + ShareButton(trip: trip, style: .icon) + .foregroundStyle(Theme.warmOrange) + + Button { + if StoreManager.shared.isPro { + Task { await exportPDF() } + } else { + showProPaywall = true } - } - Task { - updateMapRegion() - await fetchDrivingRoutes() + } label: { + HStack(spacing: 2) { + Image(systemName: "doc.fill") + if !StoreManager.shared.isPro { + ProBadge() + } + } + .foregroundStyle(Theme.warmOrange) } } - .onChange(of: travelDayOverrides) { _, _ in - // Clear drag state after travel move completed - draggedTravelId = nil - dropTargetId = nil - } - .overlay { - if isExporting { - exportProgressOverlay + } + + private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) { + draggedItem = nil + dropTargetId = nil + print("πŸ—ΊοΈ [MapUpdate] itineraryItems changed, count: \(newItems.count)") + for item in newItems { + if item.isCustom, let info = item.customInfo, info.isMappable { + print("πŸ—ΊοΈ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") } } + Task { + updateMapRegion() + await fetchDrivingRoutes() + } } // MARK: - Main Content @@ -185,7 +169,7 @@ struct TripDetailView: View { trip: trip, games: Array(games.values), itineraryItems: itineraryItems, - travelDayOverrides: travelDayOverrides, + travelOverrides: travelOverrides, headerContent: { VStack(spacing: 0) { // Hero Map @@ -214,10 +198,10 @@ struct TripDetailView: View { .padding(.bottom, Theme.Spacing.md) } }, - onTravelMoved: { travelId, newDay in + onTravelMoved: { travelId, newDay, newSortOrder in Task { @MainActor in withAnimation { - travelDayOverrides[travelId] = newDay + travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder) } await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay) } @@ -818,8 +802,9 @@ struct TripDetailView: View { } // Check for user override - only use if within valid range - if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) { - travelByDay[overrideDay] = segment + if let override = travelOverrides[travelId], + validRange.contains(override.day) { + travelByDay[override.day] = segment } else { // Use default (clamped to valid range) let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) @@ -1276,16 +1261,18 @@ struct TripDetailView: View { print("βœ… [ItineraryItems] Loaded \(items.count) items from CloudKit") itineraryItems = items - // Extract travel day overrides from travel-type items - var overrides: [String: Int] = [:] + // Extract travel overrides (day + sortOrder) from travel-type items + var overrides: [String: TravelOverride] = [:] + for item in items where item.isTravel { - if let travelInfo = item.travelInfo { - let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())" - overrides[travelId] = item.day - } + guard let travelInfo = item.travelInfo else { continue } + let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())" + + overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) } - travelDayOverrides = overrides - print("βœ… [TravelOverrides] Extracted \(overrides.count) travel day overrides") + + travelOverrides = overrides + print("βœ… [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)") } catch { print("❌ [ItineraryItems] Failed to load: \(error)") } @@ -1357,21 +1344,35 @@ struct TripDetailView: View { Task { @MainActor in // Check if this is a travel segment being dropped if droppedId.hasPrefix("travel:") { - // Validate travel is within valid bounds + // Validate travel is within valid bounds (day-level) if let validRange = self.validDayRange(for: droppedId) { guard validRange.contains(dayNumber) else { - // Day is outside valid range - reject drop (state already cleared) return } } - - // Move travel to this day + + // Choose a semantic sortOrder for dropping onto a day: + // - If this day has games, default to AFTER games (positive) + // - If no games, default to 1.0 + // + // You can later support "before games" drops by using a negative sortOrder + // when the user drops above the games row. + let maxSortOrderOnDay = self.itineraryItems + .filter { $0.day == dayNumber } + .map { $0.sortOrder } + .max() ?? 0.0 + + let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0) + withAnimation { - self.travelDayOverrides[droppedId] = dayNumber + self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder) } - - // Persist the override to CloudKit - await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber) + + // Persist to CloudKit as a travel ItineraryItem + await self.saveTravelDayOverride( + travelAnchorId: droppedId, + displayDay: dayNumber + ) return } @@ -1901,3 +1902,52 @@ struct TripMapView: View { ) } } + +// MARK: - Travel Override + +struct TravelOverride: Equatable { + let day: Int + let sortOrder: Double +} + +// MARK: - Sheet Modifiers + +private struct SheetModifiers: ViewModifier { + @Binding var showExportSheet: Bool + let exportURL: URL? + @Binding var showProPaywall: Bool + @Binding var addItemAnchor: AddItemAnchor? + @Binding var editingItem: ItineraryItem? + let tripId: UUID + let saveItineraryItem: (ItineraryItem) async -> Void + + func body(content: Content) -> some View { + content + .sheet(isPresented: $showExportSheet) { + if let url = exportURL { + ShareSheet(items: [url]) + } + } + .sheet(isPresented: $showProPaywall) { + PaywallView() + } + .sheet(item: $addItemAnchor) { anchor in + AddItemSheet( + tripId: tripId, + day: anchor.day, + existingItem: nil + ) { item in + Task { await saveItineraryItem(item) } + } + } + .sheet(item: $editingItem) { item in + AddItemSheet( + tripId: tripId, + day: item.day, + existingItem: item + ) { updatedItem in + Task { await saveItineraryItem(updatedItem) } + } + } + } +}