- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
884 lines
29 KiB
Swift
884 lines
29 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("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<SavedTrip>(
|
|
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: [:]
|
|
)
|
|
}
|
|
}
|