349 lines
12 KiB
Swift
349 lines
12 KiB
Swift
//
|
|
// AddItemSheet.swift
|
|
// SportsTime
|
|
//
|
|
// Sheet for adding/editing custom itinerary items
|
|
//
|
|
|
|
import SwiftUI
|
|
import MapKit
|
|
|
|
/// Legacy sheet for adding/editing custom itinerary items.
|
|
/// - Note: Use `QuickAddItemSheet` instead for new code.
|
|
@available(*, deprecated, message: "Use QuickAddItemSheet instead")
|
|
struct AddItemSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let tripId: UUID
|
|
let day: Int
|
|
let existingItem: ItineraryItem?
|
|
var onSave: (ItineraryItem) -> Void
|
|
|
|
// Entry mode
|
|
enum EntryMode: String, CaseIterable {
|
|
case searchPlaces = "Search Places"
|
|
case custom = "Custom"
|
|
}
|
|
|
|
@State private var entryMode: EntryMode = .searchPlaces
|
|
@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) {
|
|
// 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, let info = existing.customInfo {
|
|
title = info.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(Theme.textMuted(colorScheme))
|
|
.accessibilityHidden(true)
|
|
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(Theme.textMuted(colorScheme))
|
|
}
|
|
.minimumHitTarget()
|
|
.accessibilityLabel("Clear search")
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.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(Theme.cardBackground(colorScheme))
|
|
.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: ", ")
|
|
}
|
|
|
|
private func saveItem() {
|
|
isSaving = true
|
|
|
|
let item: ItineraryItem
|
|
|
|
if let existing = existingItem, let existingInfo = existing.customInfo {
|
|
// Editing existing item - preserve day and sortOrder
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
let customInfo = CustomInfo(
|
|
title: trimmedTitle,
|
|
icon: "\u{1F4CC}",
|
|
time: existingInfo.time,
|
|
latitude: existingInfo.latitude,
|
|
longitude: existingInfo.longitude,
|
|
address: existingInfo.address
|
|
)
|
|
|
|
item = ItineraryItem(
|
|
id: existing.id,
|
|
tripId: existing.tripId,
|
|
day: existing.day,
|
|
sortOrder: existing.sortOrder,
|
|
kind: .custom(customInfo),
|
|
modifiedAt: Date()
|
|
)
|
|
} else if entryMode == .searchPlaces, let place = selectedPlace {
|
|
// Creating from MapKit search
|
|
let placeName = place.name ?? "Unknown Place"
|
|
let coordinate = place.placemark.coordinate
|
|
|
|
let customInfo = CustomInfo(
|
|
title: placeName,
|
|
icon: "\u{1F4CC}",
|
|
time: nil,
|
|
latitude: coordinate.latitude,
|
|
longitude: coordinate.longitude,
|
|
address: formatAddress(for: place)
|
|
)
|
|
|
|
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
|
item = ItineraryItem(
|
|
tripId: tripId,
|
|
day: day,
|
|
sortOrder: 0.0,
|
|
kind: .custom(customInfo)
|
|
)
|
|
} else {
|
|
// Creating custom item (no location)
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
let customInfo = CustomInfo(
|
|
title: trimmedTitle,
|
|
icon: "\u{1F4CC}",
|
|
time: nil
|
|
)
|
|
|
|
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
|
item = ItineraryItem(
|
|
tripId: tripId,
|
|
day: day,
|
|
sortOrder: 0.0,
|
|
kind: .custom(customInfo)
|
|
)
|
|
}
|
|
|
|
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)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 12)
|
|
.background(isSelected ? Color.green.opacity(0.1) : Color.clear)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
|
|
private var formattedAddress: String? {
|
|
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
|
|
return cityContext
|
|
}
|
|
return item.address?.shortAddress ?? item.address?.fullAddress
|
|
}
|
|
}
|