Replace all system colors (.secondary, Color(.secondarySystemBackground), etc.) with Theme.textPrimary/textSecondary/textMuted/cardBackground/ surfaceGlow across 13 views. Remove PostHog debug logging. Add debug settings for sample trips and hardcoded group poll preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
443 lines
14 KiB
Swift
443 lines
14 KiB
Swift
//
|
||
// AddItemSheet.swift
|
||
// SportsTime
|
||
//
|
||
// Sheet for adding/editing custom itinerary items
|
||
//
|
||
|
||
import SwiftUI
|
||
import MapKit
|
||
|
||
/// Category for custom itinerary items with emoji icons
|
||
enum ItemCategory: String, CaseIterable {
|
||
case restaurant
|
||
case attraction
|
||
case fuel
|
||
case hotel
|
||
case other
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .restaurant: return "\u{1F37D}" // 🍽️
|
||
case .attraction: return "\u{1F3A2}" // 🎢
|
||
case .fuel: return "\u{26FD}" // ⛽
|
||
case .hotel: return "\u{1F3E8}" // 🏨
|
||
case .other: return "\u{1F4CC}" // 📌
|
||
}
|
||
}
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .restaurant: return "Eat"
|
||
case .attraction: return "See"
|
||
case .fuel: return "Fuel"
|
||
case .hotel: return "Stay"
|
||
case .other: return "Other"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 selectedCategory: 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, let info = existing.customInfo {
|
||
// Find matching category by icon, default to .other
|
||
selectedCategory = ItemCategory.allCases.first { $0.icon == info.icon } ?? .other
|
||
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: ", ")
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var categoryPicker: some View {
|
||
HStack(spacing: Theme.Spacing.md) {
|
||
ForEach(ItemCategory.allCases, id: \.self) { category in
|
||
CategoryButton(
|
||
category: category,
|
||
isSelected: selectedCategory == category
|
||
) {
|
||
selectedCategory = category
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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: selectedCategory.icon,
|
||
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: selectedCategory.icon,
|
||
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: selectedCategory.icon,
|
||
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? {
|
||
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: 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)
|
||
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
AddItemSheet(
|
||
tripId: UUID(),
|
||
day: 1,
|
||
existingItem: nil
|
||
) { _ in }
|
||
}
|