Files
Sportstime/SportsTime/Features/Trip/Views/AddItemSheet.swift
Trey t cd00384010 refactor(itinerary): replace CustomItineraryItem with ItineraryItem across codebase
- Update CKModels.swift to remove deleted type references
- Migrate LocalCustomItem to LocalItineraryItem in SavedTrip.swift
- Update AppDelegate to handle subscription removal
- Refactor AddItemSheet to create ItineraryItem with CustomInfo
- Update ItineraryTableViewController and Wrapper for new model
- Refactor TripDetailView state, methods and callbacks
- Fix TripMapView to display custom items with new model structure

This completes the migration from the legacy CustomItineraryItem/TravelDayOverride
model to the unified ItineraryItem model with ItemKind enum.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:51:32 -06:00

432 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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"
}
}
}
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(.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(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)
}
}
.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: 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 }
}