// // TripDetailView.swift // SportsTime // import SwiftUI import SwiftData import MapKit struct TripDetailView: View { @Environment(\.modelContext) private var modelContext @Environment(\.colorScheme) private var colorScheme 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: 0) { // Hero Map heroMapSection .frame(height: 280) // Content VStack(spacing: Theme.Spacing.lg) { // Header tripHeader .padding(.top, Theme.Spacing.lg) // Stats Row statsRow // Score Card if let score = trip.score { scoreCard(score) } // Day-by-Day Itinerary itinerarySection } .padding(.horizontal, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.xxl) } } .background(Theme.backgroundGradient(colorScheme)) .navigationTitle(trip.name) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { Task { await shareTrip() } } label: { Image(systemName: "square.and.arrow.up") .foregroundStyle(Theme.warmOrange) } 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") .foregroundStyle(Theme.warmOrange) } } } .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: - Hero Map Section private var heroMapSection: some View { ZStack(alignment: .bottom) { Map(position: $mapCameraPosition) { ForEach(stopCoordinates.indices, id: \.self) { index in let stop = stopCoordinates[index] Annotation(stop.name, coordinate: stop.coordinate) { PulsingDot(color: index == 0 ? Theme.warmOrange : Theme.routeGold, size: 10) } } ForEach(routePolylines.indices, id: \.self) { index in MapPolyline(routePolylines[index]) .stroke(Theme.routeGold, lineWidth: 4) } } .mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard) // Gradient overlay at bottom LinearGradient( colors: [.clear, Theme.cardBackground(colorScheme).opacity(0.8), Theme.cardBackground(colorScheme)], startPoint: .top, endPoint: .bottom ) .frame(height: 80) // Loading indicator if isLoadingRoutes { ProgressView() .tint(Theme.warmOrange) .padding(.bottom, 40) } } .task { updateMapRegion() await fetchDrivingRoutes() } } // MARK: - Header private var tripHeader: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { // Date range Text(trip.formattedDateRange) .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.textSecondary(colorScheme)) // Route preview RoutePreviewStrip(cities: trip.stops.map { $0.city }) .padding(.vertical, Theme.Spacing.xs) // Sport badges HStack(spacing: Theme.Spacing.xs) { ForEach(Array(trip.uniqueSports), id: \.self) { sport in HStack(spacing: 4) { Image(systemName: sport.iconName) .font(.system(size: 10)) Text(sport.rawValue) .font(.system(size: 11, weight: .medium)) } .padding(.horizontal, 10) .padding(.vertical, 5) .background(sport.themeColor.opacity(0.2)) .foregroundStyle(sport.themeColor) .clipShape(Capsule()) } } } .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Stats Row private var statsRow: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Theme.Spacing.sm) { StatPill(icon: "calendar", value: "\(trip.tripDuration) days") StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities") StatPill(icon: "sportscourt", value: "\(trip.totalGames) games") StatPill(icon: "road.lanes", value: trip.formattedTotalDistance) StatPill(icon: "car", value: trip.formattedTotalDriving) } } } // MARK: - Score Card private func scoreCard(_ score: TripScore) -> some View { VStack(spacing: Theme.Spacing.md) { HStack { Text("Trip Score") .font(.system(size: Theme.FontSize.cardTitle, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text(score.scoreGrade) .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(Theme.warmOrange) .glowEffect(color: Theme.warmOrange, radius: 8) } HStack(spacing: Theme.Spacing.lg) { scoreItem(label: "Games", value: score.gameQualityScore, color: Theme.mlbRed) scoreItem(label: "Route", value: score.routeEfficiencyScore, color: Theme.routeGold) scoreItem(label: "Balance", value: score.leisureBalanceScore, color: Theme.mlsGreen) scoreItem(label: "Prefs", value: score.preferenceAlignmentScore, color: Theme.nbaOrange) } } .cardStyle() } private func scoreItem(label: String, value: Double, color: Color) -> some View { VStack(spacing: 4) { Text(String(format: "%.0f", value)) .font(.system(size: Theme.FontSize.cardTitle, weight: .bold)) .foregroundStyle(color) Text(label) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } // MARK: - Itinerary private var itinerarySection: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { Text("Itinerary") .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in switch section { case .day(let dayNumber, let date, let gamesOnDay): DaySection( dayNumber: dayNumber, date: date, games: gamesOnDay ) .staggeredAnimation(index: index) case .travel(let segment): TravelSection(segment: segment) .staggeredAnimation(index: index) } } } } /// Build itinerary sections: days with travel between different cities private var itinerarySections: [ItinerarySection] { var sections: [ItinerarySection] = [] // Build day sections for days with games var daySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = [] let days = tripDays for (index, dayDate) in days.enumerated() { let dayNum = index + 1 let gamesOnDay = gamesOn(date: dayDate) // Get city from games (preferred) or from stops as fallback let cityForDay = gamesOnDay.first?.stadium.city ?? cityOn(date: dayDate) ?? "" // Include days with games // Skip empty days at the end (departure day after last game) if !gamesOnDay.isEmpty { daySections.append((dayNum, dayDate, cityForDay, gamesOnDay)) } } // Build sections: insert travel BEFORE each day when coming from different city for (index, daySection) in daySections.enumerated() { // Check if we need travel BEFORE this day (coming from different city) if index > 0 { let prevSection = daySections[index - 1] let prevCity = prevSection.city let currentCity = daySection.city // If cities differ, find travel segment from prev -> current if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity { if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) { sections.append(.travel(travelSegment)) } } } // Add the day section sections.append(.day(dayNumber: daySection.dayNumber, date: daySection.date, games: daySection.games)) } return sections } private var tripDays: [Date] { let calendar = Calendar.current guard let startDate = trip.stops.first?.arrivalDate, let endDate = trip.stops.last?.departureDate else { return [] } var days: [Date] = [] var current = calendar.startOfDay(for: startDate) let end = calendar.startOfDay(for: endDate) while current <= end { days.append(current) current = calendar.date(byAdding: .day, value: 1, to: current)! } return days } private func gamesOn(date: Date) -> [RichGame] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: date) let allGameIds = trip.stops.flatMap { $0.games } return allGameIds.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart }.sorted { $0.game.dateTime < $1.game.dateTime } } /// Get the city for a given date (from the stop that covers that date) private func cityOn(date: Date) -> String? { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: date) return trip.stops.first { stop in let arrivalDay = calendar.startOfDay(for: stop.arrivalDate) let departureDay = calendar.startOfDay(for: stop.departureDate) return dayStart >= arrivalDay && dayStart <= departureDay }?.city } /// Find travel segment that goes from one city to another private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? { let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces) let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces) return trip.travelSegments.first { segment in let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces) let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces) return segmentFrom == fromLower && segmentTo == toLower } } // MARK: - Map Helpers 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 { let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2) polylines.append(straightLine) } } routePolylines = polylines isLoadingRoutes = false } private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] { trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in if let coord = stop.coordinate { return (stop.city, coord) } if let stadiumId = stop.stadium, let stadium = dataProvider.stadium(for: stadiumId) { return (stadium.name, stadium.coordinate) } return nil } } 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 ) 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: - 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, games: games, 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: - Itinerary Section enum ItinerarySection { case day(dayNumber: Int, date: Date, games: [RichGame]) case travel(TravelSegment) } // MARK: - Day Section struct DaySection: View { let dayNumber: Int let date: Date let games: [RichGame] @Environment(\.colorScheme) private var colorScheme private var formattedDate: String { date.formatted(.dateTime.weekday(.wide).month().day()) } private var gameCity: String? { games.first?.stadium.city } private var isRestDay: Bool { games.isEmpty } var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { // Day header HStack { VStack(alignment: .leading, spacing: 2) { Text("Day \(dayNumber)") .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(formattedDate) .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() if isRestDay { Text("Rest Day") .badgeStyle(color: Theme.mlsGreen, filled: false) } else if !games.isEmpty { Text("\(games.count) game\(games.count > 1 ? "s" : "")") .badgeStyle(color: Theme.warmOrange, filled: false) } } // City label if let city = gameCity { Label(city, systemImage: "mappin") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) } // Games ForEach(games, id: \.game.id) { richGame in GameRow(game: richGame) } } .cardStyle() } } // MARK: - Game Row struct GameRow: View { let game: RichGame @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: Theme.Spacing.md) { // Sport color bar SportColorBar(sport: game.game.sport) VStack(alignment: .leading, spacing: 4) { // Matchup HStack(spacing: 4) { Text(game.awayTeam.abbreviation) .font(.system(size: Theme.FontSize.body, weight: .bold)) Text("@") .foregroundStyle(Theme.textMuted(colorScheme)) Text(game.homeTeam.abbreviation) .font(.system(size: Theme.FontSize.body, weight: .bold)) } .foregroundStyle(Theme.textPrimary(colorScheme)) // Stadium HStack(spacing: 4) { Image(systemName: "building.2") .font(.system(size: 10)) Text(game.stadium.name) .font(.system(size: Theme.FontSize.caption)) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() // Time Text(game.game.gameTime) .font(.system(size: Theme.FontSize.caption, weight: .semibold)) .foregroundStyle(Theme.warmOrange) } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) } } // MARK: - Travel Section struct TravelSection: View { let segment: TravelSegment @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 0) { // Top connector Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 2, height: 16) // Travel card HStack(spacing: Theme.Spacing.md) { // Icon ZStack { Circle() .fill(Theme.cardBackgroundElevated(colorScheme)) .frame(width: 44, height: 44) Image(systemName: "car.fill") .foregroundStyle(Theme.routeGold) } VStack(alignment: .leading, spacing: 2) { Text("Travel") .font(.system(size: Theme.FontSize.micro, weight: .semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) Text("\(segment.fromLocation.name) → \(segment.toLocation.name)") .font(.system(size: Theme.FontSize.body, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(segment.formattedDistance) .font(.system(size: Theme.FontSize.caption, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(segment.formattedDuration) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textSecondary(colorScheme)) } } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme).opacity(0.7)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1) } // Bottom connector Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 2, height: 16) } } } // 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: [:] ) } }