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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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