// // TripDetailView.swift // SportsTime // import SwiftUI import SwiftData import MapKit import Combine struct TripDetailView: View { @Environment(\.modelContext) private var modelContext @Environment(\.colorScheme) private var colorScheme let trip: Trip private let providedGames: [String: RichGame]? /// When true, shows Add buttons and custom items (only for saved trips) private let allowCustomItems: Bool @Query private var savedTrips: [SavedTrip] @State private var showProPaywall = false @State private var selectedDay: ItineraryDay? @State private var showExportSheet = false @State private var exportURL: 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 @State private var loadedGames: [String: RichGame] = [:] @State private var isLoadingGames = false // Custom items state @State private var customItems: [CustomItineraryItem] = [] @State private var addItemAnchor: AddItemAnchor? @State private var editingItem: CustomItineraryItem? @State private var subscriptionCancellable: AnyCancellable? @State private var draggedItem: CustomItineraryItem? private let exportService = ExportService() private let dataProvider = AppDataProvider.shared /// Games dictionary - uses provided games if available, otherwise uses loaded games private var games: [String: RichGame] { providedGames ?? loadedGames } /// Initialize with trip and games dictionary (existing callers - no custom items) init(trip: Trip, games: [String: RichGame]) { self.trip = trip self.providedGames = games self.allowCustomItems = false } /// Initialize with just trip - games will be loaded from AppDataProvider (no custom items) init(trip: Trip) { self.trip = trip self.providedGames = nil self.allowCustomItems = false } /// Initialize for saved trip context - enables custom items init(trip: Trip, games: [String: RichGame], allowCustomItems: Bool) { self.trip = trip self.providedGames = games self.allowCustomItems = allowCustomItems } /// Initialize for saved trip context without provided games - loads from AppDataProvider init(trip: Trip, allowCustomItems: Bool) { self.trip = trip self.providedGames = nil self.allowCustomItems = allowCustomItems } 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) { ShareButton(trip: trip, style: .icon) .foregroundStyle(Theme.warmOrange) Button { if StoreManager.shared.isPro { Task { await exportPDF() } } else { showProPaywall = true } } label: { HStack(spacing: 2) { Image(systemName: "doc.fill") if !StoreManager.shared.isPro { ProBadge() } } .foregroundStyle(Theme.warmOrange) } } } .sheet(isPresented: $showExportSheet) { if let url = exportURL { ShareSheet(items: [url]) } } .sheet(isPresented: $showProPaywall) { PaywallView() } .sheet(item: $addItemAnchor) { anchor in AddItemSheet( tripId: trip.id, anchorDay: anchor.day, anchorType: anchor.type, anchorId: anchor.anchorId, existingItem: nil ) { item in Task { await saveCustomItem(item) } } } .sheet(item: $editingItem) { item in AddItemSheet( tripId: trip.id, anchorDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, existingItem: item ) { updatedItem in Task { await saveCustomItem(updatedItem) } } } .onAppear { checkIfSaved() } .task { await loadGamesIfNeeded() if allowCustomItems { await loadCustomItems() await setupSubscription() } } .onDisappear { subscriptionCancellable?.cancel() } .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(.title2) .foregroundStyle(Theme.warmOrange) } VStack(spacing: Theme.Spacing.xs) { Text("Creating PDF") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(exportProgress?.currentStep ?? "Preparing...") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) if let progress = exportProgress { Text("\(Int(progress.percentComplete * 100))%") .font(.caption.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, interactionModes: []) { 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(.title3) .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 { LoadingSpinner(size: .medium) .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(.subheadline) .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(.caption2) Text(sport.rawValue) .font(.caption2) } .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(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text(score.scoreGrade) .font(.largeTitle) .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(.headline) .foregroundStyle(color) Text(label) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } } // MARK: - Itinerary private var itinerarySection: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { Text("Itinerary") .font(.title2) .foregroundStyle(Theme.textPrimary(colorScheme)) if isLoadingGames { HStack { Spacer() ProgressView("Loading games...") .padding(.vertical, Theme.Spacing.xl) Spacer() } } else { // ZStack with continuous vertical line behind all content ZStack(alignment: .top) { // Continuous vertical line down the center Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 2) .frame(maxHeight: .infinity) // Itinerary content VStack(spacing: Theme.Spacing.md) { ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in itineraryRow(for: section, at: index) } } } } } } @ViewBuilder private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View { switch section { case .day(let dayNumber, let date, let gamesOnDay): DaySection( dayNumber: dayNumber, date: date, games: gamesOnDay ) .staggeredAnimation(index: index) .dropDestination(for: String.self) { items, _ in guard let itemIdString = items.first, let itemId = UUID(uuidString: itemIdString), let item = customItems.first(where: { $0.id == itemId }), let lastGame = gamesOnDay.last else { return false } Task { await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id) } return true } case .travel(let segment): TravelSection(segment: segment) .staggeredAnimation(index: index) .dropDestination(for: String.self) { items, _ in guard let itemIdString = items.first, let itemId = UUID(uuidString: itemIdString), let item = customItems.first(where: { $0.id == itemId }) else { return false } // Find the day for this travel segment let day = findDayForTravelSegment(segment) Task { await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString) } return true } case .customItem(let item): CustomItemRow( item: item, onTap: { editingItem = item }, onDelete: { Task { await deleteCustomItem(item) } } ) .staggeredAnimation(index: index) .draggable(item.id.uuidString) { // Drag preview CustomItemRow( item: item, onTap: {}, onDelete: {} ) .frame(width: 300) .opacity(0.8) } .dropDestination(for: String.self) { items, _ in guard let itemIdString = items.first, let itemId = UUID(uuidString: itemIdString), let draggedItem = customItems.first(where: { $0.id == itemId }), draggedItem.id != item.id else { return false } Task { await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item) } return true } case .addButton(let day, let anchorType, let anchorId): InlineAddButton { addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId) } .dropDestination(for: String.self) { items, _ in guard let itemIdString = items.first, let itemId = UUID(uuidString: itemIdString), let item = customItems.first(where: { $0.id == itemId }) else { return false } Task { await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId) } return true } } } private func findDayForTravelSegment(_ segment: TravelSegment) -> Int { // Find which day this travel segment belongs to by looking at sections for (index, section) in itinerarySections.enumerated() { if case .travel(let s) = section, s.id == segment.id { // Look backwards to find the day for i in stride(from: index - 1, through: 0, by: -1) { if case .day(let dayNumber, _, _) = itinerarySections[i] { return dayNumber } } } } return 1 } private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async { var updated = item updated.anchorDay = day updated.anchorType = anchorType updated.anchorId = anchorId // Calculate sortOrder let itemsAtSameAnchor = customItems.filter { $0.anchorDay == day && $0.anchorType == anchorType && $0.anchorId == anchorId && $0.id != item.id }.sorted { $0.sortOrder < $1.sortOrder } if let beforeItem = beforeItem, let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) { updated.sortOrder = beforeIndex // Shift other items for i in beforeIndex.. 0 { let prevSection = dayCitySections[index - 1] let prevCity = prevSection.city let currentCity = section.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)) if allowCustomItems { // Add button after travel sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString)) // Custom items after this travel let itemsAfterTravel = customItems.filter { $0.anchorDay == section.dayNumber && $0.anchorType == .afterTravel && $0.anchorId == travelSegment.id.uuidString } for item in itemsAfterTravel { sections.append(.customItem(item)) } } } } } if allowCustomItems { // Custom items at start of day let itemsAtStart = customItems.filter { $0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay } for item in itemsAtStart { sections.append(.customItem(item)) } } // Add the day section sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games)) if allowCustomItems { // Add button after day's games if let lastGame = section.games.last { sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)) // Custom items after this game let itemsAfterGame = customItems.filter { $0.anchorDay == section.dayNumber && $0.anchorType == .afterGame && $0.anchorId == lastGame.game.id } for item in itemsAfterGame { sections.append(.customItem(item)) } } } } 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 } let foundGames = allGameIds.compactMap { games[$0] } return foundGames.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 /// Load games from AppDataProvider if not provided private func loadGamesIfNeeded() async { // Skip if games were provided guard providedGames == nil else { return } // Collect all game IDs from the trip let gameIds = trip.stops.flatMap { $0.games } guard !gameIds.isEmpty else { return } isLoadingGames = true // Load RichGame data from AppDataProvider var loaded: [String: RichGame] = [:] for gameId in gameIds { do { if let game = try await dataProvider.fetchGame(by: gameId), let richGame = dataProvider.richGame(from: game) { loaded[gameId] = richGame } } catch { // Skip games that fail to load } } loadedGames = loaded isLoadingGames = false } 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 toggleSaved() { if isSaved { unsaveTrip() } else { saveTrip() } } private func saveTrip() { // Check trip limit for free users if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit { showProPaywall = true return } 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( 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( predicate: #Predicate { $0.id == tripId } ) if let count = try? modelContext.fetchCount(descriptor), count > 0 { isSaved = true } } // MARK: - Custom Items (CloudKit persistence) private func setupSubscription() async { // Subscribe to real-time updates for this trip do { try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id) // Listen for changes and reload subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher .filter { $0 == self.trip.id } .receive(on: DispatchQueue.main) .sink { [self] _ in print("📡 [Subscription] Received update, reloading custom items...") Task { await loadCustomItems() } } } catch { print("📡 [Subscription] Failed to subscribe: \(error)") } } private func loadCustomItems() async { print("🔍 [CustomItems] Loading items for trip: \(trip.id)") do { let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id) print("✅ [CustomItems] Loaded \(items.count) items from CloudKit") for item in items { print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue))") } customItems = items } catch { print("❌ [CustomItems] Failed to load: \(error)") } } private func saveCustomItem(_ item: CustomItineraryItem) async { // Check if this is an update or create let isUpdate = customItems.contains(where: { $0.id == item.id }) print("💾 [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))") print(" - tripId: \(item.tripId)") print(" - anchorDay: \(item.anchorDay), anchorType: \(item.anchorType.rawValue)") // Update local state immediately for responsive UI if isUpdate { if let index = customItems.firstIndex(where: { $0.id == item.id }) { customItems[index] = item } } else { customItems.append(item) } // Persist to CloudKit do { if isUpdate { let updated = try await CustomItemService.shared.updateItem(item) print("✅ [CustomItems] Updated in CloudKit: \(updated.title)") } else { let created = try await CustomItemService.shared.createItem(item) print("✅ [CustomItems] Created in CloudKit: \(created.title)") } } catch { print("❌ [CustomItems] CloudKit save failed: \(error)") } } private func deleteCustomItem(_ item: CustomItineraryItem) async { print("🗑️ [CustomItems] Deleting item: '\(item.title)'") // Remove from local state immediately customItems.removeAll { $0.id == item.id } // Delete from CloudKit do { try await CustomItemService.shared.deleteItem(item.id) print("✅ [CustomItems] Deleted from CloudKit") } catch { print("❌ [CustomItems] CloudKit delete failed: \(error)") } } } // MARK: - Itinerary Section enum ItinerarySection { case day(dayNumber: Int, date: Date, games: [RichGame]) case travel(TravelSegment) case customItem(CustomItineraryItem) case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) var isCustomItem: Bool { if case .customItem = self { return true } return false } } // MARK: - Add Item Anchor (for sheet) struct AddItemAnchor: Identifiable { let id = UUID() let day: Int let type: CustomItineraryItem.AnchorType let anchorId: String? } // MARK: - Inline Add Button private struct InlineAddButton: View { let action: () -> Void var body: some View { Button(action: action) { HStack { Image(systemName: "plus.circle.fill") .foregroundStyle(Theme.warmOrange.opacity(0.6)) Text("Add") .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) .padding(.horizontal, 8) } .buttonStyle(.plain) } } // 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(.title2) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(formattedDate) .font(.subheadline) .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(.subheadline) .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(.caption2) Text(game.game.sport.rawValue) .font(.caption2) } .foregroundStyle(game.game.sport.themeColor) // Matchup HStack(spacing: 4) { Text(game.awayTeam.abbreviation) .font(.body) Text("@") .foregroundStyle(Theme.textMuted(colorScheme)) Text(game.homeTeam.abbreviation) .font(.body) } .foregroundStyle(Theme.textPrimary(colorScheme)) } // Stadium HStack(spacing: 4) { Image(systemName: "building.2") .font(.caption2) Text(game.stadium.name) .font(.subheadline) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() // Time Text(game.localGameTimeShort) .font(.subheadline) .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 { // 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(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) Text("\(segment.fromLocation.name) → \(segment.toLocation.name)") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(segment.formattedDistance) .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(segment.formattedDuration) .font(.caption) .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(.caption) Text("\(segment.evChargingStops.count) EV Charger\(segment.evChargingStops.count > 1 ? "s" : "") Along Route") .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Image(systemName: showEVChargers ? "chevron.up" : "chevron.down") .font(.caption) .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) } } } // 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(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) chargerTypeBadge } HStack(spacing: Theme.Spacing.xs) { if let address = charger.location.address { Text(address) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } Text("•") .foregroundStyle(Theme.textMuted(colorScheme)) Text("~\(charger.formattedChargeTime) charge") .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } } Spacer() } .padding(.vertical, 6) } @ViewBuilder private var chargerTypeBadge: some View { let (text, color) = chargerTypeInfo Text(text) .font(.caption2) .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") ) ) ) } }