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 sortOrderKey = "sortOrder"
static let createdAtKey = "createdAt" static let createdAtKey = "createdAt"
static let modifiedAtKey = "modifiedAt" static let modifiedAtKey = "modifiedAt"
// Location fields for mappable items
static let latitudeKey = "latitude"
static let longitudeKey = "longitude"
static let addressKey = "address"
let record: CKRecord let record: CKRecord
@@ -660,6 +664,10 @@ struct CKCustomItineraryItem {
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
record[CKCustomItineraryItem.createdAtKey] = item.createdAt record[CKCustomItineraryItem.createdAtKey] = item.createdAt
record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt 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 self.record = record
} }
@@ -681,6 +689,11 @@ struct CKCustomItineraryItem {
let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String
let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0 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( return CustomItineraryItem(
id: itemId, id: itemId,
tripId: tripId, tripId: tripId,
@@ -691,7 +704,10 @@ struct CKCustomItineraryItem {
anchorDay: anchorDay, anchorDay: anchorDay,
sortOrder: sortOrder, sortOrder: sortOrder,
createdAt: createdAt, createdAt: createdAt,
modifiedAt: modifiedAt modifiedAt: modifiedAt,
latitude: latitude,
longitude: longitude,
address: address
) )
} }
} }

View File

@@ -4,6 +4,7 @@
// //
import Foundation import Foundation
import CoreLocation
struct CustomItineraryItem: Identifiable, Codable, Hashable { struct CustomItineraryItem: Identifiable, Codable, Hashable {
let id: UUID let id: UUID
@@ -17,6 +18,22 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
let createdAt: Date let createdAt: Date
var modifiedAt: 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( init(
id: UUID = UUID(), id: UUID = UUID(),
tripId: UUID, tripId: UUID,
@@ -27,7 +44,10 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
anchorDay: Int, anchorDay: Int,
sortOrder: Int = 0, sortOrder: Int = 0,
createdAt: Date = Date(), createdAt: Date = Date(),
modifiedAt: Date = Date() modifiedAt: Date = Date(),
latitude: Double? = nil,
longitude: Double? = nil,
address: String? = nil
) { ) {
self.id = id self.id = id
self.tripId = tripId self.tripId = tripId
@@ -39,6 +59,9 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
self.sortOrder = sortOrder self.sortOrder = sortOrder
self.createdAt = createdAt self.createdAt = createdAt
self.modifiedAt = modifiedAt self.modifiedAt = modifiedAt
self.latitude = latitude
self.longitude = longitude
self.address = address
} }
enum ItemCategory: String, Codable, CaseIterable { enum ItemCategory: String, Codable, CaseIterable {

View File

@@ -60,6 +60,10 @@ actor CustomItemService {
existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
existingRecord[CKCustomItineraryItem.sortOrderKey] = item.sortOrder existingRecord[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
existingRecord[CKCustomItineraryItem.modifiedAtKey] = now 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 { do {
try await publicDatabase.save(existingRecord) try await publicDatabase.save(existingRecord)

View File

@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import MapKit
struct AddItemSheet: View { struct AddItemSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -18,10 +19,23 @@ struct AddItemSheet: View {
let existingItem: CustomItineraryItem? let existingItem: CustomItineraryItem?
var onSave: (CustomItineraryItem) -> Void 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 selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
@State private var title: String = "" @State private var title: String = ""
@State private var isSaving = false @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 } private var isEditing: Bool { existingItem != nil }
var body: some View { var body: some View {
@@ -30,10 +44,25 @@ struct AddItemSheet: View {
// Category picker // Category picker
categoryPicker categoryPicker
// Title input // Entry mode picker (only for new items)
TextField("What's the plan?", text: $title) if !isEditing {
.textFieldStyle(.roundedBorder) Picker("Entry Mode", selection: $entryMode) {
.font(.body) 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() Spacer()
} }
@@ -49,18 +78,158 @@ struct AddItemSheet: View {
Button(isEditing ? "Save" : "Add") { Button(isEditing ? "Save" : "Add") {
saveItem() saveItem()
} }
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .disabled(!canSave || isSaving)
} }
} }
.onAppear { .onAppear {
if let existing = existingItem { if let existing = existingItem {
selectedCategory = existing.category selectedCategory = existing.category
title = existing.title 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 @ViewBuilder
private var categoryPicker: some View { private var categoryPicker: some View {
HStack(spacing: Theme.Spacing.md) { HStack(spacing: Theme.Spacing.md) {
@@ -76,13 +245,15 @@ struct AddItemSheet: View {
} }
private func saveItem() { private func saveItem() {
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
isSaving = true isSaving = true
let item: CustomItineraryItem let item: CustomItineraryItem
if let existing = existingItem { if let existing = existingItem {
// Editing existing item
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
item = CustomItineraryItem( item = CustomItineraryItem(
id: existing.id, id: existing.id,
tripId: existing.tripId, tripId: existing.tripId,
@@ -91,10 +262,34 @@ struct AddItemSheet: View {
anchorType: existing.anchorType, anchorType: existing.anchorType,
anchorId: existing.anchorId, anchorId: existing.anchorId,
anchorDay: existing.anchorDay, anchorDay: existing.anchorDay,
sortOrder: existing.sortOrder,
createdAt: existing.createdAt, 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 { } else {
// Creating custom item (no location)
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
guard !trimmedTitle.isEmpty else { return }
item = CustomItineraryItem( item = CustomItineraryItem(
tripId: tripId, tripId: tripId,
category: selectedCategory, 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 // MARK: - Category Button
private struct CategoryButton: View { private struct CategoryButton: View {

View File

@@ -436,21 +436,51 @@ final class ItineraryTableViewController: UITableViewController {
// MARK: - Helper Methods // MARK: - Helper Methods
private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) { private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) {
if row > 0 { // Scan backwards to find the day's context
let previousItem = flatItems[row - 1] // Structure: travel (optional) -> dayHeader -> items
switch previousItem { var foundTravel: TravelSegment?
var foundDayGames: [RichGame] = []
for i in stride(from: row - 1, through: 0, by: -1) {
switch flatItems[i] {
case .travel(let segment, _): 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())" 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): 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 { if let lastGame = games.last {
return (.afterGame, lastGame.game.id) return (.afterGame, lastGame.game.id)
} }
return (.startOfDay, nil) // No games - check if there's travel before this day
default: // Continue scanning to find travel
return (.startOfDay, nil) 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) return (.startOfDay, nil)
} }
@@ -664,10 +694,29 @@ struct CustomItemRowView: View {
Text(item.category.icon) Text(item.category.icon)
.font(.title3) .font(.title3)
Text(item.title) VStack(alignment: .leading, spacing: 2) {
.font(.subheadline) HStack(spacing: 4) {
.foregroundStyle(Theme.textPrimary(colorScheme)) Text(item.title)
.lineLimit(2) .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() Spacer()

View File

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

View File

@@ -28,7 +28,8 @@ struct TripDetailView: View {
@State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress? @State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress?
@State private var mapCameraPosition: MapCameraPosition = .automatic @State private var mapCameraPosition: MapCameraPosition = .automatic
@State private var isSaved = false @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 isLoadingRoutes = false
@State private var loadedGames: [String: RichGame] = [:] @State private var loadedGames: [String: RichGame] = [:]
@State private var isLoadingGames = false @State private var isLoadingGames = false
@@ -150,10 +151,19 @@ struct TripDetailView: View {
.onDisappear { .onDisappear {
subscriptionCancellable?.cancel() subscriptionCancellable?.cancel()
} }
.onChange(of: customItems) { _, _ in .onChange(of: customItems) { _, newItems in
// Clear drag state after items update (move completed) // Clear drag state after items update (move completed)
draggedItem = nil draggedItem = nil
dropTargetId = 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 .onChange(of: travelDayOverrides) { _, _ in
// Clear drag state after travel move completed // Clear drag state after travel move completed
@@ -320,20 +330,15 @@ struct TripDetailView: View {
private var heroMapSection: some View { private var heroMapSection: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
Map(position: $mapCameraPosition, interactionModes: []) { TripMapView(
ForEach(stopCoordinates.indices, id: \.self) { index in cameraPosition: $mapCameraPosition,
let stop = stopCoordinates[index] routeCoordinates: routeCoordinates,
Annotation(stop.name, coordinate: stop.coordinate) { stopCoordinates: stopCoordinates,
PulsingDot(color: index == 0 ? Theme.warmOrange : Theme.routeGold, size: 10) customItems: mappableCustomItems,
} colorScheme: colorScheme,
} routeVersion: mapUpdateTrigger
)
ForEach(routePolylines.indices, id: \.self) { index in .id("map-\(mapUpdateTrigger)")
MapPolyline(routePolylines[index])
.stroke(Theme.routeGold, lineWidth: 4)
}
}
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
// Save/Unsave heart button // Save/Unsave heart button
Button { Button {
@@ -1066,15 +1071,23 @@ struct TripDetailView: View {
// MARK: - Map Helpers // MARK: - Map Helpers
private func fetchDrivingRoutes() async { private func fetchDrivingRoutes() async {
let stops = stopCoordinates // Use routeWaypoints which includes game stops + mappable custom items
guard stops.count >= 2 else { return } 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 isLoadingRoutes = true
var polylines: [MKPolyline] = [] var allCoordinates: [[CLLocationCoordinate2D]] = []
for i in 0..<(stops.count - 1) { for i in 0..<(waypoints.count - 1) {
let source = stops[i] let source = waypoints[i]
let destination = stops[i + 1] let destination = waypoints[i + 1]
let request = MKDirections.Request() let request = MKDirections.Request()
let sourceLocation = CLLocation(latitude: source.coordinate.latitude, longitude: source.coordinate.longitude) let sourceLocation = CLLocation(latitude: source.coordinate.latitude, longitude: source.coordinate.longitude)
@@ -1088,16 +1101,28 @@ struct TripDetailView: View {
do { do {
let response = try await directions.calculate() let response = try await directions.calculate()
if let route = response.routes.first { 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 { } catch {
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2) // Fallback to straight line if directions unavailable
polylines.append(straightLine) allCoordinates.append([source.coordinate, destination.coordinate])
} }
} }
routePolylines = polylines await MainActor.run {
isLoadingRoutes = false 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)] { private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
@@ -1113,10 +1138,127 @@ struct TripDetailView: View {
} }
} }
private func updateMapRegion() { /// Mappable custom items for display on the map
guard !stopCoordinates.isEmpty else { return } 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 lats = coordinates.map(\.latitude)
let lons = coordinates.map(\.longitude) let lons = coordinates.map(\.longitude)
@@ -1247,6 +1389,8 @@ struct TripDetailView: View {
if let count = try? modelContext.fetchCount(descriptor), count > 0 { if let count = try? modelContext.fetchCount(descriptor), count > 0 {
isSaved = true isSaved = true
} else {
isSaved = false
} }
} }
@@ -1277,9 +1421,6 @@ struct TripDetailView: View {
do { do {
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id) let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit") 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 customItems = items
// Also load travel day overrides // Also load travel day overrides
@@ -1804,6 +1945,65 @@ struct ShareSheet: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} 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 { #Preview {
NavigationStack { NavigationStack {
TripDetailView( TripDetailView(