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

@@ -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(