- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
832 lines
28 KiB
Swift
832 lines
28 KiB
Swift
//
|
|
// TripCreationView.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct TripCreationView: View {
|
|
@State private var viewModel = TripCreationViewModel()
|
|
@State private var showGamePicker = false
|
|
@State private var showCityInput = false
|
|
@State private var cityInputType: CityInputType = .mustStop
|
|
@State private var showLocationBanner = true
|
|
@State private var showTripDetail = false
|
|
@State private var completedTrip: Trip?
|
|
|
|
enum CityInputType {
|
|
case mustStop
|
|
case preferred
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Planning Mode Selector
|
|
planningModeSection
|
|
|
|
// Location Permission Banner (only for locations mode)
|
|
if viewModel.planningMode == .locations && showLocationBanner {
|
|
Section {
|
|
LocationPermissionBanner(isPresented: $showLocationBanner)
|
|
.listRowInsets(EdgeInsets())
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
|
|
// Mode-specific sections
|
|
switch viewModel.planningMode {
|
|
case .dateRange:
|
|
// Sports + Dates
|
|
sportsSection
|
|
datesSection
|
|
|
|
case .gameFirst:
|
|
// Sports + Game Picker
|
|
sportsSection
|
|
gameBrowserSection
|
|
tripBufferSection
|
|
|
|
case .locations:
|
|
// Locations + Sports + optional games
|
|
locationSection
|
|
sportsSection
|
|
datesSection
|
|
gamesSection
|
|
}
|
|
|
|
// Common sections
|
|
travelSection
|
|
constraintsSection
|
|
optionalSection
|
|
|
|
// Validation message
|
|
if let message = viewModel.formValidationMessage {
|
|
Section {
|
|
Label(message, systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Plan Your Trip")
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Plan") {
|
|
Task {
|
|
await viewModel.planTrip()
|
|
}
|
|
}
|
|
.disabled(!viewModel.isFormValid)
|
|
}
|
|
}
|
|
.overlay {
|
|
if case .planning = viewModel.viewState {
|
|
planningOverlay
|
|
}
|
|
}
|
|
.sheet(isPresented: $showGamePicker) {
|
|
GamePickerSheet(
|
|
games: viewModel.availableGames,
|
|
selectedIds: $viewModel.mustSeeGameIds
|
|
)
|
|
}
|
|
.sheet(isPresented: $showCityInput) {
|
|
LocationSearchSheet(inputType: cityInputType) { location in
|
|
switch cityInputType {
|
|
case .mustStop:
|
|
viewModel.addMustStopLocation(location)
|
|
case .preferred:
|
|
viewModel.addPreferredCity(location.name)
|
|
}
|
|
}
|
|
}
|
|
.alert("Error", isPresented: Binding(
|
|
get: { viewModel.viewState.isError },
|
|
set: { if !$0 { viewModel.viewState = .editing } }
|
|
)) {
|
|
Button("OK") {
|
|
viewModel.viewState = .editing
|
|
}
|
|
} message: {
|
|
if case .error(let message) = viewModel.viewState {
|
|
Text(message)
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $showTripDetail) {
|
|
if let trip = completedTrip {
|
|
TripDetailView(trip: trip, games: buildGamesDictionary())
|
|
}
|
|
}
|
|
.onChange(of: viewModel.viewState) { _, newState in
|
|
if case .completed(let trip) = newState {
|
|
completedTrip = trip
|
|
showTripDetail = true
|
|
}
|
|
}
|
|
.onChange(of: showTripDetail) { _, isShowing in
|
|
if !isShowing {
|
|
// User navigated back, reset to editing state
|
|
viewModel.viewState = .editing
|
|
completedTrip = nil
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.loadScheduleData()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var planningModeSection: some View {
|
|
Section {
|
|
Picker("Planning Mode", selection: $viewModel.planningMode) {
|
|
ForEach(PlanningMode.allCases) { mode in
|
|
Label(mode.displayName, systemImage: mode.iconName)
|
|
.tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
|
|
|
Text(viewModel.planningMode.description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var locationSection: some View {
|
|
Section("Locations") {
|
|
TextField("Start Location", text: $viewModel.startLocationText)
|
|
.textContentType(.addressCity)
|
|
|
|
TextField("End Location", text: $viewModel.endLocationText)
|
|
.textContentType(.addressCity)
|
|
}
|
|
}
|
|
|
|
private var gameBrowserSection: some View {
|
|
Section("Select Games") {
|
|
if viewModel.isLoadingGames {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Loading games...")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if viewModel.availableGames.isEmpty {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Loading games...")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.task {
|
|
await viewModel.loadGamesForBrowsing()
|
|
}
|
|
} else {
|
|
Button {
|
|
showGamePicker = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "sportscourt")
|
|
.foregroundStyle(.blue)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Browse Teams & Games")
|
|
.foregroundStyle(.primary)
|
|
Text("\(viewModel.availableGames.count) games available")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// Show selected games summary
|
|
if !viewModel.mustSeeGameIds.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
|
.fontWeight(.medium)
|
|
}
|
|
|
|
// Show selected games preview
|
|
ForEach(viewModel.selectedGames.prefix(3)) { game in
|
|
HStack(spacing: 8) {
|
|
Image(systemName: game.game.sport.iconName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
|
.font(.caption)
|
|
Spacer()
|
|
Text(game.game.formattedDate)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if viewModel.selectedGames.count > 3 {
|
|
Text("+ \(viewModel.selectedGames.count - 3) more")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tripBufferSection: some View {
|
|
Section("Trip Duration") {
|
|
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
|
|
|
|
if let dateRange = viewModel.gameFirstDateRange {
|
|
HStack {
|
|
Text("Trip window:")
|
|
Spacer()
|
|
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Text("Days before first game and after last game for travel/rest")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var sportsSection: some View {
|
|
Section("Sports") {
|
|
ForEach(Sport.supported) { sport in
|
|
Toggle(isOn: binding(for: sport)) {
|
|
Label(sport.rawValue, systemImage: sport.iconName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var datesSection: some View {
|
|
Section("Dates") {
|
|
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
|
|
|
|
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
|
|
|
|
Text("\(viewModel.tripDurationDays) day trip")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var gamesSection: some View {
|
|
Section("Must-See Games") {
|
|
Button {
|
|
showGamePicker = true
|
|
} label: {
|
|
HStack {
|
|
Text("Select Games")
|
|
Spacer()
|
|
Text("\(viewModel.selectedGamesCount) selected")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var travelSection: some View {
|
|
Section("Travel") {
|
|
Picker("Travel Mode", selection: $viewModel.travelMode) {
|
|
ForEach(TravelMode.allCases) { mode in
|
|
Label(mode.displayName, systemImage: mode.iconName)
|
|
.tag(mode)
|
|
}
|
|
}
|
|
|
|
Picker("Route Preference", selection: $viewModel.routePreference) {
|
|
ForEach(RoutePreference.allCases) { pref in
|
|
Text(pref.displayName).tag(pref)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var constraintsSection: some View {
|
|
Section("Trip Style") {
|
|
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
|
|
|
|
if viewModel.useStopCount {
|
|
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
|
|
}
|
|
|
|
Picker("Pace", selection: $viewModel.leisureLevel) {
|
|
ForEach(LeisureLevel.allCases) { level in
|
|
VStack(alignment: .leading) {
|
|
Text(level.displayName)
|
|
}
|
|
.tag(level)
|
|
}
|
|
}
|
|
|
|
Text(viewModel.leisureLevel.description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var optionalSection: some View {
|
|
Section("Optional") {
|
|
// Must-Stop Locations
|
|
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
|
|
ForEach(viewModel.mustStopLocations, id: \.name) { location in
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text(location.name)
|
|
if let address = location.address, !address.isEmpty {
|
|
Text(address)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button(role: .destructive) {
|
|
viewModel.removeMustStopLocation(location)
|
|
} label: {
|
|
Image(systemName: "minus.circle.fill")
|
|
}
|
|
}
|
|
}
|
|
Button("Add Location") {
|
|
cityInputType = .mustStop
|
|
showCityInput = true
|
|
}
|
|
}
|
|
|
|
// EV Charging
|
|
if viewModel.travelMode == .drive {
|
|
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
|
|
}
|
|
|
|
// Lodging
|
|
Picker("Lodging Type", selection: $viewModel.lodgingType) {
|
|
ForEach(LodgingType.allCases) { type in
|
|
Label(type.displayName, systemImage: type.iconName)
|
|
.tag(type)
|
|
}
|
|
}
|
|
|
|
// Drivers
|
|
if viewModel.travelMode == .drive {
|
|
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
|
|
|
|
HStack {
|
|
Text("Max Hours/Driver/Day")
|
|
Spacer()
|
|
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
|
|
}
|
|
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
|
|
}
|
|
|
|
// Other Sports
|
|
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
|
|
}
|
|
}
|
|
|
|
private var planningOverlay: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.4)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 20) {
|
|
ProgressView()
|
|
.scaleEffect(1.5)
|
|
|
|
Text("Planning your trip...")
|
|
.font(.headline)
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Finding the best route and games")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
}
|
|
.padding(40)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func binding(for sport: Sport) -> Binding<Bool> {
|
|
Binding(
|
|
get: { viewModel.selectedSports.contains(sport) },
|
|
set: { isSelected in
|
|
if isSelected {
|
|
viewModel.selectedSports.insert(sport)
|
|
} else {
|
|
viewModel.selectedSports.remove(sport)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func buildGamesDictionary() -> [UUID: RichGame] {
|
|
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
|
|
}
|
|
}
|
|
|
|
// MARK: - View State Extensions
|
|
|
|
extension TripCreationViewModel.ViewState {
|
|
var isError: Bool {
|
|
if case .error = self { return true }
|
|
return false
|
|
}
|
|
|
|
var isCompleted: Bool {
|
|
if case .completed = self { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Game Picker Sheet (Team-based selection)
|
|
|
|
struct GamePickerSheet: View {
|
|
let games: [RichGame]
|
|
@Binding var selectedIds: Set<UUID>
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Group games by team (both home and away)
|
|
private var teamsList: [TeamWithGames] {
|
|
var teamsDict: [UUID: TeamWithGames] = [:]
|
|
|
|
for game in games {
|
|
// Add to home team
|
|
if var teamData = teamsDict[game.homeTeam.id] {
|
|
teamData.games.append(game)
|
|
teamsDict[game.homeTeam.id] = teamData
|
|
} else {
|
|
teamsDict[game.homeTeam.id] = TeamWithGames(
|
|
team: game.homeTeam,
|
|
sport: game.game.sport,
|
|
games: [game]
|
|
)
|
|
}
|
|
|
|
// Add to away team
|
|
if var teamData = teamsDict[game.awayTeam.id] {
|
|
teamData.games.append(game)
|
|
teamsDict[game.awayTeam.id] = teamData
|
|
} else {
|
|
teamsDict[game.awayTeam.id] = TeamWithGames(
|
|
team: game.awayTeam,
|
|
sport: game.game.sport,
|
|
games: [game]
|
|
)
|
|
}
|
|
}
|
|
|
|
return teamsDict.values
|
|
.sorted { $0.team.name < $1.team.name }
|
|
}
|
|
|
|
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
|
|
let grouped = Dictionary(grouping: teamsList) { $0.sport }
|
|
return Sport.supported
|
|
.filter { grouped[$0] != nil }
|
|
.map { sport in
|
|
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
|
|
}
|
|
}
|
|
|
|
private var selectedGamesCount: Int {
|
|
selectedIds.count
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
// Selected games summary
|
|
if !selectedIds.isEmpty {
|
|
Section {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("\(selectedGamesCount) game(s) selected")
|
|
.fontWeight(.medium)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Teams by sport
|
|
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
|
|
Section(sportGroup.sport.rawValue) {
|
|
ForEach(sportGroup.teams) { teamData in
|
|
NavigationLink {
|
|
TeamGamesView(
|
|
teamData: teamData,
|
|
selectedIds: $selectedIds
|
|
)
|
|
} label: {
|
|
TeamRow(teamData: teamData, selectedIds: selectedIds)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Select Teams")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
if !selectedIds.isEmpty {
|
|
Button("Reset") {
|
|
selectedIds.removeAll()
|
|
}
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Team With Games Model
|
|
|
|
struct TeamWithGames: Identifiable {
|
|
let team: Team
|
|
let sport: Sport
|
|
var games: [RichGame]
|
|
|
|
var id: UUID { team.id }
|
|
|
|
var sortedGames: [RichGame] {
|
|
games.sorted { $0.game.dateTime < $1.game.dateTime }
|
|
}
|
|
}
|
|
|
|
// MARK: - Team Row
|
|
|
|
struct TeamRow: View {
|
|
let teamData: TeamWithGames
|
|
let selectedIds: Set<UUID>
|
|
|
|
private var selectedCount: Int {
|
|
teamData.games.filter { selectedIds.contains($0.id) }.count
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Team color indicator
|
|
if let colorHex = teamData.team.primaryColor {
|
|
Circle()
|
|
.fill(Color(hex: colorHex) ?? .gray)
|
|
.frame(width: 12, height: 12)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("\(teamData.team.city) \(teamData.team.name)")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
|
|
Text("\(teamData.games.count) game(s) available")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if selectedCount > 0 {
|
|
Text("\(selectedCount)")
|
|
.font(.caption)
|
|
.fontWeight(.bold)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.blue)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Team Games View
|
|
|
|
struct TeamGamesView: View {
|
|
let teamData: TeamWithGames
|
|
@Binding var selectedIds: Set<UUID>
|
|
|
|
var body: some View {
|
|
List {
|
|
ForEach(teamData.sortedGames) { game in
|
|
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
|
|
if selectedIds.contains(game.id) {
|
|
selectedIds.remove(game.id)
|
|
} else {
|
|
selectedIds.insert(game.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
struct GameRow: View {
|
|
let game: RichGame
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(game.matchupDescription)
|
|
.font(.headline)
|
|
|
|
Text(game.venueDescription)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(isSelected ? .blue : .gray)
|
|
.font(.title2)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
struct GameSelectRow: View {
|
|
let game: RichGame
|
|
let isSelected: Bool
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: 12) {
|
|
// Sport icon
|
|
Image(systemName: game.game.sport.iconName)
|
|
.font(.title3)
|
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
|
|
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(game.stadium.city)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
|
|
.font(.title3)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Location Search Sheet
|
|
|
|
struct LocationSearchSheet: View {
|
|
let inputType: TripCreationView.CityInputType
|
|
let onAdd: (LocationInput) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var searchText = ""
|
|
@State private var searchResults: [LocationSearchResult] = []
|
|
@State private var isSearching = false
|
|
@State private var searchTask: Task<Void, Never>?
|
|
|
|
private let locationService = LocationService.shared
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Search field
|
|
HStack {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
TextField("Search cities, addresses, places...", text: $searchText)
|
|
.textFieldStyle(.plain)
|
|
.autocorrectionDisabled()
|
|
if isSearching {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else if !searchText.isEmpty {
|
|
Button {
|
|
searchText = ""
|
|
searchResults = []
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.padding()
|
|
|
|
// Results list
|
|
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
|
|
ContentUnavailableView(
|
|
"No Results",
|
|
systemImage: "mappin.slash",
|
|
description: Text("Try a different search term")
|
|
)
|
|
} else {
|
|
List(searchResults) { result in
|
|
Button {
|
|
onAdd(result.toLocationInput())
|
|
dismiss()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "mappin.circle.fill")
|
|
.foregroundStyle(.red)
|
|
.font(.title2)
|
|
VStack(alignment: .leading) {
|
|
Text(result.name)
|
|
.foregroundStyle(.primary)
|
|
if !result.address.isEmpty {
|
|
Text(result.address)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: "plus.circle")
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
.onChange(of: searchText) { _, newValue in
|
|
// Debounce search
|
|
searchTask?.cancel()
|
|
searchTask = Task {
|
|
try? await Task.sleep(for: .milliseconds(300))
|
|
guard !Task.isCancelled else { return }
|
|
await performSearch(query: newValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performSearch(query: String) async {
|
|
guard !query.isEmpty else {
|
|
searchResults = []
|
|
return
|
|
}
|
|
|
|
isSearching = true
|
|
do {
|
|
searchResults = try await locationService.searchLocations(query)
|
|
} catch {
|
|
searchResults = []
|
|
}
|
|
isSearching = false
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TripCreationView()
|
|
}
|