Files
Sportstime/SportsTime/Features/Trip/Views/AddItemSheet.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
}
}