- Fix same-day different-city validation in C2C routes (no more impossible games like Detroit 7:30pm AND Milwaukee 8pm on the same day) - Cap C2C trips at 14 days max with 3 middle stops, prefer shortest routes - Add sport icon and name to game rows in trip itinerary - Add horizontal scroll to route dots in suggested trip cards - Allow swipe-to-dismiss on home sheet (trip planner still blocks) - Generate travel segments for suggested trips - Increase DAG route lookahead to 5 days for multi-day drives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
915 lines
32 KiB
Swift
915 lines
32 KiB
Swift
//
|
|
// 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 isExporting = false
|
|
@State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress?
|
|
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
|
@State private var isSaved = 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))
|
|
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
Button {
|
|
shareTrip()
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await exportPDF()
|
|
}
|
|
} label: {
|
|
Image(systemName: "doc.fill")
|
|
.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])
|
|
}
|
|
}
|
|
.onAppear {
|
|
checkIfSaved()
|
|
}
|
|
.overlay {
|
|
if isExporting {
|
|
exportProgressOverlay
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Export Progress Overlay
|
|
|
|
private var exportProgressOverlay: some View {
|
|
ZStack {
|
|
// Background dimmer
|
|
Color.black.opacity(0.6)
|
|
.ignoresSafeArea()
|
|
|
|
// Progress card
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Progress ring
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8)
|
|
.frame(width: 80, height: 80)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: exportProgress?.percentComplete ?? 0)
|
|
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
|
.frame(width: 80, height: 80)
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.easeInOut(duration: 0.3), value: exportProgress?.percentComplete)
|
|
|
|
Image(systemName: "doc.fill")
|
|
.font(.system(size: 24))
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.xs) {
|
|
Text("Creating PDF")
|
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text(exportProgress?.currentStep ?? "Preparing...")
|
|
.font(.system(size: Theme.FontSize.caption))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
.multilineTextAlignment(.center)
|
|
|
|
if let progress = exportProgress {
|
|
Text("\(Int(progress.percentComplete * 100))%")
|
|
.font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.xl)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
|
.shadow(color: .black.opacity(0.3), radius: 20, y: 10)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
|
|
// 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)
|
|
.overlay(alignment: .topTrailing) {
|
|
// Save/Unsave heart button
|
|
Button {
|
|
toggleSaved()
|
|
} label: {
|
|
Image(systemName: isSaved ? "heart.fill" : "heart")
|
|
.font(.system(size: 22, weight: .medium))
|
|
.foregroundStyle(isSaved ? .red : .white)
|
|
.padding(12)
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(Circle())
|
|
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
|
|
}
|
|
.padding(.top, 12)
|
|
.padding(.trailing, 12)
|
|
}
|
|
|
|
// 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 {
|
|
ThemedSpinnerCompact(size: 24)
|
|
.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()
|
|
let sourceLocation = CLLocation(latitude: source.coordinate.latitude, longitude: source.coordinate.longitude)
|
|
let destLocation = CLLocation(latitude: destination.coordinate.latitude, longitude: destination.coordinate.longitude)
|
|
request.source = MKMapItem(location: sourceLocation, address: nil)
|
|
request.destination = MKMapItem(location: destLocation, address: nil)
|
|
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 {
|
|
isExporting = true
|
|
exportProgress = nil
|
|
|
|
do {
|
|
let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in
|
|
await MainActor.run {
|
|
self.exportProgress = progress
|
|
}
|
|
}
|
|
exportURL = url
|
|
showExportSheet = true
|
|
} catch {
|
|
// PDF export failed silently
|
|
}
|
|
|
|
isExporting = false
|
|
}
|
|
|
|
private func shareTrip() {
|
|
shareURL = exportService.shareTrip(trip)
|
|
showShareSheet = true
|
|
}
|
|
|
|
private func toggleSaved() {
|
|
if isSaved {
|
|
unsaveTrip()
|
|
} else {
|
|
saveTrip()
|
|
}
|
|
}
|
|
|
|
private func saveTrip() {
|
|
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
|
|
return
|
|
}
|
|
|
|
modelContext.insert(savedTrip)
|
|
|
|
do {
|
|
try modelContext.save()
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
|
isSaved = true
|
|
}
|
|
} catch {
|
|
// Save failed silently
|
|
}
|
|
}
|
|
|
|
private func unsaveTrip() {
|
|
let tripId = trip.id
|
|
let descriptor = FetchDescriptor<SavedTrip>(
|
|
predicate: #Predicate { $0.id == tripId }
|
|
)
|
|
|
|
do {
|
|
let savedTrips = try modelContext.fetch(descriptor)
|
|
for savedTrip in savedTrips {
|
|
modelContext.delete(savedTrip)
|
|
}
|
|
try modelContext.save()
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
|
isSaved = false
|
|
}
|
|
} catch {
|
|
// Unsave failed silently
|
|
}
|
|
}
|
|
|
|
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: - 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) {
|
|
// Sport badge + Matchup
|
|
HStack(spacing: 6) {
|
|
// Sport icon and name
|
|
HStack(spacing: 3) {
|
|
Image(systemName: game.game.sport.iconName)
|
|
.font(.system(size: 10))
|
|
Text(game.game.sport.rawValue)
|
|
.font(.system(size: 10, weight: .medium))
|
|
}
|
|
.foregroundStyle(game.game.sport.themeColor)
|
|
|
|
// 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
|
|
@State private var showEVChargers = false
|
|
|
|
private var hasEVChargers: Bool {
|
|
!segment.evChargingStops.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Top connector
|
|
Rectangle()
|
|
.fill(Theme.routeGold.opacity(0.4))
|
|
.frame(width: 2, height: 16)
|
|
|
|
// Travel card
|
|
VStack(spacing: 0) {
|
|
// Main travel info
|
|
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)
|
|
|
|
// EV Chargers section (if available)
|
|
if hasEVChargers {
|
|
Divider()
|
|
.background(Theme.routeGold.opacity(0.2))
|
|
|
|
Button {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
|
showEVChargers.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
Image(systemName: "bolt.fill")
|
|
.foregroundStyle(.green)
|
|
.font(.system(size: 12))
|
|
|
|
Text("\(segment.evChargingStops.count) EV Charger\(segment.evChargingStops.count > 1 ? "s" : "") Along Route")
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: showEVChargers ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if showEVChargers {
|
|
VStack(spacing: 0) {
|
|
ForEach(segment.evChargingStops) { charger in
|
|
EVChargerRow(charger: charger)
|
|
}
|
|
}
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
.padding(.bottom, Theme.Spacing.sm)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
}
|
|
}
|
|
.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: - EV Charger Row
|
|
|
|
struct EVChargerRow: View {
|
|
let charger: EVChargingStop
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
// Connector line indicator
|
|
VStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(.green.opacity(0.3))
|
|
.frame(width: 1, height: 8)
|
|
Circle()
|
|
.fill(.green)
|
|
.frame(width: 6, height: 6)
|
|
Rectangle()
|
|
.fill(.green.opacity(0.3))
|
|
.frame(width: 1, height: 8)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: Theme.Spacing.xs) {
|
|
Text(charger.name)
|
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.lineLimit(1)
|
|
|
|
chargerTypeBadge
|
|
}
|
|
|
|
HStack(spacing: Theme.Spacing.xs) {
|
|
if let address = charger.location.address {
|
|
Text(address)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Text("•")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
Text("~\(charger.formattedChargeTime) charge")
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var chargerTypeBadge: some View {
|
|
let (text, color) = chargerTypeInfo
|
|
Text(text)
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(color.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
private var chargerTypeInfo: (String, Color) {
|
|
switch charger.chargerType {
|
|
case .supercharger:
|
|
return ("Supercharger", .red)
|
|
case .dcFast:
|
|
return ("DC Fast", .blue)
|
|
case .level2:
|
|
return ("Level 2", .green)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: [:]
|
|
)
|
|
}
|
|
}
|