// // TripDetailView.swift // SportsTime // import SwiftUI import SwiftData import MapKit import UniformTypeIdentifiers struct TripDetailView: View { @Environment(\.modelContext) private var modelContext @Environment(\.colorScheme) private var colorScheme @Environment(\.isDemoMode) private var isDemoMode 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 routeCoordinates: [[CLLocationCoordinate2D]] = [] @State private var mapUpdateTrigger = UUID() // Force map refresh @State private var isLoadingRoutes = false @State private var loadedGames: [String: RichGame] = [:] @State private var isLoadingGames = false @State private var hasAppliedDemoSelection = false @State private var demoSaveTask: Task? // Itinerary items state @State private var itineraryItems: [ItineraryItem] = [] @State private var addItemAnchor: AddItemAnchor? @State private var editingItem: ItineraryItem? @State private var mapUpdateTask: Task? @State private var draggedItem: ItineraryItem? @State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var dropTargetId: String? // Track which drop zone is being hovered @State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder @State private var cachedSections: [ItinerarySection] = [] @State private var cachedTripDays: [Date] = [] @State private var cachedRouteWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = [] // Apple Maps state @State private var showMultiRouteAlert = false @State private var multiRouteChunks: [[MKMapItem]] = [] 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 { bodyContent } @ViewBuilder private var bodyContent: some View { mainContent .background(Theme.backgroundGradient(colorScheme)) .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) .toolbar { toolbarContent } .modifier(SheetModifiers( showExportSheet: $showExportSheet, exportURL: exportURL, showProPaywall: $showProPaywall, addItemAnchor: $addItemAnchor, editingItem: $editingItem, tripId: trip.id, saveItineraryItem: saveItineraryItem, deleteItineraryItem: deleteItineraryItem )) .alert("Large Trip Route", isPresented: $showMultiRouteAlert) { ForEach(multiRouteChunks.indices, id: \.self) { index in Button("Open Part \(index + 1) of \(multiRouteChunks.count)") { AppleMapsLauncher.open(chunk: index, of: multiRouteChunks) } } Button("Cancel", role: .cancel) { } } message: { Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?") } .onAppear { AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new")) checkIfSaved() // Demo mode: auto-favorite the trip if isDemoMode && !hasAppliedDemoSelection && !isSaved { hasAppliedDemoSelection = true demoSaveTask = Task { try? await Task.sleep(for: .seconds(DemoConfig.selectionDelay + 0.5)) guard !Task.isCancelled, !isSaved else { return } saveTrip() } } } .task { await loadGamesIfNeeded() if allowCustomItems { await loadItineraryItems() } recomputeTripDays() recomputeSections() recomputeRouteWaypoints() } .onDisappear { mapUpdateTask?.cancel() demoSaveTask?.cancel() } .onChange(of: itineraryItems) { _, newItems in handleItineraryItemsChange(newItems) recomputeTripDays() recomputeSections() recomputeRouteWaypoints() } .onChange(of: travelOverrides.count) { _, _ in draggedTravelId = nil dropTargetId = nil recomputeTripDays() recomputeSections() recomputeRouteWaypoints() } .onChange(of: loadedGames.count) { _, _ in recomputeTripDays() recomputeSections() recomputeRouteWaypoints() } .overlay { if isExporting { exportProgressOverlay } } } @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { 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) } .accessibilityLabel("Export trip as PDF") .accessibilityIdentifier("tripDetail.pdfExportButton") } } private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) { draggedItem = nil dropTargetId = nil print("πŸ—ΊοΈ [MapUpdate] itineraryItems changed, count: \(newItems.count)") for item in newItems { if item.isCustom, let info = item.customInfo, info.isMappable { print("πŸ—ΊοΈ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") } } mapUpdateTask?.cancel() mapUpdateTask = Task { updateMapRegion() await fetchDrivingRoutes() } } // MARK: - Main Content @ViewBuilder private var mainContent: some View { if allowCustomItems { // Full-screen table with map as header ItineraryTableViewWrapper( trip: trip, games: Array(games.values), itineraryItems: itineraryItems, travelOverrides: travelOverrides, headerContent: { VStack(spacing: 0) { // Hero Map heroMapSection .frame(height: 280) // Content header VStack(spacing: Theme.Spacing.lg) { tripHeader .padding(.top, Theme.Spacing.lg) statsRow if let score = trip.score { scoreCard(score) } // Itinerary title Text("Itinerary") .font(.title2) .fontWeight(.bold) .foregroundStyle(Theme.textPrimary(colorScheme)) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.md) } }, onTravelMoved: { travelId, newDay, newSortOrder in Task { @MainActor in withAnimation { travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder) } await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay, sortOrder: newSortOrder) } }, onCustomItemMoved: { itemId, day, sortOrder in Task { @MainActor in guard let item = itineraryItems.first(where: { $0.id == itemId }) else { return } await moveItem(item, toDay: day, sortOrder: sortOrder) } }, onCustomItemTapped: { item in editingItem = item }, onCustomItemDeleted: { item in Task { await deleteItineraryItem(item) } }, onAddButtonTapped: { day in let coord = coordinateForDay(day) addItemAnchor = AddItemAnchor(day: day, regionCoordinate: coord) } ) .ignoresSafeArea(edges: .bottom) } else { // Non-editable scroll view for unsaved trips ScrollViewReader { proxy in ScrollView { VStack(spacing: 0) { heroMapSection .frame(height: 280) VStack(spacing: Theme.Spacing.lg) { tripHeader .padding(.top, Theme.Spacing.lg) statsRow if let score = trip.score { scoreCard(score) } itinerarySection } .padding(.horizontal, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.xxl) Color.clear .frame(height: 1) .id("tripDetailBottom") } .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in draggedTravelId = nil draggedItem = nil dropTargetId = nil return true } } #if DEBUG .onAppear { if UserDefaults.standard.bool(forKey: "marketingVideoMode") { DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { withAnimation(.easeInOut(duration: 6.0)) { proxy.scrollTo("tripDetailBottom", anchor: .bottom) } } } } #endif } } } // 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( Theme.Animation.prefersReducedMotion ? nil : .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) { TripMapView( cameraPosition: $mapCameraPosition, routeCoordinates: routeCoordinates, stopCoordinates: stopCoordinates, customItems: mappableCustomItems, colorScheme: colorScheme, routeVersion: mapUpdateTrigger ) .id("map-\(mapUpdateTrigger)") .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) } .accessibilityIdentifier("tripDetail.favoriteButton") .accessibilityLabel(isSaved ? "Remove from favorites" : "Save to favorites") .padding(.top, 16) .padding(.trailing, 16) } // Gradient overlay at bottom LinearGradient( colors: [.clear, Theme.cardBackground(colorScheme).opacity(0.8), Theme.cardBackground(colorScheme)], startPoint: .top, endPoint: .bottom ) .frame(height: 80) // Open in Apple Maps button β€” above gradient in ZStack Button { openInAppleMaps() } label: { Image(systemName: "map.fill") .font(.title3) .foregroundStyle(.white) .padding(12) .background(Theme.warmOrange) .clipShape(Circle()) .shadow(color: .black.opacity(0.3), radius: 4, y: 2) } .accessibilityLabel("Open in Apple Maps") .accessibilityHint("Opens this trip route in Apple Maps") .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(.bottom, 16) .padding(.trailing, 16) // 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) } .accessibilityElement(children: .ignore) .accessibilityLabel(sport.rawValue) .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) } } .accessibilityIdentifier("tripDetail.statsRow") } // 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 (for non-editable scroll view) 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 { // Non-editable view for non-saved trips ZStack(alignment: .top) { Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 2) .frame(maxHeight: .infinity) VStack(spacing: Theme.Spacing.md) { ForEach(Array(cachedSections.enumerated()), id: \.offset) { index, section in itineraryRow(for: section, at: index) } } } } } } @ViewBuilder private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View { let sectionId = sectionIdentifier(for: section, at: index) let isDragging = draggedItem != nil || draggedTravelId != nil let isDropTarget = dropTargetId == sectionId && isDragging switch section { case .day(let dayNumber, let date, let gamesOnDay): // Show indicator at TOP for travel (travel appears above day), BOTTOM for custom items let indicatorAlignment: Alignment = draggedTravelId != nil ? .top : .bottom // Pre-compute if this day is a valid travel target let isValidTravelTarget: Bool = { guard let travelId = draggedTravelId, let validRange = validDayRange(for: travelId) else { return true } return validRange.contains(dayNumber) }() DaySection( dayNumber: dayNumber, date: date, games: gamesOnDay ) .staggeredAnimation(index: index) .overlay(alignment: indicatorAlignment) { // Only show indicator if valid target (or dragging custom item) if isDropTarget && (draggedTravelId == nil || isValidTravelTarget) { DropTargetIndicator() } } .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( get: { dropTargetId == sectionId }, set: { targeted in // Only show as target if it's a valid drop location let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget) Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShowTarget { dropTargetId = sectionId } else if dropTargetId == sectionId { dropTargetId = nil } } } )) { providers in handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay) } case .travel(let segment, let segmentIndex): let travelId = stableTravelAnchorId(segment, at: segmentIndex) TravelSection(segment: segment) .staggeredAnimation(index: index) .overlay(alignment: .bottom) { // Show drop indicator for custom items, but not when dragging this travel if isDropTarget && draggedTravelId != travelId { DropTargetIndicator() } } .onDrag { draggedTravelId = travelId return NSItemProvider(object: travelId as NSString) } .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( get: { dropTargetId == sectionId }, set: { targeted in // Only accept custom items on travel, not other travel let shouldShow = targeted && draggedItem != nil Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShow { dropTargetId = sectionId } else if dropTargetId == sectionId { dropTargetId = nil } } } )) { providers in handleTravelDrop(providers: providers, segment: segment) } case .customItem(let item): let isDraggingThis = draggedItem?.id == item.id CustomItemRow( item: item, onTap: { editingItem = item } ) .contextMenu { Button(role: .destructive) { Task { await deleteItineraryItem(item) } } label: { Label("Delete", systemImage: "trash") } } .opacity(isDraggingThis ? 0.4 : 1.0) .staggeredAnimation(index: index) .overlay(alignment: .top) { if isDropTarget && !isDraggingThis { DropTargetIndicator() } } .onDrag { draggedItem = item return NSItemProvider(object: item.id.uuidString as NSString) } .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( get: { dropTargetId == sectionId }, set: { targeted in // Only accept custom items, not travel let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShow { dropTargetId = sectionId } else if dropTargetId == sectionId { dropTargetId = nil } } } )) { providers in handleCustomItemDrop(providers: providers, targetItem: item) } case .addButton(let day): VStack(spacing: 0) { if isDropTarget { DropTargetIndicator() } InlineAddButton { let coord = coordinateForDay(day) addItemAnchor = AddItemAnchor(day: day, regionCoordinate: coord) } } .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( get: { dropTargetId == sectionId }, set: { targeted in // Only accept custom items, not travel let shouldShow = targeted && draggedItem != nil Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShow { dropTargetId = sectionId } else if dropTargetId == sectionId { dropTargetId = nil } } } )) { providers in handleAddButtonDrop(providers: providers, day: day) } } } // MARK: - Drop Handlers private func handleTravelDrop(providers: [NSItemProvider], segment: TravelSegment) -> Bool { guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { return false } // Clear drag state immediately (synchronously) before async work draggedTravelId = nil draggedItem = nil dropTargetId = nil provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId) else { return } Task { @MainActor in guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return } let day = self.findDayForTravelSegment(segment) // Place at beginning of day (sortOrder before existing items) let minSortOrder = self.itineraryItems .filter { $0.day == day && $0.id != droppedItem.id } .map { $0.sortOrder } .min() ?? 1.0 await self.moveItem(droppedItem, toDay: day, sortOrder: minSortOrder / 2.0) } } return true } private func handleCustomItemDrop(providers: [NSItemProvider], targetItem: ItineraryItem) -> Bool { guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { return false } // Clear drag state immediately (synchronously) before async work draggedTravelId = nil draggedItem = nil dropTargetId = nil provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId) else { return } Task { @MainActor in guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }), droppedItem.id != targetItem.id else { return } // Place before target item using midpoint insertion let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id } .sorted { $0.sortOrder < $1.sortOrder } let targetIdx = itemsInDay.firstIndex(where: { $0.id == targetItem.id }) ?? 0 let prevSortOrder = targetIdx > 0 ? itemsInDay[targetIdx - 1].sortOrder : 0.0 let newSortOrder = (prevSortOrder + targetItem.sortOrder) / 2.0 await self.moveItem(droppedItem, toDay: targetItem.day, sortOrder: newSortOrder) } } return true } private func handleAddButtonDrop(providers: [NSItemProvider], day: Int) -> Bool { guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { return false } // Clear drag state immediately (synchronously) before async work draggedTravelId = nil draggedItem = nil dropTargetId = nil provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId) else { return } Task { @MainActor in guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return } // Calculate sortOrder: append at end of day's items let maxSortOrder = self.itineraryItems .filter { $0.day == day && $0.id != droppedItem.id } .map { $0.sortOrder } .max() ?? 0.0 await self.moveItem(droppedItem, toDay: day, sortOrder: maxSortOrder + 1.0) } } return true } private func clearDragState() { draggedItem = nil draggedTravelId = nil dropTargetId = nil } /// Create a stable identifier for an itinerary section (for drop target tracking) private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String { switch section { case .day(let dayNumber, _, _): return "day-\(dayNumber)" case .travel(let segment, let segmentIndex): return "travel-\(segmentIndex)-\(segment.fromLocation.name)-\(segment.toLocation.name)" case .customItem(let item): return "item-\(item.id.uuidString)" case .addButton(let day): return "add-\(day)" } } private func findDayForTravelSegment(_ segment: TravelSegment) -> Int { // Find which day this travel segment belongs to by looking at sections // Travel appears BEFORE the arrival day, so look FORWARD to find arrival day for (index, section) in cachedSections.enumerated() { if case .travel(let s, _) = section, s.id == segment.id { // Look forward to find the arrival day for i in (index + 1).. String { ItinerarySectionBuilder.stableTravelAnchorId(segment, at: index) } /// Move item to a new day and sortOrder position private func moveItem(_ item: ItineraryItem, toDay day: Int, sortOrder: Double) async { var updated = item updated.day = day updated.sortOrder = sortOrder updated.modifiedAt = Date() let title = item.customInfo?.title ?? "item" print("πŸ“ [Move] Moving \(title) to day \(day), sortOrder: \(sortOrder)") // Update local state if let idx = itineraryItems.firstIndex(where: { $0.id == item.id }) { itineraryItems[idx] = updated } // Persist locally saveItemLocally(updated, isUpdate: true) // Sync to CloudKit (debounced) await ItineraryItemService.shared.updateItem(updated) print("βœ… [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)") } /// Recompute cached itinerary sections from current state. private func recomputeSections() { cachedSections = ItinerarySectionBuilder.build( trip: trip, tripDays: tripDays, games: games, travelOverrides: travelOverrides, itineraryItems: itineraryItems, allowCustomItems: allowCustomItems ) } private var tripDays: [Date] { cachedTripDays } private func recomputeTripDays() { let calendar = Calendar.current guard let startDate = trip.stops.first?.arrivalDate, let endDate = trip.stops.last?.departureDate else { cachedTripDays = [] 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)! } cachedTripDays = 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 } /// Convert a date to a 1-based day number within the trip. /// Returns 0 if the date is before the trip, or tripDays.count + 1 if after. private func dayNumber(for date: Date) -> Int { let calendar = Calendar.current let target = calendar.startOfDay(for: date) let days = tripDays for (index, tripDay) in days.enumerated() { if calendar.startOfDay(for: tripDay) == target { return index + 1 } } // Date is outside the trip range if let firstDay = days.first, target < firstDay { return 0 } return days.count + 1 } /// Get valid day range for a travel segment using stop indices. /// Uses the from/to stop dates so repeat cities don't confuse placement. private func validDayRange(for travelId: String) -> ClosedRange? { // Parse segment index from travel ID (format: "travel:INDEX:from->to") guard let segmentIndex = Self.parseSegmentIndex(from: travelId), segmentIndex < trip.stops.count - 1 else { return nil } let fromStop = trip.stops[segmentIndex] let toStop = trip.stops[segmentIndex + 1] let fromDayNum = dayNumber(for: fromStop.departureDate) let toDayNum = dayNumber(for: toStop.arrivalDate) let minDay = max(fromDayNum + 1, 1) let maxDay = min(toDayNum, tripDays.count) if minDay > maxDay { return nil } return minDay...maxDay } /// Parse the segment index from a travel anchor ID. /// Format: "travel:INDEX:from->to" β†’ INDEX private static func parseSegmentIndex(from travelId: String) -> Int? { let stripped = travelId.replacingOccurrences(of: "travel:", with: "") let parts = stripped.components(separatedBy: ":") guard parts.count >= 2, let index = Int(parts[0]) else { return nil } return index } /// Canonicalize travel IDs to the current segment's normalized city pair. private func canonicalTravelAnchorId(from travelId: String) -> String? { guard let segmentIndex = Self.parseSegmentIndex(from: travelId), segmentIndex >= 0, segmentIndex < trip.travelSegments.count else { return nil } let segment = trip.travelSegments[segmentIndex] return stableTravelAnchorId(segment, at: segmentIndex) } // MARK: - Map Helpers private func fetchDrivingRoutes() async { // Use routeWaypoints which includes game stops + mappable custom items let waypoints = routeWaypoints guard waypoints.count >= 2 else { return } isLoadingRoutes = true var allCoordinates: [[CLLocationCoordinate2D]] = [] for i in 0..<(waypoints.count - 1) { let source = waypoints[i] let destination = waypoints[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 { // Extract coordinates from MKPolyline let polyline = route.polyline let pointCount = polyline.pointCount var coords: [CLLocationCoordinate2D] = [] let points = polyline.points() for j in 0.. (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 } } /// Mappable custom items for display on the map private var mappableCustomItems: [ItineraryItem] { itineraryItems.filter { $0.isCustom && $0.customInfo?.isMappable == true } } /// Convert stored route coordinates to MKPolyline for rendering private var routePolylinesFromCoords: [MKPolyline] { routeCoordinates.map { coords in MKPolyline(coordinates: coords, count: coords.count) } } /// Route waypoints including both game stops and mappable custom items in itinerary order private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] { cachedRouteWaypoints } private func recomputeRouteWaypoints() { // Build an ordered list combining game stops and mappable custom items // Items are ordered by (day, sortOrder) - visual order matches route order let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day } #if DEBUG print("πŸ—ΊοΈ [Waypoints] Building waypoints. Mappable items by day:") for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) { for item in items.sorted(by: { $0.sortOrder < $1.sortOrder }) { let title = item.customInfo?.title ?? "item" print("πŸ—ΊοΈ [Waypoints] Day \(day): \(title), sortOrder: \(item.sortOrder)") } } #endif var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = [] let days = tripDays #if DEBUG print("πŸ—ΊοΈ [Waypoints] Trip has \(days.count) days") #endif for (dayIndex, dayDate) in days.enumerated() { let dayNumber = dayIndex + 1 // Find games on this day let gamesOnDay = gamesOn(date: dayDate) let calendar = Calendar.current let dayCity = gamesOnDay.first?.stadium.city ?? trip.stops.first(where: { stop in let arrival = calendar.startOfDay(for: stop.arrivalDate) let departure = calendar.startOfDay(for: stop.departureDate) let day = calendar.startOfDay(for: dayDate) return day >= arrival && day <= departure })?.city #if DEBUG print("πŸ—ΊοΈ [Waypoints] Day \(dayNumber): city=\(dayCity ?? "none"), games=\(gamesOnDay.count)") #endif // Game stop for this day (only add once per city to avoid duplicates) if let city = dayCity { // Check if we already have this city in waypoints (by city name or stadium name) let alreadyHasCity = waypoints.contains(where: { wp in if wp.isCustomItem { return false } // Check by city name if wp.name == city { return true } // Check by stadium name for this city if let stop = trip.stops.first(where: { $0.city == city }), let stadiumId = stop.stadium, let stadium = dataProvider.stadium(for: stadiumId), wp.name == stadium.name { return true } return false }) if !alreadyHasCity { if let stop = trip.stops.first(where: { $0.city == city }) { if let stadiumId = stop.stadium, let stadium = dataProvider.stadium(for: stadiumId) { waypoints.append((stadium.name, stadium.coordinate, false)) } else if let coord = stop.coordinate { waypoints.append((city, coord, false)) } } } else { #if DEBUG print("πŸ—ΊοΈ [Waypoints] \(city) already in waypoints, skipping") #endif } } // Custom items for this day (ordered by sortOrder - visual order matches route) if let items = itemsByDay[dayNumber] { let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder } for item in sortedItems { if let info = item.customInfo, let coord = info.coordinate { waypoints.append((info.title, coord, true)) } } } } cachedRouteWaypoints = waypoints } private func updateMapRegion() { // Include both game stops and mappable custom items in region calculation let waypoints = routeWaypoints guard !waypoints.isEmpty else { return } let coordinates = waypoints.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 AnalyticsManager.shared.track(.pdfExportStarted(tripId: trip.id.uuidString, stopCount: trip.stops.count)) do { // Build complete itinerary items (games + travel + custom) let completeItems = buildCompleteItineraryItems() let url = try await exportService.exportToPDF( trip: trip, games: games, itineraryItems: completeItems ) { progress in await MainActor.run { self.exportProgress = progress } } exportURL = url showExportSheet = true AnalyticsManager.shared.track(.pdfExportCompleted(tripId: trip.id.uuidString)) } catch { AnalyticsManager.shared.track(.pdfExportFailed(tripId: trip.id.uuidString, error: error.localizedDescription)) } isExporting = false } /// Build complete itinerary items by merging games, travel, and custom items private func buildCompleteItineraryItems() -> [ItineraryItem] { var allItems: [ItineraryItem] = [] // Get itinerary days from trip let tripDays = trip.itineraryDays() // 1. Add game items using day.gameIds (reliable source from trip stops) for day in tripDays { for (gameIndex, gameId) in day.gameIds.enumerated() { guard let richGame = games[gameId] else { continue } let gameItem = ItineraryItem( tripId: trip.id, day: day.dayNumber, sortOrder: Double(gameIndex) * 0.01, // Games near the start of the day kind: .game(gameId: gameId, city: richGame.stadium.city) ) allItems.append(gameItem) } } // 2. Add travel items (from trip segments + overrides) for (segmentIndex, segment) in trip.travelSegments.enumerated() { let travelId = stableTravelAnchorId(segment, at: segmentIndex) // Use override if available, otherwise default to day 1 let override = travelOverrides[travelId] let day = override?.day ?? 1 let sortOrder = override?.sortOrder ?? 100.0 // After games by default let travelItem = ItineraryItem( tripId: trip.id, day: day, sortOrder: sortOrder, kind: .travel(TravelInfo( segment: segment, segmentIndex: segmentIndex, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds )) ) allItems.append(travelItem) } // 3. Add custom items (from CloudKit) let customItems = itineraryItems.filter { $0.isCustom } allItems.append(contentsOf: customItems) return allItems } /// Returns the coordinate of the first stop on the given day, for location-biased search. private func coordinateForDay(_ day: Int) -> CLLocationCoordinate2D? { let tripDay = trip.itineraryDays().first { $0.dayNumber == day } return tripDay?.stops.first?.coordinate } private func toggleSaved() { if isSaved { unsaveTrip() } else { saveTrip() } } private func openInAppleMaps() { let result = AppleMapsLauncher.prepare( stops: trip.stops, customItems: mappableCustomItems ) switch result { case .ready(let mapItems): AppleMapsLauncher.open(mapItems) case .multipleRoutes(let chunks): multiRouteChunks = chunks showMultiRouteAlert = true case .noWaypoints: // No routable locations - button shouldn't be visible but handle gracefully break } } 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() Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = true } AnalyticsManager.shared.track(.tripSaved( tripId: trip.id.uuidString, stopCount: trip.stops.count, gameCount: trip.totalGames )) } 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() Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = false } AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString)) } 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 } else { isSaved = false } } // MARK: - Itinerary Items (Local-first persistence with CloudKit sync) private func loadItineraryItems() async { print("πŸ” [ItineraryItems] Loading items for trip: \(trip.id)") // 1. Load from local SwiftData first (instant, works offline) let tripId = trip.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.tripId == tripId } ) let localItems = (try? modelContext.fetch(descriptor))?.compactMap(\.toItem) ?? [] if !localItems.isEmpty { print("βœ… [ItineraryItems] Loaded \(localItems.count) items from local cache") itineraryItems = localItems extractTravelOverrides(from: localItems) } // 2. Try CloudKit for latest data (background sync) do { let cloudItems = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id) print("βœ… [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit") // Merge: use CloudKit as source of truth when available if !cloudItems.isEmpty || localItems.isEmpty { itineraryItems = cloudItems extractTravelOverrides(from: cloudItems) syncLocalCache(with: cloudItems) } } catch { print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)") } } private func extractTravelOverrides(from items: [ItineraryItem]) { var overrides: [String: TravelOverride] = [:] for item in items where item.isTravel { guard let travelInfo = item.travelInfo else { continue } if let segIdx = travelInfo.segmentIndex, segIdx >= 0, segIdx < trip.travelSegments.count { let segment = trip.travelSegments[segIdx] if !travelInfo.matches(segment: segment) { print("⚠️ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities") } let travelId = stableTravelAnchorId(segment, at: segIdx) overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) continue } let matches = trip.travelSegments.enumerated().filter { _, segment in travelInfo.matches(segment: segment) } guard matches.count == 1, let match = matches.first else { print("⚠️ [TravelOverrides] Ignoring ambiguous legacy travel override \(travelInfo.fromCity)->\(travelInfo.toCity)") continue } let segIdx = match.offset let segment = match.element let travelId = stableTravelAnchorId(segment, at: segIdx) overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) } travelOverrides = overrides print("βœ… [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)") } private func syncLocalCache(with items: [ItineraryItem]) { let tripId = trip.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.tripId == tripId } ) let existing = (try? modelContext.fetch(descriptor)) ?? [] for old in existing { modelContext.delete(old) } for item in items { if let local = LocalItineraryItem.from(item) { modelContext.insert(local) } } try? modelContext.save() } private func saveItineraryItem(_ item: ItineraryItem) async { let isUpdate = itineraryItems.contains(where: { $0.id == item.id }) let title = item.customInfo?.title ?? "item" print("πŸ’Ύ [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))") // Update in-memory state immediately if isUpdate { if let index = itineraryItems.firstIndex(where: { $0.id == item.id }) { itineraryItems[index] = item } } else { itineraryItems.append(item) } // Persist to local SwiftData immediately saveItemLocally(item, isUpdate: isUpdate) // Sync to CloudKit in background do { if isUpdate { await ItineraryItemService.shared.updateItem(item) print("βœ… [ItineraryItems] Updated in CloudKit: \(title)") } else { _ = try await ItineraryItemService.shared.createItem(item) print("βœ… [ItineraryItems] Created in CloudKit: \(title)") markLocalItemSynced(item.id) } } catch { print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)") } } private func saveItemLocally(_ item: ItineraryItem, isUpdate: Bool) { if isUpdate { let itemId = item.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == itemId } ) if let existing = try? modelContext.fetch(descriptor).first { existing.day = item.day existing.sortOrder = item.sortOrder existing.kindData = (try? JSONEncoder().encode(item.kind)) ?? existing.kindData existing.modifiedAt = item.modifiedAt existing.pendingSync = true } } else { if let local = LocalItineraryItem.from(item, pendingSync: true) { modelContext.insert(local) } } try? modelContext.save() } private func markLocalItemSynced(_ itemId: UUID) { let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == itemId } ) if let local = try? modelContext.fetch(descriptor).first { local.pendingSync = false try? modelContext.save() } } private func deleteItineraryItem(_ item: ItineraryItem) async { let title = item.customInfo?.title ?? "item" print("πŸ—‘οΈ [ItineraryItems] Deleting item: '\(title)'") // Remove from in-memory state itineraryItems.removeAll { $0.id == item.id } // Remove from local SwiftData let itemId = item.id let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == itemId } ) if let local = try? modelContext.fetch(descriptor).first { modelContext.delete(local) try? modelContext.save() } // Delete from CloudKit do { try await ItineraryItemService.shared.deleteItem(item.id) print("βœ… [ItineraryItems] Deleted from CloudKit") } catch { print("⚠️ [ItineraryItems] CloudKit delete failed (removed locally): \(error)") } } private func handleDayDrop(providers: [NSItemProvider], dayNumber: Int, gamesOnDay: [RichGame]) -> Bool { guard let provider = providers.first else { return false } // Capture and clear drag state immediately (synchronously) before async work // This ensures the UI resets even if validation fails let capturedTravelId = draggedTravelId let capturedItem = draggedItem draggedTravelId = nil draggedItem = nil dropTargetId = nil // Load the string from the provider if provider.canLoadObject(ofClass: NSString.self) { provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String else { return } Task { @MainActor in // Check if this is a travel segment being dropped if droppedId.hasPrefix("travel:") { guard let canonicalTravelId = self.canonicalTravelAnchorId(from: droppedId) else { return } // Validate travel is within valid bounds (day-level) if let validRange = self.validDayRange(for: canonicalTravelId) { guard validRange.contains(dayNumber) else { return } } // Choose a semantic sortOrder for dropping onto a day: // - If this day has games, default to AFTER games (positive) // - If no games, default to 1.0 // // You can later support "before games" drops by using a negative sortOrder // when the user drops above the games row. let maxSortOrderOnDay = self.itineraryItems .filter { $0.day == dayNumber } .map { $0.sortOrder } .max() ?? 0.0 let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0) withAnimation { self.travelOverrides[canonicalTravelId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder) } // Persist to CloudKit as a travel ItineraryItem await self.saveTravelDayOverride( travelAnchorId: canonicalTravelId, displayDay: dayNumber, sortOrder: newSortOrder ) return } // Otherwise, it's a custom item drop guard let itemId = UUID(uuidString: droppedId), let item = self.itineraryItems.first(where: { $0.id == itemId }) else { return } // Append at end of day's items let maxSortOrder = self.itineraryItems .filter { $0.day == dayNumber && $0.id != item.id } .map { $0.sortOrder } .max() ?? 0.0 await self.moveItem(item, toDay: dayNumber, sortOrder: maxSortOrder + 1.0) } } return true } return false } private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async { print("πŸ’Ύ [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)") guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId), segmentIndex >= 0, segmentIndex < trip.travelSegments.count else { print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)") return } let segment = trip.travelSegments[segmentIndex] let canonicalTravelId = stableTravelAnchorId(segment, at: segmentIndex) let canonicalInfo = TravelInfo(segment: segment, segmentIndex: segmentIndex) // Find existing travel item matching by segment index (preferred) or city pair (legacy fallback). if let existingIndex = itineraryItems.firstIndex(where: { guard $0.isTravel, let info = $0.travelInfo else { return false } if let itemIdx = info.segmentIndex { return itemIdx == segmentIndex } return info.matches(segment: segment) }) { // Update existing var updated = itineraryItems[existingIndex] updated.day = displayDay updated.sortOrder = sortOrder updated.modifiedAt = Date() updated.kind = .travel(canonicalInfo) itineraryItems[existingIndex] = updated saveItemLocally(updated, isUpdate: true) await ItineraryItemService.shared.updateItem(updated) } else { // Create new travel item let item = ItineraryItem( tripId: trip.id, day: displayDay, sortOrder: sortOrder, kind: .travel(canonicalInfo) ) itineraryItems.append(item) saveItemLocally(item, isUpdate: false) do { _ = try await ItineraryItemService.shared.createItem(item) markLocalItemSynced(item.id) } catch { print("❌ [TravelOverrides] CloudKit save failed: \(error)") return } } if canonicalTravelId != travelAnchorId { print("ℹ️ [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)") } print("βœ… [TravelOverrides] Saved to CloudKit") } } // MARK: - Itinerary Section enum ItinerarySection { case day(dayNumber: Int, date: Date, games: [RichGame]) case travel(TravelSegment, segmentIndex: Int) case customItem(ItineraryItem) case addButton(day: Int) 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 regionCoordinate: CLLocationCoordinate2D? } // 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) .frame(maxWidth: .infinity, alignment: .leading) } } // MARK: - Drop Target Indicator private struct DropTargetIndicator: View { var body: some View { HStack(spacing: 6) { Circle() .fill(Theme.warmOrange) .frame(width: 8, height: 8) Rectangle() .fill(Theme.warmOrange) .frame(height: 2) Circle() .fill(Theme.warmOrange) .frame(width: 8, height: 8) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, 4) .transition(.opacity.combined(with: .scale(scale: 0.8))) } } // 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 { if isRestDay { // Minimal rest day display - just header with date HStack { VStack(alignment: .leading, spacing: 2) { Text("Day \(dayNumber)") .font(.title2) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(formattedDate) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .cardStyle() .accessibilityElement(children: .combine) .accessibilityLabel("Day \(dayNumber), \(formattedDate), rest day") } else { // Full game day display 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() 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() .accessibilityElement(children: .combine) .accessibilityLabel("Day \(dayNumber), \(formattedDate), \(games.count) game\(games.count > 1 ? "s" : "") in \(gameCity ?? "unknown city")") } } } // 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)) .accessibilityElement(children: .ignore) .accessibilityLabel("\(game.game.sport.rawValue): \(game.awayTeam.name) at \(game.homeTeam.name), \(game.stadium.name), \(game.localGameTimeShort)") } } // 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 { Theme.Animation.withMotion(.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)) .accessibilityHidden(true) } .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 { // Opaque base + semi-transparent accent (prevents line showing through) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .fill(Theme.cardBackground(colorScheme)) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .fill(Theme.routeGold.opacity(0.05)) } .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) {} } // MARK: - Trip Map View (separate component for proper state updates) struct TripMapView: View { @Binding var cameraPosition: MapCameraPosition let routeCoordinates: [[CLLocationCoordinate2D]] let stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] let customItems: [ItineraryItem] let colorScheme: ColorScheme let routeVersion: UUID // Force re-render when routes change /// Create unique ID for each route segment based on start/end coordinates private func routeId(for coords: [CLLocationCoordinate2D], index: Int) -> String { guard let first = coords.first, let last = coords.last else { return "route-\(index)-empty" } return "route-\(index)-\(first.latitude)-\(first.longitude)-\(last.latitude)-\(last.longitude)" } var body: some View { Map(position: $cameraPosition, interactionModes: []) { // Routes (driving directions) ForEach(Array(routeCoordinates.enumerated()), id: \.offset) { index, coords in if !coords.isEmpty { MapPolyline(MKPolyline(coordinates: coords, count: coords.count)) .stroke(Theme.routeGold, lineWidth: 4) } } // Game stop markers 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) } } // Custom item markers ForEach(customItems, id: \.id) { item in if let info = item.customInfo, let coordinate = info.coordinate { Annotation(info.title, coordinate: coordinate) { ZStack { Circle() .fill(Theme.warmOrange) .frame(width: 24, height: 24) Text(info.icon) .font(.caption2) } } } } } .id(routeVersion) // Force Map to recreate when routes change .mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard) .accessibilityElement(children: .contain) .accessibilityLabel("Trip route map showing \(stopCoordinates.count) stops") } } #Preview { NavigationStack { TripDetailView( trip: Trip( name: "MLB Road Trip", preferences: TripPreferences( startLocation: LocationInput(name: "New York"), endLocation: LocationInput(name: "Chicago") ) ) ) } } // MARK: - Travel Override struct TravelOverride: Equatable { let day: Int let sortOrder: Double } // MARK: - Sheet Modifiers private struct SheetModifiers: ViewModifier { @Binding var showExportSheet: Bool let exportURL: URL? @Binding var showProPaywall: Bool @Binding var addItemAnchor: AddItemAnchor? @Binding var editingItem: ItineraryItem? let tripId: UUID let saveItineraryItem: (ItineraryItem) async -> Void let deleteItineraryItem: (ItineraryItem) async -> Void func body(content: Content) -> some View { content .sheet(isPresented: $showExportSheet) { if let url = exportURL { ShareSheet(items: [url]) } } .sheet(isPresented: $showProPaywall) { PaywallView(source: "trip_detail") } .sheet(item: $addItemAnchor) { anchor in QuickAddItemSheet( tripId: tripId, day: anchor.day, existingItem: nil, regionCoordinate: anchor.regionCoordinate ) { item in Task { await saveItineraryItem(item) } } } .sheet(item: $editingItem) { item in QuickAddItemSheet( tripId: tripId, day: item.day, existingItem: item, onSave: { updatedItem in Task { await saveItineraryItem(updatedItem) } }, onDelete: { deletedItem in Task { await deleteItineraryItem(deletedItem) } } ) } } }