Add 22 new UI tests across 8 test files covering Home, Schedule, Progress, Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility identifiers to 11 view files for test element discovery. Fix sport chip assertion logic (all sports start selected, tap deselects), scroll container issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow. Update QA coverage map from 32 to 54 automated test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2140 lines
82 KiB
Swift
2140 lines
82 KiB
Swift
//
|
||
// TripDetailView.swift
|
||
// SportsTime
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
import MapKit
|
||
import Combine
|
||
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
|
||
|
||
// Itinerary items state
|
||
@State private var itineraryItems: [ItineraryItem] = []
|
||
@State private var addItemAnchor: AddItemAnchor?
|
||
@State private var editingItem: ItineraryItem?
|
||
@State private var subscriptionCancellable: AnyCancellable?
|
||
@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
|
||
|
||
// 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
|
||
))
|
||
.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
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
|
||
if !isSaved {
|
||
saveTrip()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
await loadGamesIfNeeded()
|
||
if allowCustomItems {
|
||
await loadItineraryItems()
|
||
await setupSubscription()
|
||
}
|
||
}
|
||
.onDisappear { subscriptionCancellable?.cancel() }
|
||
.onChange(of: itineraryItems) { _, newItems in
|
||
handleItineraryItemsChange(newItems)
|
||
}
|
||
.onChange(of: travelOverrides.count) { _, _ in
|
||
draggedTravelId = nil
|
||
dropTargetId = nil
|
||
}
|
||
.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)")
|
||
}
|
||
}
|
||
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
|
||
addItemAnchor = AddItemAnchor(day: day)
|
||
}
|
||
)
|
||
.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, 12)
|
||
.padding(.trailing, 12)
|
||
}
|
||
.overlay(alignment: .bottomTrailing) {
|
||
// Open in Apple Maps button
|
||
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)
|
||
}
|
||
.padding(.bottom, 90) // Above the gradient
|
||
.padding(.trailing, 12)
|
||
.accessibilityLabel("Open in Apple Maps")
|
||
.accessibilityHint("Opens this trip route in Apple Maps")
|
||
}
|
||
|
||
// Gradient overlay at bottom
|
||
LinearGradient(
|
||
colors: [.clear, Theme.cardBackground(colorScheme).opacity(0.8), Theme.cardBackground(colorScheme)],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
.frame(height: 80)
|
||
|
||
// Loading indicator
|
||
if isLoadingRoutes {
|
||
LoadingSpinner(size: .medium)
|
||
.padding(.bottom, 40)
|
||
}
|
||
}
|
||
.task {
|
||
updateMapRegion()
|
||
await fetchDrivingRoutes()
|
||
}
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var tripHeader: some View {
|
||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||
// Date range
|
||
Text(trip.formattedDateRange)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||
|
||
// Route preview
|
||
RoutePreviewStrip(cities: trip.stops.map { $0.city })
|
||
.padding(.vertical, Theme.Spacing.xs)
|
||
|
||
// Sport badges
|
||
HStack(spacing: Theme.Spacing.xs) {
|
||
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
|
||
HStack(spacing: 4) {
|
||
Image(systemName: sport.iconName)
|
||
.font(.caption2)
|
||
Text(sport.rawValue)
|
||
.font(.caption2)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(sport.themeColor.opacity(0.2))
|
||
.foregroundStyle(sport.themeColor)
|
||
.clipShape(Capsule())
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
// MARK: - Stats Row
|
||
|
||
private var statsRow: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: Theme.Spacing.sm) {
|
||
StatPill(icon: "calendar", value: "\(trip.tripDuration) days")
|
||
StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities")
|
||
StatPill(icon: "sportscourt", value: "\(trip.totalGames) games")
|
||
StatPill(icon: "road.lanes", value: trip.formattedTotalDistance)
|
||
StatPill(icon: "car", value: trip.formattedTotalDriving)
|
||
}
|
||
}
|
||
.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(itinerarySections.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 {
|
||
addItemAnchor = AddItemAnchor(day: day)
|
||
}
|
||
}
|
||
.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),
|
||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||
|
||
Task { @MainActor in
|
||
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),
|
||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
|
||
droppedItem.id != targetItem.id else { return }
|
||
|
||
Task { @MainActor in
|
||
// 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),
|
||
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
|
||
|
||
Task { @MainActor in
|
||
// 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 itinerarySections.enumerated() {
|
||
if case .travel(let s, _) = section, s.id == segment.id {
|
||
// Look forward to find the arrival day
|
||
for i in (index + 1)..<itinerarySections.count {
|
||
if case .day(let dayNumber, _, _) = itinerarySections[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 {
|
||
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
||
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
||
return "travel:\(index):\(from)->\(to)"
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
// Sync to CloudKit (debounced)
|
||
await ItineraryItemService.shared.updateItem(updated)
|
||
print("✅ [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)")
|
||
}
|
||
|
||
/// Build itinerary sections: shows ALL days with travel, custom items, and add buttons
|
||
private var itinerarySections: [ItinerarySection] {
|
||
var sections: [ItinerarySection] = []
|
||
let days = tripDays
|
||
|
||
// Use TravelPlacement for consistent day calculation (shared with tests).
|
||
var travelByDay = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
||
|
||
// Apply user overrides on top of computed defaults.
|
||
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||
guard let override = travelOverrides[travelId] else { continue }
|
||
|
||
// Validate override is within valid day range
|
||
if let validRange = validDayRange(for: travelId),
|
||
validRange.contains(override.day) {
|
||
// Remove from computed position (search all days)
|
||
for key in travelByDay.keys {
|
||
travelByDay[key]?.removeAll { $0.id == segment.id }
|
||
}
|
||
travelByDay.keys.forEach { key in
|
||
if travelByDay[key]?.isEmpty == true { travelByDay[key] = nil }
|
||
}
|
||
// Place at overridden position
|
||
travelByDay[override.day, default: []].append(segment)
|
||
}
|
||
}
|
||
|
||
// Process ALL days
|
||
for (index, dayDate) in days.enumerated() {
|
||
let dayNum = index + 1
|
||
let gamesOnDay = gamesOn(date: dayDate)
|
||
|
||
// Travel for this day (if any) - appears before day header
|
||
for travelSegment in (travelByDay[dayNum] ?? []) {
|
||
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == travelSegment.id }) ?? 0
|
||
sections.append(.travel(travelSegment, segmentIndex: segIdx))
|
||
}
|
||
|
||
// Day section - shows games or minimal rest day display
|
||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||
|
||
// Custom items for this day (sorted by sortOrder)
|
||
if allowCustomItems {
|
||
// Add button first - always right after day header
|
||
sections.append(.addButton(day: dayNum))
|
||
|
||
let dayItems = itineraryItems
|
||
.filter { $0.day == dayNum && $0.isCustom }
|
||
.sorted { $0.sortOrder < $1.sortOrder }
|
||
for item in dayItems {
|
||
sections.append(.customItem(item))
|
||
}
|
||
}
|
||
}
|
||
|
||
return sections
|
||
}
|
||
|
||
private var tripDays: [Date] {
|
||
let calendar = Calendar.current
|
||
guard let startDate = trip.stops.first?.arrivalDate,
|
||
let endDate = trip.stops.last?.departureDate else { return [] }
|
||
|
||
var days: [Date] = []
|
||
var current = calendar.startOfDay(for: startDate)
|
||
let end = calendar.startOfDay(for: endDate)
|
||
|
||
while current <= end {
|
||
days.append(current)
|
||
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
||
}
|
||
return days
|
||
}
|
||
|
||
private func gamesOn(date: Date) -> [RichGame] {
|
||
let calendar = Calendar.current
|
||
let dayStart = calendar.startOfDay(for: date)
|
||
let allGameIds = trip.stops.flatMap { $0.games }
|
||
|
||
let foundGames = allGameIds.compactMap { games[$0] }
|
||
|
||
return foundGames.filter { richGame in
|
||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||
}
|
||
|
||
/// Get the city for a given date (from the stop that covers that date)
|
||
private func cityOn(date: Date) -> String? {
|
||
let calendar = Calendar.current
|
||
let dayStart = calendar.startOfDay(for: date)
|
||
|
||
return trip.stops.first { stop in
|
||
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
|
||
let departureDay = calendar.startOfDay(for: stop.departureDate)
|
||
return dayStart >= arrivalDay && dayStart <= departureDay
|
||
}?.city
|
||
}
|
||
|
||
/// 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
|
||
print("🗺️ [FetchRoutes] Computing routes with \(waypoints.count) waypoints:")
|
||
for (index, wp) in waypoints.enumerated() {
|
||
print("🗺️ [FetchRoutes] \(index): \(wp.name) (custom: \(wp.isCustomItem))")
|
||
}
|
||
guard waypoints.count >= 2 else {
|
||
print("🗺️ [FetchRoutes] Not enough waypoints, skipping")
|
||
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 {
|
||
print("🗺️ [FetchRoutes] Setting \(allCoordinates.count) route segments")
|
||
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)] {
|
||
// 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 }
|
||
|
||
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)")
|
||
}
|
||
}
|
||
|
||
var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
|
||
let days = tripDays
|
||
|
||
print("🗺️ [Waypoints] Trip has \(days.count) days")
|
||
|
||
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
|
||
|
||
print("🗺️ [Waypoints] Day \(dayNumber): city=\(dayCity ?? "none"), games=\(gamesOnDay.count)")
|
||
|
||
// 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) {
|
||
print("🗺️ [Waypoints] Adding \(stadium.name) (stadium)")
|
||
waypoints.append((stadium.name, stadium.coordinate, false))
|
||
} else if let coord = stop.coordinate {
|
||
// No stadium ID but stop has coordinate
|
||
print("🗺️ [Waypoints] Adding \(city) (city coord)")
|
||
waypoints.append((city, coord, false))
|
||
}
|
||
}
|
||
} else {
|
||
print("🗺️ [Waypoints] \(city) already in waypoints, skipping")
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
print("🗺️ [Waypoints] Adding \(info.title) (sortOrder: \(item.sortOrder))")
|
||
waypoints.append((info.title, coord, true))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return 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
|
||
}
|
||
|
||
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<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 {
|
||
// Unsave failed silently
|
||
}
|
||
}
|
||
|
||
private func checkIfSaved() {
|
||
let tripId = trip.id
|
||
let descriptor = FetchDescriptor<SavedTrip>(
|
||
predicate: #Predicate { $0.id == tripId }
|
||
)
|
||
|
||
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
|
||
isSaved = true
|
||
} else {
|
||
isSaved = false
|
||
}
|
||
}
|
||
|
||
// MARK: - Itinerary Items (CloudKit persistence)
|
||
|
||
private func setupSubscription() async {
|
||
// TODO: Re-implement CloudKit subscription for ItineraryItem changes
|
||
// The subscription service was removed during the ItineraryItem refactor.
|
||
// For now, items are only loaded on view appear.
|
||
print("📡 [Subscription] CloudKit subscriptions not yet implemented for ItineraryItem")
|
||
}
|
||
|
||
private func loadItineraryItems() async {
|
||
print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)")
|
||
do {
|
||
let items = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id)
|
||
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
|
||
itineraryItems = items
|
||
|
||
// Extract travel overrides (day + sortOrder) from travel-type items
|
||
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
|
||
}
|
||
|
||
// Legacy record without segment index: only accept if the city pair maps uniquely.
|
||
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)")
|
||
} catch {
|
||
print("❌ [ItineraryItems] Failed to load: \(error)")
|
||
}
|
||
}
|
||
|
||
private func saveItineraryItem(_ item: ItineraryItem) async {
|
||
// Check if this is an update or create
|
||
let isUpdate = itineraryItems.contains(where: { $0.id == item.id })
|
||
let title = item.customInfo?.title ?? "item"
|
||
print("💾 [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))")
|
||
print(" - tripId: \(item.tripId)")
|
||
print(" - day: \(item.day), sortOrder: \(item.sortOrder)")
|
||
|
||
// Update local state immediately for responsive UI
|
||
if isUpdate {
|
||
if let index = itineraryItems.firstIndex(where: { $0.id == item.id }) {
|
||
itineraryItems[index] = item
|
||
}
|
||
} else {
|
||
itineraryItems.append(item)
|
||
}
|
||
|
||
// Persist to CloudKit
|
||
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)")
|
||
}
|
||
} catch {
|
||
print("❌ [ItineraryItems] CloudKit save failed: \(error)")
|
||
}
|
||
}
|
||
|
||
private func deleteItineraryItem(_ item: ItineraryItem) async {
|
||
let title = item.customInfo?.title ?? "item"
|
||
print("🗑️ [ItineraryItems] Deleting item: '\(title)'")
|
||
|
||
// Remove from local state immediately
|
||
itineraryItems.removeAll { $0.id == item.id }
|
||
|
||
// Delete from CloudKit
|
||
do {
|
||
try await ItineraryItemService.shared.deleteItem(item.id)
|
||
print("✅ [ItineraryItems] Deleted from CloudKit")
|
||
} catch {
|
||
print("❌ [ItineraryItems] CloudKit delete failed: \(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
|
||
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)
|
||
do {
|
||
_ = try await ItineraryItemService.shared.createItem(item)
|
||
} 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
|
||
}
|
||
|
||
// 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()
|
||
} 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()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Game Row
|
||
|
||
struct GameRow: View {
|
||
let game: RichGame
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
var body: some View {
|
||
HStack(spacing: Theme.Spacing.md) {
|
||
// Sport color bar
|
||
SportColorBar(sport: game.game.sport)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
// Sport badge + Matchup
|
||
HStack(spacing: 6) {
|
||
// Sport icon and name
|
||
HStack(spacing: 3) {
|
||
Image(systemName: game.game.sport.iconName)
|
||
.font(.caption2)
|
||
Text(game.game.sport.rawValue)
|
||
.font(.caption2)
|
||
}
|
||
.foregroundStyle(game.game.sport.themeColor)
|
||
|
||
// Matchup
|
||
HStack(spacing: 4) {
|
||
Text(game.awayTeam.abbreviation)
|
||
.font(.body)
|
||
Text("@")
|
||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||
Text(game.homeTeam.abbreviation)
|
||
.font(.body)
|
||
}
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
}
|
||
|
||
// Stadium
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "building.2")
|
||
.font(.caption2)
|
||
Text(game.stadium.name)
|
||
.font(.subheadline)
|
||
}
|
||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Time
|
||
Text(game.localGameTimeShort)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
}
|
||
.padding(Theme.Spacing.sm)
|
||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||
}
|
||
}
|
||
|
||
// MARK: - Travel Section
|
||
|
||
struct TravelSection: View {
|
||
let segment: TravelSegment
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@State private var showEVChargers = false
|
||
|
||
private var hasEVChargers: Bool {
|
||
!segment.evChargingStops.isEmpty
|
||
}
|
||
|
||
var body: some View {
|
||
// Travel card
|
||
VStack(spacing: 0) {
|
||
// Main travel info
|
||
HStack(spacing: Theme.Spacing.md) {
|
||
// Icon
|
||
ZStack {
|
||
Circle()
|
||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||
.frame(width: 44, height: 44)
|
||
|
||
Image(systemName: "car.fill")
|
||
.foregroundStyle(Theme.routeGold)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Travel")
|
||
.font(.caption)
|
||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||
|
||
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||
.font(.body)
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(segment.formattedDistance)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
Text(segment.formattedDuration)
|
||
.font(.caption)
|
||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||
}
|
||
}
|
||
.padding(Theme.Spacing.md)
|
||
|
||
// EV Chargers section (if available)
|
||
if hasEVChargers {
|
||
Divider()
|
||
.background(Theme.routeGold.opacity(0.2))
|
||
|
||
Button {
|
||
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 {
|
||
let _ = print("🗺️ [TripMapView] Rendering with \(routeCoordinates.count) route segments, version: \(routeVersion)")
|
||
Map(position: $cameraPosition, interactionModes: []) {
|
||
// Routes (driving directions)
|
||
ForEach(Array(routeCoordinates.enumerated()), id: \.offset) { index, coords in
|
||
let _ = print("🗺️ [TripMapView] Drawing route \(index) with \(coords.count) points")
|
||
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)
|
||
}
|
||
}
|
||
|
||
#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
|
||
|
||
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
|
||
) { item in
|
||
Task { await saveItineraryItem(item) }
|
||
}
|
||
}
|
||
.sheet(item: $editingItem) { item in
|
||
QuickAddItemSheet(
|
||
tripId: tripId,
|
||
day: item.day,
|
||
existingItem: item
|
||
) { updatedItem in
|
||
Task { await saveItineraryItem(updatedItem) }
|
||
}
|
||
}
|
||
}
|
||
}
|