Files
Sportstime/SportsTime/Features/Trip/Views/TripDetailView.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

2355 lines
89 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 persistenceErrorMessage: String?
@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<Void, Never>?
// Itinerary items state
@State private var itineraryItems: [ItineraryItem] = []
@State private var addItemAnchor: AddItemAnchor?
@State private var editingItem: ItineraryItem?
@State private var mapUpdateTask: Task<Void, Never>?
@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?")
}
.alert(
"Unable to Save Changes",
isPresented: Binding(
get: { persistenceErrorMessage != nil },
set: { if !$0 { persistenceErrorMessage = nil } }
)
) {
Button("OK", role: .cancel) { persistenceErrorMessage = nil }
} message: {
Text(persistenceErrorMessage ?? "Please try again.")
}
.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
#if DEBUG
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)")
}
}
#endif
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)..<cachedSections.count {
if case .day(let dayNumber, _, _) = cachedSections[i] {
return dayNumber
}
}
}
}
return 1
}
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> 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"
#if DEBUG
print("📍 [Move] Moving \(title) to day \(day), sortOrder: \(sortOrder)")
#endif
// 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)
#if DEBUG
print("✅ [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)")
#endif
}
/// 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<Int>? {
// 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..<pointCount {
coords.append(points[j].coordinate)
}
allCoordinates.append(coords)
}
} catch {
// Fallback to straight line if directions unavailable
allCoordinates.append([source.coordinate, destination.coordinate])
}
}
await MainActor.run {
routeCoordinates = allCoordinates
mapUpdateTrigger = UUID() // Force map to re-render with new routes
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
}
}
/// 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 {
persistenceErrorMessage = "Failed to save this trip. Please try again."
}
}
private func unsaveTrip() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
do {
let savedTrips = try modelContext.fetch(descriptor)
for savedTrip in savedTrips {
modelContext.delete(savedTrip)
}
try modelContext.save()
Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) {
isSaved = false
}
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
} catch {
persistenceErrorMessage = "Failed to remove this saved trip. Please try again."
}
}
private func checkIfSaved() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
do {
let count = try modelContext.fetchCount(descriptor)
isSaved = count > 0
} catch {
#if DEBUG
print("⚠️ [TripDetail] Failed to check save status: \(error)")
#endif
isSaved = false
}
}
// MARK: - Itinerary Items (Local-first persistence with CloudKit sync)
private func loadItineraryItems() async {
#if DEBUG
print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)")
#endif
// 1. Load from local SwiftData first (instant, works offline)
let tripId = trip.id
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.tripId == tripId }
)
let localModels: [LocalItineraryItem]
do {
localModels = try modelContext.fetch(descriptor)
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] Failed to fetch local items: \(error)")
#endif
localModels = []
}
let localItems = localModels.compactMap(\.toItem)
let pendingLocalItems = localModels.compactMap { local -> ItineraryItem? in
guard local.pendingSync else { return nil }
return local.toItem
}
if !localItems.isEmpty {
#if DEBUG
print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache")
#endif
itineraryItems = localItems
extractTravelOverrides(from: localItems)
}
// 2. Try CloudKit for latest data (background sync)
do {
let cloudItems = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id)
#if DEBUG
print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit")
#endif
// Merge: cloud as baseline, but always preserve local pending edits.
let cloudById = Dictionary(uniqueKeysWithValues: cloudItems.map { ($0.id, $0) })
let unresolvedPendingItems = pendingLocalItems.filter { localPending in
guard let cloudItem = cloudById[localPending.id] else { return true }
return cloudItem.modifiedAt < localPending.modifiedAt
}
var mergedById = cloudById
for localPending in unresolvedPendingItems {
mergedById[localPending.id] = localPending
}
let mergedItems = mergedById.values.sorted { lhs, rhs in
if lhs.day != rhs.day { return lhs.day < rhs.day }
if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }
return lhs.modifiedAt < rhs.modifiedAt
}
if !mergedItems.isEmpty || localItems.isEmpty {
itineraryItems = mergedItems
extractTravelOverrides(from: mergedItems)
syncLocalCache(with: mergedItems, pendingSyncItemIDs: Set(unresolvedPendingItems.map(\.id)))
}
// Ensure unsynced local edits continue retrying in the background.
for pendingItem in unresolvedPendingItems {
await ItineraryItemService.shared.updateItem(pendingItem)
}
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)")
#endif
for pendingItem in pendingLocalItems {
await ItineraryItemService.shared.updateItem(pendingItem)
}
}
}
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) {
#if DEBUG
print("⚠️ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities")
#endif
}
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 {
#if DEBUG
print("⚠️ [TravelOverrides] Ignoring ambiguous legacy travel override \(travelInfo.fromCity)->\(travelInfo.toCity)")
#endif
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
#if DEBUG
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
#endif
}
private func syncLocalCache(with items: [ItineraryItem], pendingSyncItemIDs: Set<UUID> = []) {
let tripId = trip.id
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.tripId == tripId }
)
do {
let existing = try modelContext.fetch(descriptor)
for old in existing { modelContext.delete(old) }
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] Failed to fetch existing local items for sync: \(error)")
#endif
}
for item in items {
if let local = LocalItineraryItem.from(item, pendingSync: pendingSyncItemIDs.contains(item.id)) {
modelContext.insert(local)
}
}
do {
try modelContext.save()
} catch {
persistenceErrorMessage = "Failed to sync itinerary cache: \(error.localizedDescription)"
}
}
private func saveItineraryItem(_ item: ItineraryItem) async {
let isUpdate = itineraryItems.contains(where: { $0.id == item.id })
let title = item.customInfo?.title ?? "item"
#if DEBUG
print("💾 [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))")
#endif
// 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)
#if DEBUG
print("✅ [ItineraryItems] Updated in CloudKit: \(title)")
#endif
} else {
_ = try await ItineraryItemService.shared.createItem(item)
#if DEBUG
print("✅ [ItineraryItems] Created in CloudKit: \(title)")
#endif
markLocalItemSynced(item.id)
}
} catch {
await ItineraryItemService.shared.updateItem(item)
#if DEBUG
print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)")
#endif
}
}
private func saveItemLocally(_ item: ItineraryItem, isUpdate: Bool) {
if isUpdate {
let itemId = item.id
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.id == itemId }
)
do {
if let existing = try modelContext.fetch(descriptor).first {
existing.day = item.day
existing.sortOrder = item.sortOrder
do {
existing.kindData = try JSONEncoder().encode(item.kind)
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] Failed to encode item kind: \(error)")
#endif
}
existing.modifiedAt = item.modifiedAt
existing.pendingSync = true
} else if let local = LocalItineraryItem.from(item, pendingSync: true) {
modelContext.insert(local)
}
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] Failed to fetch local item for update: \(error)")
#endif
if let local = LocalItineraryItem.from(item, pendingSync: true) {
modelContext.insert(local)
}
}
} else {
if let local = LocalItineraryItem.from(item, pendingSync: true) {
modelContext.insert(local)
}
}
do {
try modelContext.save()
} catch {
persistenceErrorMessage = "Failed to save itinerary item: \(error.localizedDescription)"
}
}
private func markLocalItemSynced(_ itemId: UUID) {
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.id == itemId }
)
do {
if let local = try modelContext.fetch(descriptor).first {
local.pendingSync = false
try modelContext.save()
}
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] Failed to mark item synced: \(error)")
#endif
}
}
private func deleteItineraryItem(_ item: ItineraryItem) async {
let title = item.customInfo?.title ?? "item"
#if DEBUG
print("🗑️ [ItineraryItems] Deleting item: '\(title)'")
#endif
// Remove from in-memory state
itineraryItems.removeAll { $0.id == item.id }
// Remove from local SwiftData
let itemId = item.id
let descriptor = FetchDescriptor<LocalItineraryItem>(
predicate: #Predicate { $0.id == itemId }
)
do {
if let local = try modelContext.fetch(descriptor).first {
modelContext.delete(local)
try modelContext.save()
}
} catch {
persistenceErrorMessage = "Failed to delete itinerary item: \(error.localizedDescription)"
}
// Delete from CloudKit
do {
try await ItineraryItemService.shared.deleteItem(item.id)
#if DEBUG
print("✅ [ItineraryItems] Deleted from CloudKit")
#endif
} catch {
#if DEBUG
print("⚠️ [ItineraryItems] CloudKit delete failed (removed locally): \(error)")
#endif
}
}
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
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 {
#if DEBUG
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
#endif
guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId),
segmentIndex >= 0,
segmentIndex < trip.travelSegments.count else {
#if DEBUG
print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)")
#endif
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 {
#if DEBUG
print("❌ [TravelOverrides] CloudKit save failed: \(error)")
#endif
return
}
}
#if DEBUG
if canonicalTravelId != travelAnchorId {
print(" [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)")
}
print("✅ [TravelOverrides] Saved to CloudKit")
#endif
}
}
// 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) }
}
)
}
}
}