Files
Sportstime/SportsTime/Features/Trip/Views/AddItemSheet.swift
Trey t 2a8bfeeff8 refactor(itinerary): replace anchor-based positioning with day/sortOrder
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>
2026-01-17 09:47:11 -06:00

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 }
}