Files
Sportstime/SportsTime/Features/Trip/Views/TripDetailView.swift
Trey t f84addb39d feat(itinerary): reorder Add button after day header + comprehensive docs
- Move Add button to appear immediately after Day header (before games)
- Split games out of dayHeader into separate row for correct ordering
- Add 600+ lines of inline documentation to ItineraryTableViewController
- Document architecture decisions, data flow, constraints, and algorithms
- Add function-level comments explaining drag/drop, sortOrder calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:07:37 -06:00

1876 lines
70 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
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
// Custom items state
@State private var customItems: [CustomItineraryItem] = []
@State private var addItemAnchor: AddItemAnchor?
@State private var editingItem: CustomItineraryItem?
@State private var subscriptionCancellable: AnyCancellable?
@State private var draggedItem: CustomItineraryItem?
@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 travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number
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 {
mainContent
.background(Theme.backgroundGradient(colorScheme))
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
ShareButton(trip: trip, style: .icon)
.foregroundStyle(Theme.warmOrange)
Button {
if StoreManager.shared.isPro {
Task {
await exportPDF()
}
} else {
showProPaywall = true
}
} label: {
HStack(spacing: 2) {
Image(systemName: "doc.fill")
if !StoreManager.shared.isPro {
ProBadge()
}
}
.foregroundStyle(Theme.warmOrange)
}
}
}
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: trip.id,
day: anchor.day,
existingItem: nil
) { item in
Task { await saveCustomItem(item) }
}
}
.sheet(item: $editingItem) { item in
AddItemSheet(
tripId: trip.id,
day: item.day,
existingItem: item
) { updatedItem in
Task { await saveCustomItem(updatedItem) }
}
}
.onAppear {
checkIfSaved()
}
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadCustomItems()
await setupSubscription()
}
}
.onDisappear {
subscriptionCancellable?.cancel()
}
.onChange(of: customItems) { _, newItems in
// Clear drag state after items update (move completed)
draggedItem = nil
dropTargetId = nil
// Recalculate routes when custom items change (mappable items affect route)
print("🗺️ [MapUpdate] customItems changed, count: \(newItems.count)")
for item in newItems where item.isMappable {
print("🗺️ [MapUpdate] Mappable: \(item.title) on day \(item.day), sortOrder: \(item.sortOrder)")
}
Task {
updateMapRegion()
await fetchDrivingRoutes()
}
}
.onChange(of: travelDayOverrides) { _, _ in
// Clear drag state after travel move completed
draggedTravelId = nil
dropTargetId = nil
}
.overlay {
if isExporting {
exportProgressOverlay
}
}
}
// 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),
customItems: customItems,
travelDayOverrides: travelDayOverrides,
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 in
Task { @MainActor in
withAnimation {
travelDayOverrides[travelId] = newDay
}
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
}
},
onCustomItemMoved: { itemId, day, sortOrder in
Task { @MainActor in
guard let item = customItems.first(where: { $0.id == itemId }) else { return }
await moveItem(item, toDay: day, sortOrder: sortOrder)
}
},
onCustomItemTapped: { item in
editingItem = item
},
onCustomItemDeleted: { item in
Task { await deleteCustomItem(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(.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)
}
.padding(.top, 12)
.padding(.trailing, 12)
}
// Gradient overlay at bottom
LinearGradient(
colors: [.clear, Theme.cardBackground(colorScheme).opacity(0.8), Theme.cardBackground(colorScheme)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 80)
// Loading indicator
if isLoadingRoutes {
LoadingSpinner(size: .medium)
.padding(.bottom, 40)
}
}
.task {
updateMapRegion()
await fetchDrivingRoutes()
}
}
// MARK: - Header
private var tripHeader: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Date range
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
// Route preview
RoutePreviewStrip(cities: trip.stops.map { $0.city })
.padding(.vertical, Theme.Spacing.xs)
// Sport badges
HStack(spacing: Theme.Spacing.xs) {
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
HStack(spacing: 4) {
Image(systemName: sport.iconName)
.font(.caption2)
Text(sport.rawValue)
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(sport.themeColor.opacity(0.2))
.foregroundStyle(sport.themeColor)
.clipShape(Capsule())
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Stats Row
private var statsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
StatPill(icon: "calendar", value: "\(trip.tripDuration) days")
StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities")
StatPill(icon: "sportscourt", value: "\(trip.totalGames) games")
StatPill(icon: "road.lanes", value: trip.formattedTotalDistance)
StatPill(icon: "car", value: trip.formattedTotalDriving)
}
}
}
// MARK: - Score Card
private func scoreCard(_ score: TripScore) -> some View {
VStack(spacing: Theme.Spacing.md) {
HStack {
Text("Trip Score")
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text(score.scoreGrade)
.font(.largeTitle)
.foregroundStyle(Theme.warmOrange)
.glowEffect(color: Theme.warmOrange, radius: 8)
}
HStack(spacing: Theme.Spacing.lg) {
scoreItem(label: "Games", value: score.gameQualityScore, color: Theme.mlbRed)
scoreItem(label: "Route", value: score.routeEfficiencyScore, color: Theme.routeGold)
scoreItem(label: "Balance", value: score.leisureBalanceScore, color: Theme.mlsGreen)
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore, color: Theme.nbaOrange)
}
}
.cardStyle()
}
private func scoreItem(label: String, value: Double, color: Color) -> some View {
VStack(spacing: 4) {
Text(String(format: "%.0f", value))
.font(.headline)
.foregroundStyle(color)
Text(label)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
// MARK: - Itinerary (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)
withAnimation(.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
withAnimation(.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 },
onDelete: { Task { await deleteCustomItem(item) } }
)
.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
withAnimation(.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
withAnimation(.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.customItems.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.customItems
.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: CustomItineraryItem) -> 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.customItems.first(where: { $0.id == itemId }),
droppedItem.id != targetItem.id else { return }
Task { @MainActor in
// Place before target item using midpoint insertion
let itemsInDay = self.customItems.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.customItems.first(where: { $0.id == itemId }) else { return }
Task { @MainActor in
// Calculate sortOrder: append at end of day's items
let maxSortOrder = self.customItems
.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: CustomItineraryItem, toDay day: Int, sortOrder: Double) async {
var updated = item
updated.day = day
updated.sortOrder = sortOrder
updated.modifiedAt = Date()
print("📍 [Move] Moving \(item.title) to day \(day), sortOrder: \(sortOrder)")
// Update local state
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[idx] = updated
}
// Sync to CloudKit
do {
_ = try await CustomItemService.shared.updateItem(updated)
print("✅ [Move] Synced \(updated.title) with day: \(day), sortOrder: \(sortOrder)")
} catch {
print("❌ [Move] Failed to sync: \(error)")
}
}
/// 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
// Default: day after last game in departure city, or use validated override
var travelByDay: [Int: TravelSegment] = [:]
for segment in trip.travelSegments {
let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Calculate valid range for this travel
// Travel can only happen AFTER the last game in departure city
let lastGameInFromCity = findLastGameDay(in: fromCity)
let firstGameInToCity = findFirstGameDay(in: toCity)
let minDay = max(lastGameInFromCity + 1, 1)
let maxDay = min(firstGameInToCity, tripDays.count)
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Calculate default day (day after last game in departure city)
let defaultDay: Int
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
defaultDay = lastGameInFromCity + 1
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
} else {
defaultDay = 1
}
// Check for user override - only use if within valid range
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
travelByDay[overrideDay] = 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 = customItems.filter { $0.day == dayNum }
.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
}
/// Find the last day number that has a game in the given city
private func findLastGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
let days = tripDays
var lastDay = 0
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
lastDay = dayNum
}
}
return lastDay
}
/// Find the first day number that has a game in the given city
private func findFirstGameDay(in city: String) -> Int {
let cityLower = city.lowercased()
let days = tripDays
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
return dayNum
}
}
return tripDays.count // Default to last day if no games found
}
/// Get valid day range for a travel segment
/// Travel can be displayed from the day of last departure game to the day of first arrival game
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
// Find the segment matching this travel ID
guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else {
return nil
}
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Travel can only happen AFTER the last game in departure city
// So the earliest travel day is the day AFTER the last game
let lastGameInFromCity = findLastGameDay(in: fromCity)
let minDay = max(lastGameInFromCity + 1, 1)
// Travel must happen BEFORE or ON the first game day in arrival city
let firstGameInToCity = findFirstGameDay(in: toCity)
let maxDay = min(firstGameInToCity, tripDays.count)
// Handle edge case where minDay > maxDay shouldn't happen, but safeguard
if minDay > maxDay {
return minDay...minDay
}
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: [CustomItineraryItem] {
customItems.filter { $0.isMappable }
}
/// 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 }) {
print("🗺️ [Waypoints] Day \(day): \(item.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 coord = item.coordinate {
print("🗺️ [Waypoints] Adding \(item.title) (sortOrder: \(item.sortOrder))")
waypoints.append((item.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
do {
let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in
await MainActor.run {
self.exportProgress = progress
}
}
exportURL = url
showExportSheet = true
} catch {
// PDF export failed silently
}
isExporting = false
}
private func toggleSaved() {
if isSaved {
unsaveTrip()
} else {
saveTrip()
}
}
private func saveTrip() {
// Check trip limit for free users
if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit {
showProPaywall = true
return
}
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
return
}
modelContext.insert(savedTrip)
do {
try modelContext.save()
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isSaved = true
}
} catch {
// Save failed silently
}
}
private func unsaveTrip() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
do {
let savedTrips = try modelContext.fetch(descriptor)
for savedTrip in savedTrips {
modelContext.delete(savedTrip)
}
try modelContext.save()
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isSaved = false
}
} catch {
// Unsave failed silently
}
}
private func checkIfSaved() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
isSaved = true
} else {
isSaved = false
}
}
// MARK: - Custom Items (CloudKit persistence)
private func setupSubscription() async {
// Subscribe to real-time updates for this trip
do {
try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id)
// Listen for changes and reload
subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher
.filter { $0 == self.trip.id }
.receive(on: DispatchQueue.main)
.sink { [self] _ in
print("📡 [Subscription] Received update, reloading custom items...")
Task {
await loadCustomItems()
}
}
} catch {
print("📡 [Subscription] Failed to subscribe: \(error)")
}
}
private func loadCustomItems() async {
print("🔍 [CustomItems] Loading items for trip: \(trip.id)")
do {
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
customItems = items
// Also load travel day overrides
let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id)
print("✅ [TravelOverrides] Loaded \(overrides.count) travel day overrides")
travelDayOverrides = overrides
} catch {
print("❌ [CustomItems] Failed to load: \(error)")
}
}
private func saveCustomItem(_ item: CustomItineraryItem) async {
// Check if this is an update or create
let isUpdate = customItems.contains(where: { $0.id == item.id })
print("💾 [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))")
print(" - tripId: \(item.tripId)")
print(" - day: \(item.day), sortOrder: \(item.sortOrder)")
// Update local state immediately for responsive UI
if isUpdate {
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[index] = item
}
} else {
customItems.append(item)
}
// Persist to CloudKit
do {
if isUpdate {
let updated = try await CustomItemService.shared.updateItem(item)
print("✅ [CustomItems] Updated in CloudKit: \(updated.title)")
} else {
let created = try await CustomItemService.shared.createItem(item)
print("✅ [CustomItems] Created in CloudKit: \(created.title)")
}
} catch {
print("❌ [CustomItems] CloudKit save failed: \(error)")
}
}
private func deleteCustomItem(_ item: CustomItineraryItem) async {
print("🗑️ [CustomItems] Deleting item: '\(item.title)'")
// Remove from local state immediately
customItems.removeAll { $0.id == item.id }
// Delete from CloudKit
do {
try await CustomItemService.shared.deleteItem(item.id)
print("✅ [CustomItems] Deleted from CloudKit")
} catch {
print("❌ [CustomItems] CloudKit delete failed: \(error)")
}
}
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
if let validRange = self.validDayRange(for: droppedId) {
guard validRange.contains(dayNumber) else {
// Day is outside valid range - reject drop (state already cleared)
return
}
}
// Move travel to this day
withAnimation {
self.travelDayOverrides[droppedId] = dayNumber
}
// Persist the override to CloudKit
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber)
return
}
// Otherwise, it's a custom item drop
guard let itemId = UUID(uuidString: droppedId),
let item = self.customItems.first(where: { $0.id == itemId }) else { return }
// Append at end of day's items
let maxSortOrder = self.customItems
.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) async {
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)")
let override = TravelDayOverride(
tripId: trip.id,
travelAnchorId: travelAnchorId,
displayDay: displayDay
)
do {
_ = try await TravelOverrideService.shared.saveOverride(override)
print("✅ [TravelOverrides] Saved to CloudKit")
} catch {
print("❌ [TravelOverrides] CloudKit save failed: \(error)")
}
}
}
// MARK: - Itinerary Section
enum ItinerarySection {
case day(dayNumber: Int, date: Date, games: [RichGame])
case travel(TravelSegment)
case customItem(CustomItineraryItem)
case addButton(day: Int)
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 {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showEVChargers.toggle()
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "bolt.fill")
.foregroundStyle(.green)
.font(.caption)
Text("\(segment.evChargingStops.count) EV Charger\(segment.evChargingStops.count > 1 ? "s" : "") Along Route")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Image(systemName: showEVChargers ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if showEVChargers {
VStack(spacing: 0) {
ForEach(segment.evChargingStops) { charger in
EVChargerRow(charger: charger)
}
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.bottom, Theme.Spacing.sm)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.background {
// 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: [CustomItineraryItem]
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 coordinate = item.coordinate {
Annotation(item.title, coordinate: coordinate) {
ZStack {
Circle()
.fill(Theme.warmOrange)
.frame(width: 24, height: 24)
Image(systemName: item.category.systemImage)
.font(.caption2)
.foregroundStyle(.white)
}
}
}
}
}
.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")
)
)
)
}
}