Files
Sportstime/SportsTime/Features/Trip/Views/TripDetailView.swift
Trey t ab89c25f2f Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:26:17 -06:00

573 lines
19 KiB
Swift

//
// 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<SavedTrip>(
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: [:]
)
}
}