// // TripDetailView.swift // SportsTime // import SwiftUI import SwiftData import MapKit struct TripDetailView: View { @Environment(\.modelContext) private var modelContext let trip: Trip let games: [UUID: RichGame] @State private var selectedDay: ItineraryDay? @State private var showExportSheet = false @State private var showShareSheet = false @State private var exportURL: URL? @State private var shareURL: URL? @State private var mapCameraPosition: MapCameraPosition = .automatic @State private var isSaved = false @State private var showSaveConfirmation = false @State private var routePolylines: [MKPolyline] = [] @State private var isLoadingRoutes = false private let exportService = ExportService() private let dataProvider = AppDataProvider.shared var body: some View { ScrollView { VStack(spacing: 20) { // Header tripHeader // Score Card if let score = trip.score { scoreCard(score) } // Stats statsGrid // Map Preview mapPreview // Day-by-Day Itinerary itinerarySection } .padding() } .navigationTitle(trip.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { Task { await shareTrip() } } label: { Image(systemName: "square.and.arrow.up") } Menu { Button { Task { await exportPDF() } } label: { Label("Export PDF", systemImage: "doc.fill") } Button { saveTrip() } label: { Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark") } .disabled(isSaved) } label: { Image(systemName: "ellipsis.circle") } } } .sheet(isPresented: $showExportSheet) { if let url = exportURL { ShareSheet(items: [url]) } } .sheet(isPresented: $showShareSheet) { if let url = shareURL { ShareSheet(items: [url]) } else { ShareSheet(items: [trip.name, trip.formattedDateRange]) } } .alert("Trip Saved", isPresented: $showSaveConfirmation) { Button("OK", role: .cancel) { } } message: { Text("Your trip has been saved and can be accessed from My Trips.") } .onAppear { checkIfSaved() } } // MARK: - Header private var tripHeader: some View { VStack(alignment: .leading, spacing: 8) { Text(trip.formattedDateRange) .font(.subheadline) .foregroundStyle(.secondary) HStack(spacing: 16) { ForEach(Array(trip.uniqueSports), id: \.self) { sport in Label(sport.rawValue, systemImage: sport.iconName) .font(.caption) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Color.blue.opacity(0.1)) .clipShape(Capsule()) } } } .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Score Card private func scoreCard(_ score: TripScore) -> some View { VStack(spacing: 12) { HStack { Text("Trip Score") .font(.headline) Spacer() Text(score.scoreGrade) .font(.largeTitle) .fontWeight(.bold) .foregroundStyle(.green) } HStack(spacing: 20) { scoreItem(label: "Games", value: score.gameQualityScore) scoreItem(label: "Route", value: score.routeEfficiencyScore) scoreItem(label: "Balance", value: score.leisureBalanceScore) scoreItem(label: "Prefs", value: score.preferenceAlignmentScore) } } .padding() .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } private func scoreItem(label: String, value: Double) -> some View { VStack(spacing: 4) { Text(String(format: "%.0f", value)) .font(.headline) Text(label) .font(.caption2) .foregroundStyle(.secondary) } } // MARK: - Stats Grid private var statsGrid: some View { LazyVGrid(columns: [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ], spacing: 16) { statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar") statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle") statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt") statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes") statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car") statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium") } } private func statCell(value: String, label: String, icon: String) -> some View { VStack(spacing: 6) { Image(systemName: icon) .font(.title2) .foregroundStyle(.blue) Text(value) .font(.headline) Text(label) .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) } // MARK: - Map Preview private var mapPreview: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Route") .font(.headline) Spacer() if isLoadingRoutes { ProgressView() .scaleEffect(0.7) } } Map(position: $mapCameraPosition) { // Add markers for each stop ForEach(stopCoordinates.indices, id: \.self) { index in let stop = stopCoordinates[index] Marker(stop.name, coordinate: stop.coordinate) .tint(.blue) } // Add actual driving route polylines ForEach(routePolylines.indices, id: \.self) { index in MapPolyline(routePolylines[index]) .stroke(.blue, lineWidth: 3) } } .frame(height: 200) .clipShape(RoundedRectangle(cornerRadius: 12)) .task { updateMapRegion() await fetchDrivingRoutes() } } } /// Fetch actual driving routes using MKDirections private func fetchDrivingRoutes() async { let stops = stopCoordinates guard stops.count >= 2 else { return } isLoadingRoutes = true var polylines: [MKPolyline] = [] for i in 0..<(stops.count - 1) { let source = stops[i] let destination = stops[i + 1] let request = MKDirections.Request() request.source = MKMapItem(placemark: MKPlacemark(coordinate: source.coordinate)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination.coordinate)) request.transportType = .automobile let directions = MKDirections(request: request) do { let response = try await directions.calculate() if let route = response.routes.first { polylines.append(route.polyline) } } catch { // Fallback to straight line if directions fail print("Failed to get directions from \(source.name) to \(destination.name): \(error)") let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2) polylines.append(straightLine) } } routePolylines = polylines isLoadingRoutes = false } /// Get coordinates for all stops (from stop coordinate or stadium) private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] { trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in // First try to use the stop's stored coordinate if let coord = stop.coordinate { return (stop.city, coord) } // Fall back to stadium coordinate if available if let stadiumId = stop.stadium, let stadium = dataProvider.stadium(for: stadiumId) { return (stadium.name, stadium.coordinate) } return nil } } /// Resolved stadiums from trip stops (for markers) private var tripStadiums: [Stadium] { trip.stops.compactMap { stop in guard let stadiumId = stop.stadium else { return nil } return dataProvider.stadium(for: stadiumId) } } private func updateMapRegion() { guard !stopCoordinates.isEmpty else { return } let coordinates = stopCoordinates.map(\.coordinate) let lats = coordinates.map(\.latitude) let lons = coordinates.map(\.longitude) guard let minLat = lats.min(), let maxLat = lats.max(), let minLon = lons.min(), let maxLon = lons.max() else { return } let center = CLLocationCoordinate2D( latitude: (minLat + maxLat) / 2, longitude: (minLon + maxLon) / 2 ) // Add padding to the span let latSpan = (maxLat - minLat) * 1.3 + 0.5 let lonSpan = (maxLon - minLon) * 1.3 + 0.5 mapCameraPosition = .region(MKCoordinateRegion( center: center, span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1)) )) } // MARK: - Itinerary private var itinerarySection: some View { VStack(alignment: .leading, spacing: 12) { Text("Route Options") .font(.headline) let combinations = computeRouteCombinations() if combinations.count == 1 { // Single route - show fully expanded SingleRouteView( route: combinations[0], days: trip.itineraryDays(), games: games ) } else { // Multiple combinations - show each as expandable row ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in RouteCombinationRow( routeNumber: index + 1, route: route, days: trip.itineraryDays(), games: games, totalRoutes: combinations.count ) } } } } /// Computes all possible route combinations across days private func computeRouteCombinations() -> [[DayChoice]] { let days = trip.itineraryDays() let calendar = Calendar.current // Build options for each day var dayOptions: [[DayChoice]] = [] for day in days { let dayStart = calendar.startOfDay(for: day.date) // Find stops with games on this day let stopsWithGames = day.stops.filter { stop in stop.games.compactMap { games[$0] }.contains { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } if stopsWithGames.isEmpty { // Rest day or travel day - use first stop or create empty if let firstStop = day.stops.first { dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)]) } } else { // Create choices for each stop with games let choices = stopsWithGames.compactMap { stop -> DayChoice? in let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first) } if !choices.isEmpty { dayOptions.append(choices) } } } // Compute cartesian product of all day options return cartesianProduct(dayOptions) } /// Computes cartesian product of arrays private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] { guard !arrays.isEmpty else { return [[]] } var result: [[DayChoice]] = [[]] for array in arrays { var newResult: [[DayChoice]] = [] for existing in result { for element in array { newResult.append(existing + [element]) } } result = newResult } return result } /// Detects if there are games in different cities on the same day private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: day.date) // Find all stops that have games on this specific day let stopsWithGamesToday = day.stops.filter { stop in stop.games.compactMap { games[$0] }.contains { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } // Get unique cities with games today let citiesWithGames = Set(stopsWithGamesToday.map { $0.city }) if citiesWithGames.count > 1 { return DayConflictInfo( hasConflict: true, conflictingStops: stopsWithGamesToday, conflictingCities: Array(citiesWithGames) ) } return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: []) } // MARK: - Actions private func exportPDF() async { do { let url = try await exportService.exportToPDF(trip: trip, games: games) exportURL = url showExportSheet = true } catch { print("Failed to export PDF: \(error)") } } private func shareTrip() async { shareURL = await exportService.shareTrip(trip) showShareSheet = true } private func saveTrip() { guard let savedTrip = SavedTrip.from(trip, status: .planned) else { print("Failed to create SavedTrip") return } modelContext.insert(savedTrip) do { try modelContext.save() isSaved = true showSaveConfirmation = true } catch { print("Failed to save trip: \(error)") } } private func checkIfSaved() { let tripId = trip.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == tripId } ) if let count = try? modelContext.fetchCount(descriptor), count > 0 { isSaved = true } } } // MARK: - Day Conflict Info struct DayConflictInfo { let hasConflict: Bool let conflictingStops: [TripStop] let conflictingCities: [String] var warningMessage: String { guard hasConflict else { return "" } let otherCities = conflictingCities.joined(separator: ", ") return "Scheduling conflict: Games in \(otherCities) on the same day" } } // MARK: - Day Choice (Route Option) /// Represents a choice for a single day in a route struct DayChoice: Hashable { let dayNumber: Int let stop: TripStop let game: RichGame? func hash(into hasher: inout Hasher) { hasher.combine(dayNumber) hasher.combine(stop.city) } static func == (lhs: DayChoice, rhs: DayChoice) -> Bool { lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city } } // MARK: - Route Combination Row (Expandable full route) struct RouteCombinationRow: View { let routeNumber: Int let route: [DayChoice] let days: [ItineraryDay] let games: [UUID: RichGame] let totalRoutes: Int @State private var isExpanded = false /// Summary string like "CLE @ SD → CHC @ ATH → ATL @ LAD" private var routeSummary: String { route.compactMap { choice -> String? in guard let game = choice.game else { return nil } return game.matchupDescription }.joined(separator: " → ") } /// Cities in the route private var routeCities: String { route.map { $0.stop.city }.joined(separator: " → ") } var body: some View { VStack(spacing: 0) { // Header (always visible, tappable) Button { withAnimation(.easeInOut(duration: 0.25)) { isExpanded.toggle() } } label: { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { // Route number badge Text("Route \(routeNumber)") .font(.caption) .fontWeight(.bold) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.blue) .clipShape(Capsule()) // Game sequence summary Text(routeSummary) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(.primary) .lineLimit(2) .multilineTextAlignment(.leading) // Cities Text(routeCities) .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) .padding(8) .background(Color(.tertiarySystemFill)) .clipShape(Circle()) } .padding() .background(Color(.secondarySystemBackground)) } .buttonStyle(.plain) // Expanded content - full day-by-day itinerary if isExpanded { VStack(spacing: 8) { ForEach(route, id: \.dayNumber) { choice in if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) { RouteDayCard(day: day, choice: choice, games: games) } } } .padding(12) .background(Color(.secondarySystemBackground)) } } .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.blue.opacity(0.2), lineWidth: 1) ) } } // MARK: - Single Route View (Auto-expanded when only one option) struct SingleRouteView: View { let route: [DayChoice] let days: [ItineraryDay] let games: [UUID: RichGame] var body: some View { VStack(spacing: 12) { ForEach(route, id: \.dayNumber) { choice in if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) { RouteDayCard(day: day, choice: choice, games: games) } } } } } // MARK: - Route Day Card (Individual day within a route) struct RouteDayCard: View { let day: ItineraryDay let choice: DayChoice let games: [UUID: RichGame] private var gamesOnThisDay: [RichGame] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: day.date) return choice.stop.games.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } var body: some View { VStack(alignment: .leading, spacing: 8) { // Day header HStack { Text("Day \(day.dayNumber)") .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(.blue) Text(day.formattedDate) .font(.subheadline) .foregroundStyle(.secondary) Spacer() if gamesOnThisDay.isEmpty { Text("Rest Day") .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.green.opacity(0.2)) .clipShape(Capsule()) } } // City Label(choice.stop.city, systemImage: "mappin") .font(.caption) .foregroundStyle(.secondary) // Travel if day.hasTravelSegment { ForEach(day.travelSegments) { segment in HStack(spacing: 4) { Image(systemName: segment.travelMode.iconName) Text("\(segment.formattedDistance) • \(segment.formattedDuration)") } .font(.caption) .foregroundStyle(.orange) } } // Games ForEach(gamesOnThisDay, id: \.game.id) { richGame in HStack { Image(systemName: richGame.game.sport.iconName) .foregroundStyle(.blue) Text(richGame.matchupDescription) .font(.subheadline) Spacer() Text(richGame.game.gameTime) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } } .padding() .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) } } // MARK: - Day Card struct DayCard: View { let day: ItineraryDay let games: [UUID: RichGame] var specificStop: TripStop? = nil var conflictInfo: DayConflictInfo? = nil /// The city to display for this card var primaryCityForDay: String? { // If a specific stop is provided (conflict mode), use that stop's city if let stop = specificStop { return stop.city } let calendar = Calendar.current let dayStart = calendar.startOfDay(for: day.date) // Find the stop with a game on this day let primaryStop = day.stops.first { stop in stop.games.compactMap { games[$0] }.contains { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } ?? day.stops.first return primaryStop?.city } /// Games to display on this card var gamesOnThisDay: [RichGame] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: day.date) // If a specific stop is provided (conflict mode), only show that stop's games if let stop = specificStop { return stop.games.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } // Find the stop where we're actually located on this day let primaryStop = day.stops.first { stop in stop.games.compactMap { games[$0] }.contains { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } ?? day.stops.first guard let stop = primaryStop else { return [] } return stop.games.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } /// Whether this card has a scheduling conflict var hasConflict: Bool { conflictInfo?.hasConflict ?? false } /// Other cities with conflicting games (excluding current city) var otherConflictingCities: [String] { guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] } return info.conflictingCities.filter { $0 != currentCity } } var body: some View { VStack(alignment: .leading, spacing: 8) { // Conflict warning banner if hasConflict { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))") .font(.caption) .fontWeight(.medium) } .padding(.horizontal, 10) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 8)) } // Day header HStack { Text("Day \(day.dayNumber)") .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(.blue) Text(day.formattedDate) .font(.subheadline) .foregroundStyle(.secondary) Spacer() if day.isRestDay && !hasConflict { Text("Rest Day") .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.green.opacity(0.2)) .clipShape(Capsule()) } } // City if let city = primaryCityForDay { Label(city, systemImage: "mappin") .font(.caption) .foregroundStyle(.secondary) } // Travel (only show if not in conflict mode, to avoid duplication) if day.hasTravelSegment && specificStop == nil { ForEach(day.travelSegments) { segment in HStack(spacing: 4) { Image(systemName: segment.travelMode.iconName) Text("\(segment.formattedDistance) • \(segment.formattedDuration)") } .font(.caption) .foregroundStyle(.orange) } } // Games ForEach(gamesOnThisDay, id: \.game.id) { richGame in HStack { Image(systemName: richGame.game.sport.iconName) .foregroundStyle(.blue) Text(richGame.matchupDescription) .font(.subheadline) Spacer() Text(richGame.game.gameTime) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } } .padding() .background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1) ) } } // MARK: - Share Sheet struct ShareSheet: UIViewControllerRepresentable { let items: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) } func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview { NavigationStack { TripDetailView( trip: Trip( name: "MLB Road Trip", preferences: TripPreferences( startLocation: LocationInput(name: "New York"), endLocation: LocationInput(name: "Chicago") ) ), games: [:] ) } }