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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user