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