// // 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("Itinerary") .font(.headline) ForEach(tripDays, id: \.self) { dayDate in SimpleDayCard( dayNumber: dayNumber(for: dayDate), date: dayDate, gamesOnDay: gamesOn(date: dayDate), travelOnDay: travelOn(date: dayDate) ) } } } /// All calendar days in the trip 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 } /// Day number for a given date private func dayNumber(for date: Date) -> Int { guard let firstDay = tripDays.first else { return 1 } let calendar = Calendar.current let days = calendar.dateComponents([.day], from: firstDay, to: date).day ?? 0 return days + 1 } /// Games scheduled on a specific date private func gamesOn(date: Date) -> [RichGame] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: date) // Get all game IDs from all stops let allGameIds = trip.stops.flatMap { $0.games } return allGameIds.compactMap { games[$0] }.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart } } /// Travel segments departing on a specific date private func travelOn(date: Date) -> [TravelSegment] { let calendar = Calendar.current let dayStart = calendar.startOfDay(for: date) return trip.travelSegments.filter { segment in calendar.startOfDay(for: segment.departureTime) == dayStart } } // 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: - Simple Day Card (queries games and travel separately by date) struct SimpleDayCard: View { let dayNumber: Int let date: Date let gamesOnDay: [RichGame] let travelOnDay: [TravelSegment] private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "EEEE, MMM d" return formatter.string(from: date) } private var isRestDay: Bool { gamesOnDay.isEmpty && travelOnDay.isEmpty } /// City where games are (from stadium) private var gameCity: String? { gamesOnDay.first?.stadium.city } var body: some View { VStack(alignment: .leading, spacing: 12) { // Day header HStack { Text("Day \(dayNumber)") .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(.blue) Text(formattedDate) .font(.subheadline) .foregroundStyle(.secondary) Spacer() if isRestDay { Text("Rest Day") .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.green.opacity(0.2)) .clipShape(Capsule()) } } // Games (each as its own row) if !gamesOnDay.isEmpty { VStack(alignment: .leading, spacing: 6) { // City label if let city = gameCity { Label(city, systemImage: "mappin") .font(.caption) .foregroundStyle(.secondary) } ForEach(gamesOnDay, id: \.game.id) { richGame in HStack { Image(systemName: richGame.game.sport.iconName) .foregroundStyle(.blue) .frame(width: 20) Text(richGame.matchupDescription) .font(.subheadline) Spacer() Text(richGame.game.gameTime) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } } // Travel segments (each as its own row, separate from games) ForEach(travelOnDay) { segment in HStack(spacing: 8) { Image(systemName: segment.travelMode.iconName) .foregroundStyle(.orange) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text("Drive to \(segment.toLocation.name)") .font(.subheadline) .fontWeight(.medium) Text("\(segment.formattedDistance) • \(segment.formattedDuration)") .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 8) .padding(.horizontal, 10) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.orange.opacity(0.3), lineWidth: 1) ) } } .padding() .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } // 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: [:] ) } }