// // ItineraryTableViewWrapper.swift // SportsTime // // UIViewControllerRepresentable wrapper for ItineraryTableViewController // import SwiftUI struct ItineraryTableViewWrapper: UIViewControllerRepresentable { @Environment(\.colorScheme) private var colorScheme let trip: Trip let games: [RichGame] let itineraryItems: [ItineraryItem] let travelOverrides: [String: TravelOverride] let headerContent: HeaderContent // Callbacks 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], travelOverrides: [String: TravelOverride], @ViewBuilder headerContent: () -> HeaderContent, onTravelMoved: ((String, Int, Double) -> Void)? = nil, onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil, onCustomItemTapped: ((ItineraryItem) -> Void)? = nil, onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil, onAddButtonTapped: ((Int) -> Void)? = nil ) { self.trip = trip self.games = games self.itineraryItems = itineraryItems self.travelOverrides = travelOverrides self.headerContent = headerContent() self.onTravelMoved = onTravelMoved self.onCustomItemMoved = onCustomItemMoved self.onCustomItemTapped = onCustomItemTapped 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 controller.onTravelMoved = onTravelMoved controller.onCustomItemMoved = onCustomItemMoved 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 let targetSize = CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height) 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, allItemsForConstraints, travelSegmentIndices) = buildItineraryData() controller.reloadData( days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints, travelSegmentIndices: travelSegmentIndices ) #if DEBUG controller.scrollToBottomAnimated(delay: 5.0) #endif return controller } func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) { controller.colorScheme = colorScheme controller.onTravelMoved = onTravelMoved controller.onCustomItemMoved = onCustomItemMoved 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, allItemsForConstraints, travelSegmentIndices) = buildItineraryData() controller.reloadData( days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints, travelSegmentIndices: travelSegmentIndices ) } // MARK: - Build Itinerary Data private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange], [ItineraryItem], [UUID: Int]) { let tripDays = calculateTripDays() var travelValidRanges: [String: ClosedRange] = [:] let travelSegmentIndices = Dictionary(uniqueKeysWithValues: trip.travelSegments.enumerated().map { ($1.id, $0) }) // Build game items from RichGame data for constraint validation var gameItems: [ItineraryItem] = [] for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate) for (gameIndex, richGame) in gamesOnDay.enumerated() { let gameItem = ItineraryItem( tripId: trip.id, day: dayNum, sortOrder: Double(gameIndex) * 0.01, // Games have sortOrder ~0 (at the visual boundary) kind: .game(gameId: richGame.game.id, city: richGame.stadium.city) ) gameItems.append(gameItem) } } // Build travel as semantic items with (day, sortOrder) var travelItems: [ItineraryItem] = [] travelItems.reserveCapacity(trip.travelSegments.count) func gamesIn(city: String, day: Int) -> [ItineraryItem] { gameItems.filter { item in guard item.day == day else { return false } guard let gameCity = item.gameCity else { return false } return cityMatches(gameCity, searchCity: city) } } for (segmentIndex, segment) in trip.travelSegments.enumerated() { let travelId = stableTravelAnchorId(segment, at: segmentIndex) let fromCity = segment.fromLocation.name let toCity = segment.toLocation.name // 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 // Placement (override if valid) let placement: TravelOverride if let override = travelOverrides[travelId], validRange.contains(override.day) { placement = override } else { // Default day: minDay. Default sortOrder depends on whether it's an edge game day. let day = minDay // 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(segment: segment, segmentIndex: segmentIndex) ) ) 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 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 { 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 { if let info = travel.travelInfo { guard let idx = info.segmentIndex, idx >= 0, idx < trip.travelSegments.count else { continue } let seg = trip.travelSegments[idx] guard info.matches(segment: seg) else { continue } 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 segIdx = travelSegmentIndices[seg.id] ?? 0 let id = stableTravelAnchorId(seg, at: segIdx) return (travelOverrides[id]?.sortOrder) ?? (travelItems.first(where: { ti in guard case .travel(let inf) = ti.kind else { return false } return inf.segmentIndex == segIdx })?.sortOrder ?? 0.0) default: return 0.0 } } return so(a) < so(b) } let dayData = ItineraryDayData( id: dayNum, dayNumber: dayNum, date: dayDate, games: gamesOnDay, items: rows, travelBefore: nil ) days.append(dayData) } return (days, travelValidRanges, gameItems + itineraryItems + travelItems, travelSegmentIndices) } // 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, at index: Int) -> String { let from = TravelInfo.normalizeCityName(segment.fromLocation.name) let to = TravelInfo.normalizeCityName(segment.toLocation.name) return "travel:\(index):\(from)->\(to)" } 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) if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) { lastDay = dayNum } } return lastDay } private func findFirstGameDay(in city: String, tripDays: [Date]) -> Int { for (index, dayDate) in tripDays.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate) if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) { return dayNum } } 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 } }