WIP: map route updates and custom item drag/drop fixes (broken)

- Fixed map header not updating in ItineraryTableViewWrapper using Coordinator pattern
- Added routeVersion UUID to force Map re-render when routes change
- Fixed determineAnchor to scan backwards for correct anchor context
- Added location support to CustomItineraryItem (lat/lng/address)
- Added MapKit place search to AddItemSheet
- Added extensive debug logging for route waypoint calculation

Known issues:
- Custom items still not routing correctly after drag/drop
- Anchor type determination may still have bugs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 00:00:57 -06:00
parent 43501b6ac1
commit 8df33a5614
7 changed files with 605 additions and 57 deletions

View File

@@ -638,6 +638,10 @@ struct CKCustomItineraryItem {
static let sortOrderKey = "sortOrder"
static let createdAtKey = "createdAt"
static let modifiedAtKey = "modifiedAt"
// Location fields for mappable items
static let latitudeKey = "latitude"
static let longitudeKey = "longitude"
static let addressKey = "address"
let record: CKRecord
@@ -660,6 +664,10 @@ struct CKCustomItineraryItem {
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
record[CKCustomItineraryItem.createdAtKey] = item.createdAt
record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
// Location fields (nil values are not stored in CloudKit)
record[CKCustomItineraryItem.latitudeKey] = item.latitude
record[CKCustomItineraryItem.longitudeKey] = item.longitude
record[CKCustomItineraryItem.addressKey] = item.address
self.record = record
}
@@ -681,6 +689,11 @@ struct CKCustomItineraryItem {
let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String
let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0
// Location fields (optional - nil if not stored)
let latitude = record[CKCustomItineraryItem.latitudeKey] as? Double
let longitude = record[CKCustomItineraryItem.longitudeKey] as? Double
let address = record[CKCustomItineraryItem.addressKey] as? String
return CustomItineraryItem(
id: itemId,
tripId: tripId,
@@ -691,7 +704,10 @@ struct CKCustomItineraryItem {
anchorDay: anchorDay,
sortOrder: sortOrder,
createdAt: createdAt,
modifiedAt: modifiedAt
modifiedAt: modifiedAt,
latitude: latitude,
longitude: longitude,
address: address
)
}
}

View File

@@ -4,6 +4,7 @@
//
import Foundation
import CoreLocation
struct CustomItineraryItem: Identifiable, Codable, Hashable {
let id: UUID
@@ -17,6 +18,22 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
let createdAt: Date
var modifiedAt: Date
// Optional location for mappable items (from MapKit search)
var latitude: Double?
var longitude: Double?
var address: String?
/// Whether this item has a location and can be shown on the map
var isMappable: Bool {
latitude != nil && longitude != nil
}
/// Get coordinate if mappable
var coordinate: CLLocationCoordinate2D? {
guard let lat = latitude, let lon = longitude else { return nil }
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
init(
id: UUID = UUID(),
tripId: UUID,
@@ -27,7 +44,10 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
anchorDay: Int,
sortOrder: Int = 0,
createdAt: Date = Date(),
modifiedAt: Date = Date()
modifiedAt: Date = Date(),
latitude: Double? = nil,
longitude: Double? = nil,
address: String? = nil
) {
self.id = id
self.tripId = tripId
@@ -39,6 +59,9 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
self.sortOrder = sortOrder
self.createdAt = createdAt
self.modifiedAt = modifiedAt
self.latitude = latitude
self.longitude = longitude
self.address = address
}
enum ItemCategory: String, Codable, CaseIterable {

View File

@@ -60,6 +60,10 @@ actor CustomItemService {
existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
existingRecord[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
existingRecord[CKCustomItineraryItem.modifiedAtKey] = now
// Location fields (nil values clear the field in CloudKit)
existingRecord[CKCustomItineraryItem.latitudeKey] = item.latitude
existingRecord[CKCustomItineraryItem.longitudeKey] = item.longitude
existingRecord[CKCustomItineraryItem.addressKey] = item.address
do {
try await publicDatabase.save(existingRecord)

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import MapKit
struct AddItemSheet: View {
@Environment(\.dismiss) private var dismiss
@@ -18,10 +19,23 @@ struct AddItemSheet: View {
let existingItem: CustomItineraryItem?
var onSave: (CustomItineraryItem) -> Void
// Entry mode
enum EntryMode: String, CaseIterable {
case searchPlaces = "Search Places"
case custom = "Custom"
}
@State private var entryMode: EntryMode = .searchPlaces
@State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
@State private var title: String = ""
@State private var isSaving = false
// MapKit search state
@State private var searchQuery = ""
@State private var searchResults: [MKMapItem] = []
@State private var selectedPlace: MKMapItem?
@State private var isSearching = false
private var isEditing: Bool { existingItem != nil }
var body: some View {
@@ -30,10 +44,25 @@ struct AddItemSheet: View {
// Category picker
categoryPicker
// Title input
TextField("What's the plan?", text: $title)
.textFieldStyle(.roundedBorder)
.font(.body)
// Entry mode picker (only for new items)
if !isEditing {
Picker("Entry Mode", selection: $entryMode) {
ForEach(EntryMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
if entryMode == .searchPlaces && !isEditing {
// MapKit search UI
searchPlacesView
} else {
// Custom text entry
TextField("What's the plan?", text: $title)
.textFieldStyle(.roundedBorder)
.font(.body)
}
Spacer()
}
@@ -49,18 +78,158 @@ struct AddItemSheet: View {
Button(isEditing ? "Save" : "Add") {
saveItem()
}
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.disabled(!canSave || isSaving)
}
}
.onAppear {
if let existing = existingItem {
selectedCategory = existing.category
title = existing.title
// If editing a mappable item, switch to custom mode
entryMode = .custom
}
}
}
}
private var canSave: Bool {
if entryMode == .searchPlaces && !isEditing {
return selectedPlace != nil
} else {
return !title.trimmingCharacters(in: .whitespaces).isEmpty
}
}
@ViewBuilder
private var searchPlacesView: some View {
VStack(spacing: Theme.Spacing.md) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search for a place...", text: $searchQuery)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onSubmit {
performSearch()
}
if isSearching {
ProgressView()
.scaleEffect(0.8)
} else if !searchQuery.isEmpty {
Button {
searchQuery = ""
searchResults = []
selectedPlace = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(10)
// Search results
if !searchResults.isEmpty {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(searchResults, id: \.self) { item in
PlaceResultRow(
item: item,
isSelected: selectedPlace == item
) {
selectedPlace = item
}
Divider()
}
}
}
.frame(maxHeight: 300)
.background(Color(.systemBackground))
.cornerRadius(10)
} else if !searchQuery.isEmpty && !isSearching {
Text("No results found")
.foregroundStyle(.secondary)
.font(.subheadline)
.padding()
}
// Selected place preview
if let place = selectedPlace {
selectedPlacePreview(place)
}
}
}
@ViewBuilder
private func selectedPlacePreview(_ place: MKMapItem) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(place.name ?? "Unknown Place")
.font(.headline)
Spacer()
}
if let address = formatAddress(for: place) {
Text(address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color.green.opacity(0.1))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.green.opacity(0.3), lineWidth: 1)
)
}
private func performSearch() {
let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespaces)
guard !trimmedQuery.isEmpty else { return }
isSearching = true
selectedPlace = nil
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = trimmedQuery
let search = MKLocalSearch(request: request)
search.start { response, error in
isSearching = false
if let response = response {
searchResults = response.mapItems
} else {
searchResults = []
}
}
}
private func formatAddress(for item: MKMapItem) -> String? {
let placemark = item.placemark
var components: [String] = []
if let thoroughfare = placemark.thoroughfare {
if let subThoroughfare = placemark.subThoroughfare {
components.append("\(subThoroughfare) \(thoroughfare)")
} else {
components.append(thoroughfare)
}
}
if let locality = placemark.locality {
components.append(locality)
}
if let administrativeArea = placemark.administrativeArea {
components.append(administrativeArea)
}
return components.isEmpty ? nil : components.joined(separator: ", ")
}
@ViewBuilder
private var categoryPicker: some View {
HStack(spacing: Theme.Spacing.md) {
@@ -76,13 +245,15 @@ struct AddItemSheet: View {
}
private func saveItem() {
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
isSaving = true
let item: CustomItineraryItem
if let existing = existingItem {
// Editing existing item
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
item = CustomItineraryItem(
id: existing.id,
tripId: existing.tripId,
@@ -91,10 +262,34 @@ struct AddItemSheet: View {
anchorType: existing.anchorType,
anchorId: existing.anchorId,
anchorDay: existing.anchorDay,
sortOrder: existing.sortOrder,
createdAt: existing.createdAt,
modifiedAt: Date()
modifiedAt: Date(),
latitude: existing.latitude,
longitude: existing.longitude,
address: existing.address
)
} else if entryMode == .searchPlaces, let place = selectedPlace {
// Creating from MapKit search
let placeName = place.name ?? "Unknown Place"
let coordinate = place.placemark.coordinate
item = CustomItineraryItem(
tripId: tripId,
category: selectedCategory,
title: placeName,
anchorType: anchorType,
anchorId: anchorId,
anchorDay: anchorDay,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
address: formatAddress(for: place)
)
} else {
// Creating custom item (no location)
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
item = CustomItineraryItem(
tripId: tripId,
category: selectedCategory,
@@ -110,6 +305,55 @@ struct AddItemSheet: View {
}
}
// MARK: - Place Result Row
private struct PlaceResultRow: View {
let item: MKMapItem
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(item.name ?? "Unknown")
.font(.subheadline)
.foregroundStyle(.primary)
if let address = formattedAddress {
Text(address)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(isSelected ? Color.green.opacity(0.1) : Color.clear)
}
.buttonStyle(.plain)
}
private var formattedAddress: String? {
let placemark = item.placemark
var components: [String] = []
if let locality = placemark.locality {
components.append(locality)
}
if let administrativeArea = placemark.administrativeArea {
components.append(administrativeArea)
}
return components.isEmpty ? nil : components.joined(separator: ", ")
}
}
// MARK: - Category Button
private struct CategoryButton: View {

View File

@@ -436,21 +436,51 @@ final class ItineraryTableViewController: UITableViewController {
// MARK: - Helper Methods
private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) {
if row > 0 {
let previousItem = flatItems[row - 1]
switch previousItem {
// Scan backwards to find the day's context
// Structure: travel (optional) -> dayHeader -> items
var foundTravel: TravelSegment?
var foundDayGames: [RichGame] = []
for i in stride(from: row - 1, through: 0, by: -1) {
switch flatItems[i] {
case .travel(let segment, _):
// Found travel - if this is the first significant item, use afterTravel
// But only if we haven't passed a day header yet
if foundDayGames.isEmpty {
foundTravel = segment
}
// Travel marks the boundary - stop scanning
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
return (.afterTravel, travelId)
// If the drop is right after travel (no day header between), use afterTravel
if foundDayGames.isEmpty {
return (.afterTravel, travelId)
}
// Otherwise we already passed a day header, use that context
break
case .dayHeader(_, _, let games):
// Found the day header for this section
foundDayGames = games
// If day has games, items dropped after should be afterGame
if let lastGame = games.last {
return (.afterGame, lastGame.game.id)
}
return (.startOfDay, nil)
default:
return (.startOfDay, nil)
// No games - check if there's travel before this day
// Continue scanning to find travel
continue
case .customItem, .addButton:
// Skip these, keep scanning backwards
continue
}
}
// If we found travel but no games, use afterTravel
if let segment = foundTravel {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
return (.afterTravel, travelId)
}
return (.startOfDay, nil)
}
@@ -664,10 +694,29 @@ struct CustomItemRowView: View {
Text(item.category.icon)
.font(.title3)
Text(item.title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(2)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(item.title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
// Map pin for mappable items
if item.isMappable {
Image(systemName: "mappin.circle.fill")
.font(.caption)
.foregroundStyle(Theme.warmOrange)
}
}
// Address subtitle for mappable items
if let address = item.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
}
Spacer()

View File

@@ -47,6 +47,14 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
self.onAddButtonTapped = onAddButtonTapped
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator {
var headerHostingController: UIHostingController<HeaderContent>?
}
func makeUIViewController(context: Context) -> ItineraryTableViewController {
let controller = ItineraryTableViewController(style: .plain)
controller.colorScheme = colorScheme
@@ -60,6 +68,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
let hostingController = UIHostingController(rootView: headerContent)
hostingController.view.backgroundColor = .clear
// Store in coordinator for later updates
context.coordinator.headerHostingController = hostingController
// Pre-size the header view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
let targetWidth = UIScreen.main.bounds.width
@@ -85,8 +96,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Note: Don't update header content here - it causes infinite layout loops
// Header is set once in makeUIViewController and remains static
// Update header content by updating the hosting controller's rootView
// This avoids recreating the view hierarchy and prevents infinite loops
context.coordinator.headerHostingController?.rootView = headerContent
let (days, validRanges) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges)

View File

@@ -28,7 +28,8 @@ struct TripDetailView: View {
@State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress?
@State private var mapCameraPosition: MapCameraPosition = .automatic
@State private var isSaved = false
@State private var routePolylines: [MKPolyline] = []
@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
@@ -150,10 +151,19 @@ struct TripDetailView: View {
.onDisappear {
subscriptionCancellable?.cancel()
}
.onChange(of: customItems) { _, _ in
.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.anchorDay), anchor: \(item.anchorType.rawValue)")
}
Task {
updateMapRegion()
await fetchDrivingRoutes()
}
}
.onChange(of: travelDayOverrides) { _, _ in
// Clear drag state after travel move completed
@@ -320,20 +330,15 @@ struct TripDetailView: View {
private var heroMapSection: some View {
ZStack(alignment: .bottom) {
Map(position: $mapCameraPosition, interactionModes: []) {
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)
}
}
ForEach(routePolylines.indices, id: \.self) { index in
MapPolyline(routePolylines[index])
.stroke(Theme.routeGold, lineWidth: 4)
}
}
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
TripMapView(
cameraPosition: $mapCameraPosition,
routeCoordinates: routeCoordinates,
stopCoordinates: stopCoordinates,
customItems: mappableCustomItems,
colorScheme: colorScheme,
routeVersion: mapUpdateTrigger
)
.id("map-\(mapUpdateTrigger)")
.overlay(alignment: .topTrailing) {
// Save/Unsave heart button
Button {
@@ -1066,15 +1071,23 @@ struct TripDetailView: View {
// MARK: - Map Helpers
private func fetchDrivingRoutes() async {
let stops = stopCoordinates
guard stops.count >= 2 else { return }
// 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 polylines: [MKPolyline] = []
var allCoordinates: [[CLLocationCoordinate2D]] = []
for i in 0..<(stops.count - 1) {
let source = stops[i]
let destination = stops[i + 1]
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)
@@ -1088,16 +1101,28 @@ struct TripDetailView: View {
do {
let response = try await directions.calculate()
if let route = response.routes.first {
polylines.append(route.polyline)
// 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 {
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
polylines.append(straightLine)
// Fallback to straight line if directions unavailable
allCoordinates.append([source.coordinate, destination.coordinate])
}
}
routePolylines = polylines
isLoadingRoutes = false
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)] {
@@ -1113,10 +1138,127 @@ struct TripDetailView: View {
}
}
private func updateMapRegion() {
guard !stopCoordinates.isEmpty else { return }
/// Mappable custom items for display on the map
private var mappableCustomItems: [CustomItineraryItem] {
customItems.filter { $0.isMappable }
}
let coordinates = stopCoordinates.map(\.coordinate)
/// 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
// Group custom items by day
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.anchorDay }
print("🗺️ [Waypoints] Building waypoints. Mappable items by day:")
for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) {
for item in items {
print("🗺️ [Waypoints] Day \(day): \(item.title), anchor: \(item.anchorType.rawValue)")
}
}
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)")
// Custom items at start of day (before games)
if let items = itemsByDay[dayNumber] {
let startItems = items.filter { $0.anchorType == .startOfDay }
.sorted { $0.sortOrder < $1.sortOrder }
for item in startItems {
if let coord = item.coordinate {
print("🗺️ [Waypoints] Adding \(item.title) (startOfDay)")
waypoints.append((item.title, coord, true))
}
}
}
// 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 after games
if let items = itemsByDay[dayNumber] {
let afterGameItems = items.filter { $0.anchorType == .afterGame }
.sorted { $0.sortOrder < $1.sortOrder }
for item in afterGameItems {
if let coord = item.coordinate {
print("🗺️ [Waypoints] Adding \(item.title) (afterGame)")
waypoints.append((item.title, coord, true))
}
}
// Custom items after travel
let afterTravelItems = items.filter { $0.anchorType == .afterTravel }
.sorted { $0.sortOrder < $1.sortOrder }
for item in afterTravelItems {
if let coord = item.coordinate {
print("🗺️ [Waypoints] Adding \(item.title) (afterTravel)")
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)
@@ -1247,6 +1389,8 @@ struct TripDetailView: View {
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
isSaved = true
} else {
isSaved = false
}
}
@@ -1277,9 +1421,6 @@ struct TripDetailView: View {
do {
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
for item in items {
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue), sortOrder: \(item.sortOrder))")
}
customItems = items
// Also load travel day overrides
@@ -1804,6 +1945,65 @@ struct ShareSheet: UIViewControllerRepresentable {
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(