- Add VoiceOver labels, hints, and element grouping across all 60+ views - Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations - Replace fixed font sizes with semantic Dynamic Type styles - Hide decorative elements from VoiceOver with .accessibilityHidden(true) - Add .minimumHitTarget() modifier ensuring 44pt touch targets - Add AccessibilityAnnouncer utility for VoiceOver announcements - Improve color contrast values in Theme.swift for WCAG AA compliance - Extract CloudKitContainerConfig for explicit container identity - Remove PostHog debug console log from AnalyticsManager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2087 lines
79 KiB
Swift
2087 lines
79 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")
|
|
}
|
|
}
|
|
|
|
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
|
|
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)
|
|
}
|
|
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
|
draggedTravelId = nil
|
|
draggedItem = nil
|
|
dropTargetId = nil
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 travelId = stableTravelAnchorId(segment)
|
|
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):
|
|
return "travel-\(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) -> String {
|
|
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
|
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
|
return "travel:\(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
|
|
|
|
// Pre-calculate which day each travel segment belongs to.
|
|
// Uses stop indices (not city name matching) so repeat cities work correctly.
|
|
// trip.travelSegments[i] connects trip.stops[i] → trip.stops[i+1].
|
|
var travelByDay: [Int: TravelSegment] = [:]
|
|
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
|
let travelId = stableTravelAnchorId(segment)
|
|
|
|
// Use stop dates for precise placement (handles repeat cities)
|
|
let minDay: Int
|
|
let maxDay: Int
|
|
let defaultDay: Int
|
|
|
|
if segmentIndex < trip.stops.count - 1 {
|
|
let fromStop = trip.stops[segmentIndex]
|
|
let toStop = trip.stops[segmentIndex + 1]
|
|
|
|
let fromDayNum = dayNumber(for: fromStop.departureDate)
|
|
let toDayNum = dayNumber(for: toStop.arrivalDate)
|
|
|
|
// Travel goes after the from stop's last day, up to the to stop's first day
|
|
minDay = max(fromDayNum + 1, 1)
|
|
maxDay = min(toDayNum, days.count)
|
|
defaultDay = minDay
|
|
} else {
|
|
// Fallback: segment doesn't align with stops
|
|
minDay = 1
|
|
maxDay = days.count
|
|
defaultDay = 1
|
|
}
|
|
|
|
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
|
|
|
|
// Check for user override - only use if within valid range
|
|
if let override = travelOverrides[travelId],
|
|
validRange.contains(override.day) {
|
|
travelByDay[override.day] = segment
|
|
} else {
|
|
// Use default (clamped to valid range)
|
|
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
|
travelByDay[clampedDefault] = 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
|
|
if let travelSegment = travelByDay[dayNum] {
|
|
sections.append(.travel(travelSegment))
|
|
}
|
|
|
|
// 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>? {
|
|
// Find the segment index matching this travel ID
|
|
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == 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
|
|
}
|
|
|
|
// 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 segment in trip.travelSegments {
|
|
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
|
|
|
// 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(
|
|
fromCity: segment.fromLocation.name,
|
|
toCity: segment.toLocation.name,
|
|
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 }
|
|
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
|
|
|
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:") {
|
|
// Validate travel is within valid bounds (day-level)
|
|
if let validRange = self.validDayRange(for: droppedId) {
|
|
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[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
|
|
}
|
|
|
|
// Persist to CloudKit as a travel ItineraryItem
|
|
await self.saveTravelDayOverride(
|
|
travelAnchorId: droppedId,
|
|
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)")
|
|
|
|
// Parse travel ID to extract cities (format: "travel:city1->city2")
|
|
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
|
|
let parts = stripped.components(separatedBy: "->")
|
|
guard parts.count == 2 else {
|
|
print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)")
|
|
return
|
|
}
|
|
|
|
let fromCity = parts[0]
|
|
let toCity = parts[1]
|
|
|
|
// Find existing travel item or create new one
|
|
if let existingIndex = itineraryItems.firstIndex(where: {
|
|
$0.isTravel && $0.travelInfo?.fromCity.lowercased() == fromCity && $0.travelInfo?.toCity.lowercased() == toCity
|
|
}) {
|
|
// Update existing
|
|
var updated = itineraryItems[existingIndex]
|
|
updated.day = displayDay
|
|
updated.sortOrder = sortOrder
|
|
updated.modifiedAt = Date()
|
|
itineraryItems[existingIndex] = updated
|
|
await ItineraryItemService.shared.updateItem(updated)
|
|
} else {
|
|
// Create new travel item
|
|
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity)
|
|
let item = ItineraryItem(
|
|
tripId: trip.id,
|
|
day: displayDay,
|
|
sortOrder: sortOrder,
|
|
kind: .travel(travelInfo)
|
|
)
|
|
itineraryItems.append(item)
|
|
do {
|
|
_ = try await ItineraryItemService.shared.createItem(item)
|
|
} catch {
|
|
print("❌ [TravelOverrides] CloudKit save failed: \(error)")
|
|
return
|
|
}
|
|
}
|
|
print("✅ [TravelOverrides] Saved to CloudKit")
|
|
}
|
|
}
|
|
|
|
// MARK: - Itinerary Section
|
|
|
|
enum ItinerarySection {
|
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
|
case travel(TravelSegment)
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
}
|