Replace complex anchor system (anchorType, anchorId, anchorDay) with simple (day: Int, sortOrder: Double) positioning for custom items. Changes: - CustomItineraryItem: Remove anchor fields, add day and sortOrder - CKModels: Add migration fallback from old CloudKit fields - ItineraryTableViewController: Add calculateSortOrder() for midpoint insertion - TripDetailView: Simplify callbacks, itinerarySections, and routeWaypoints - AddItemSheet: Take simple day parameter instead of anchor - SavedTrip: Update LocalCustomItem SwiftData model Benefits: - Items freely movable via drag-and-drop - Route waypoints follow exact visual order - Simpler mental model: position = (day, sortOrder) - Midpoint insertion allows unlimited reordering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
388 lines
12 KiB
Swift
388 lines
12 KiB
Swift
//
|
|
// AddItemSheet.swift
|
|
// SportsTime
|
|
//
|
|
// Sheet for adding/editing custom itinerary items
|
|
//
|
|
|
|
import SwiftUI
|
|
import MapKit
|
|
|
|
struct AddItemSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let tripId: UUID
|
|
let day: Int
|
|
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 {
|
|
NavigationStack {
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Category picker
|
|
categoryPicker
|
|
|
|
// 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()
|
|
}
|
|
.padding()
|
|
.background(Theme.backgroundGradient(colorScheme))
|
|
.navigationTitle(isEditing ? "Edit Item" : "Add Item")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(isEditing ? "Save" : "Add") {
|
|
saveItem()
|
|
}
|
|
.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) {
|
|
ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in
|
|
CategoryButton(
|
|
category: category,
|
|
isSelected: selectedCategory == category
|
|
) {
|
|
selectedCategory = category
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveItem() {
|
|
isSaving = true
|
|
|
|
let item: CustomItineraryItem
|
|
|
|
if let existing = existingItem {
|
|
// Editing existing item - preserve day and sortOrder
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
item = CustomItineraryItem(
|
|
id: existing.id,
|
|
tripId: existing.tripId,
|
|
category: selectedCategory,
|
|
title: trimmedTitle,
|
|
day: existing.day,
|
|
sortOrder: existing.sortOrder,
|
|
createdAt: existing.createdAt,
|
|
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
|
|
|
|
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
|
item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: selectedCategory,
|
|
title: placeName,
|
|
day: day,
|
|
sortOrder: 0.0,
|
|
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 }
|
|
|
|
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
|
item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: selectedCategory,
|
|
title: trimmedTitle,
|
|
day: day,
|
|
sortOrder: 0.0
|
|
)
|
|
}
|
|
|
|
onSave(item)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
let category: CustomItineraryItem.ItemCategory
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(spacing: 4) {
|
|
Text(category.icon)
|
|
.font(.title2)
|
|
Text(category.label)
|
|
.font(.caption2)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(isSelected ? Theme.warmOrange.opacity(0.2) : Color.clear)
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(isSelected ? Theme.warmOrange : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
AddItemSheet(
|
|
tripId: UUID(),
|
|
day: 1,
|
|
existingItem: nil
|
|
) { _ in }
|
|
}
|