From 8df33a56148d19275e10872bb156b4d5fdfb45e2 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 00:00:57 -0600 Subject: [PATCH] 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 --- .../Core/Models/CloudKit/CKModels.swift | 18 +- .../Models/Domain/CustomItineraryItem.swift | 25 +- .../Core/Services/CustomItemService.swift | 4 + .../Features/Trip/Views/AddItemSheet.swift | 262 ++++++++++++++++- .../Views/ItineraryTableViewController.swift | 71 ++++- .../Views/ItineraryTableViewWrapper.swift | 16 +- .../Features/Trip/Views/TripDetailView.swift | 266 +++++++++++++++--- 7 files changed, 605 insertions(+), 57 deletions(-) diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 03cc7d3..415f653 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -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 ) } } diff --git a/SportsTime/Core/Models/Domain/CustomItineraryItem.swift b/SportsTime/Core/Models/Domain/CustomItineraryItem.swift index e56e339..004705f 100644 --- a/SportsTime/Core/Models/Domain/CustomItineraryItem.swift +++ b/SportsTime/Core/Models/Domain/CustomItineraryItem.swift @@ -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 { diff --git a/SportsTime/Core/Services/CustomItemService.swift b/SportsTime/Core/Services/CustomItemService.swift index 1b1715b..1c8e39e 100644 --- a/SportsTime/Core/Services/CustomItemService.swift +++ b/SportsTime/Core/Services/CustomItemService.swift @@ -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) diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift index 3f0181a..66fdada 100644 --- a/SportsTime/Features/Trip/Views/AddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -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 { diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 17c4373..8a7cb79 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -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() diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index 0e80c6a..733658b 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -47,6 +47,14 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent self.onAddButtonTapped = onAddButtonTapped } + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator { + var headerHostingController: UIHostingController? + } + func makeUIViewController(context: Context) -> ItineraryTableViewController { let controller = ItineraryTableViewController(style: .plain) controller.colorScheme = colorScheme @@ -60,6 +68,9 @@ struct ItineraryTableViewWrapper: 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: 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) diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index c426b15..aacbbe7 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -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..= 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(