// // ItineraryTableViewWrapper.swift // SportsTime // // UIViewControllerRepresentable wrapper for ItineraryTableViewController // import SwiftUI struct ItineraryTableViewWrapper: UIViewControllerRepresentable { @Environment(\.colorScheme) private var colorScheme let trip: Trip let games: [RichGame] let customItems: [CustomItineraryItem] let travelDayOverrides: [String: Int] let headerContent: HeaderContent // Callbacks var onTravelMoved: ((String, Int) -> Void)? var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemTapped: ((CustomItineraryItem) -> Void)? var onCustomItemDeleted: ((CustomItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number init( trip: Trip, games: [RichGame], customItems: [CustomItineraryItem], travelDayOverrides: [String: Int], @ViewBuilder headerContent: () -> HeaderContent, onTravelMoved: ((String, Int) -> Void)? = nil, onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil, onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil, onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil, onAddButtonTapped: ((Int) -> Void)? = nil ) { self.trip = trip self.games = games self.customItems = customItems self.travelDayOverrides = travelDayOverrides 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) = buildItineraryData() controller.reloadData(days: days, travelValidRanges: validRanges) 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) = buildItineraryData() controller.reloadData(days: days, travelValidRanges: validRanges) } // MARK: - Build Itinerary Data private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange]) { let tripDays = calculateTripDays() var travelValidRanges: [String: ClosedRange] = [:] // Pre-calculate travel segment placements var travelByDay: [Int: TravelSegment] = [:] 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 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 } else { defaultDay = 1 } // 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 } } // Build day data var days: [ItineraryDayData] = [] 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 - simply filter by day and sort by sortOrder // Note: Add button is now embedded in the day header row (not a separate item) let dayItems = customItems.filter { $0.day == dayNum } .sorted { $0.sortOrder < $1.sortOrder } for item in dayItems { items.append(ItineraryRowItem.customItem(item)) } let dayData = ItineraryDayData( id: dayNum, dayNumber: dayNum, date: dayDate, games: gamesOnDay, items: items, travelBefore: travelBefore ) days.append(dayData) } return (days, travelValidRanges) } // 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) 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 } }