LocationSearchSheet now shows only stadium cities (with sport badges) when selecting start/end locations, preventing users from picking cities with no stadiums. TripWizardViewModel filters available sports to the union of sports at the selected cities, and clears invalid selections when locations change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
19 KiB
Swift
430 lines
19 KiB
Swift
//
|
|
// TripWizardView.swift
|
|
// SportsTime
|
|
//
|
|
// Wizard for trip creation.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct TripWizardView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var viewModel = TripWizardViewModel()
|
|
@State private var showTripOptions = false
|
|
@State private var tripOptions: [ItineraryOption] = []
|
|
@State private var gamesForDisplay: [String: RichGame] = [:]
|
|
@State private var planningError: String?
|
|
@State private var showError = false
|
|
|
|
private let planningEngine = TripPlanningEngine()
|
|
|
|
/// Selected team name for display in ReviewStep
|
|
private var selectedTeamName: String? {
|
|
guard let teamId = viewModel.selectedTeamId else { return nil }
|
|
return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
GeometryReader { geometry in
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.vertical) {
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Step 1: Planning Mode (always visible)
|
|
PlanningModeStep(selection: $viewModel.planningMode)
|
|
|
|
// All other steps appear together after planning mode selected
|
|
if viewModel.areStepsVisible {
|
|
Group {
|
|
// Mode-specific steps
|
|
if viewModel.showGamePickerStep {
|
|
GamePickerStep(
|
|
selectedSports: $viewModel.gamePickerSports,
|
|
selectedTeamIds: $viewModel.gamePickerTeamIds,
|
|
selectedGameIds: $viewModel.selectedGameIds,
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate
|
|
)
|
|
}
|
|
|
|
if viewModel.showTeamPickerStep {
|
|
TeamPickerStep(
|
|
selectedSport: $viewModel.teamPickerSport,
|
|
selectedTeamId: $viewModel.selectedTeamId
|
|
)
|
|
}
|
|
|
|
if viewModel.showLocationsStep {
|
|
LocationsStep(
|
|
startLocation: $viewModel.startLocation,
|
|
endLocation: $viewModel.endLocation
|
|
)
|
|
.onChange(of: viewModel.startLocation) { _, _ in
|
|
viewModel.validateSportsForLocations()
|
|
}
|
|
.onChange(of: viewModel.endLocation) { _, _ in
|
|
viewModel.validateSportsForLocations()
|
|
}
|
|
}
|
|
|
|
if viewModel.showTeamFirstStep {
|
|
TeamFirstWizardStep(
|
|
selectedSport: $viewModel.teamFirstSport,
|
|
selectedTeamIds: $viewModel.teamFirstSelectedTeamIds
|
|
)
|
|
}
|
|
|
|
// Common steps (conditionally shown)
|
|
if viewModel.showDatesStep {
|
|
DatesStep(
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate,
|
|
hasSetDates: $viewModel.hasSetDates,
|
|
onDatesChanged: {
|
|
Task {
|
|
await viewModel.fetchSportAvailability()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
if viewModel.showSportsStep {
|
|
SportsStep(
|
|
selectedSports: $viewModel.selectedSports,
|
|
sportAvailability: viewModel.sportAvailability,
|
|
isLoading: viewModel.isLoadingSportAvailability,
|
|
canSelectSport: viewModel.canSelectSport
|
|
)
|
|
}
|
|
|
|
if viewModel.showRegionsStep {
|
|
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
|
}
|
|
|
|
// Always shown steps
|
|
RoutePreferenceStep(
|
|
routePreference: $viewModel.routePreference,
|
|
hasSetRoutePreference: $viewModel.hasSetRoutePreference
|
|
)
|
|
|
|
RepeatCitiesStep(
|
|
allowRepeatCities: $viewModel.allowRepeatCities,
|
|
hasSetRepeatCities: $viewModel.hasSetRepeatCities
|
|
)
|
|
|
|
MustStopsStep(mustStopLocations: $viewModel.mustStopLocations)
|
|
|
|
ReviewStep(
|
|
planningMode: viewModel.planningMode ?? .dateRange,
|
|
selectedSports: viewModel.selectedSports,
|
|
startDate: viewModel.startDate,
|
|
endDate: viewModel.endDate,
|
|
selectedRegions: viewModel.selectedRegions,
|
|
routePreference: viewModel.routePreference,
|
|
allowRepeatCities: viewModel.allowRepeatCities,
|
|
mustStopLocations: viewModel.mustStopLocations,
|
|
isPlanning: viewModel.isPlanning,
|
|
canPlanTrip: viewModel.canPlanTrip,
|
|
fieldValidation: viewModel.fieldValidation,
|
|
onPlan: { Task { await planTrip() } },
|
|
selectedGameCount: viewModel.selectedGameIds.count,
|
|
selectedTeamName: selectedTeamName,
|
|
startLocationName: viewModel.startLocation?.name,
|
|
endLocationName: viewModel.endLocation?.name,
|
|
teamFirstTeamCount: viewModel.teamFirstSelectedTeamIds.count
|
|
)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
|
|
Color.clear
|
|
.frame(height: 1)
|
|
.id("wizardBottom")
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.frame(width: geometry.size.width)
|
|
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
|
}
|
|
#if DEBUG
|
|
.onChange(of: viewModel.planningMode) { _, newMode in
|
|
if newMode == .gameFirst && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
|
|
marketingAutoFill(proxy: proxy)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
.themedBackground()
|
|
.navigationTitle("Plan a Trip")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $showTripOptions) {
|
|
TripOptionsView(
|
|
options: tripOptions,
|
|
games: gamesForDisplay,
|
|
preferences: buildPreferences(),
|
|
convertToTrip: { option in
|
|
convertOptionToTrip(option)
|
|
}
|
|
)
|
|
}
|
|
.alert("Planning Error", isPresented: $showError) {
|
|
Button("OK") { showError = false }
|
|
} message: {
|
|
Text(planningError ?? "An unknown error occurred")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Planning
|
|
|
|
private func planTrip() async {
|
|
let mode = viewModel.planningMode?.rawValue ?? "unknown"
|
|
AnalyticsManager.shared.track(.tripWizardStarted(mode: mode))
|
|
viewModel.isPlanning = true
|
|
defer { viewModel.isPlanning = false }
|
|
|
|
do {
|
|
var preferences = buildPreferences()
|
|
|
|
// Build dictionaries from arrays
|
|
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
|
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
|
|
|
// For gameFirst mode, use the UI-selected date range (set by GamePickerStep)
|
|
// The date range is a 7-day span centered on the selected game(s)
|
|
var games: [Game]
|
|
if viewModel.planningMode == .teamFirst {
|
|
// Team-First mode: fetch ALL games for the season
|
|
// ScenarioEPlanner will generate sliding windows across the full season
|
|
games = try await AppDataProvider.shared.allGames(for: preferences.sports)
|
|
print("🔍 TripWizard: Team-First mode - fetched \(games.count) games for \(preferences.sports)")
|
|
} else if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
|
|
// Fetch all games for the selected sports within the UI date range
|
|
// GamePickerStep already set viewModel.startDate/endDate to a 7-day span
|
|
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
|
|
|
|
// Validate that selected must-see games exist
|
|
let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) }
|
|
|
|
if mustSeeGames.isEmpty {
|
|
planningError = "Could not find the selected games. Please try again."
|
|
showError = true
|
|
return
|
|
}
|
|
|
|
// Use the UI-selected date range (already set by GamePickerStep to 7-day span)
|
|
// Filter all games within this range - ScenarioBPlanner will use anchor games
|
|
// as required stops and add bonus games that fit geographically
|
|
let rangeStart = Calendar.current.startOfDay(for: preferences.startDate)
|
|
let rangeEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate) ?? preferences.endDate
|
|
|
|
games = allGames.filter {
|
|
$0.dateTime >= rangeStart && $0.dateTime <= rangeEnd
|
|
}
|
|
} else {
|
|
// Standard mode: fetch games for date range
|
|
games = try await AppDataProvider.shared.filterGames(
|
|
sports: preferences.sports,
|
|
startDate: preferences.startDate,
|
|
endDate: preferences.endDate
|
|
)
|
|
}
|
|
|
|
// Build RichGame dictionary for display
|
|
var richGamesDict: [String: RichGame] = [:]
|
|
for game in games {
|
|
if let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] {
|
|
let richGame = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
richGamesDict[game.id] = richGame
|
|
}
|
|
}
|
|
|
|
// Build planning request
|
|
let request = PlanningRequest(
|
|
preferences: preferences,
|
|
availableGames: games,
|
|
teams: teamsById,
|
|
stadiums: stadiumsById
|
|
)
|
|
|
|
// Run planning engine
|
|
let result = planningEngine.planItineraries(request: request)
|
|
|
|
switch result {
|
|
case .success(let options):
|
|
if options.isEmpty {
|
|
planningError = "No valid trip options found for your criteria. Try expanding your date range or regions."
|
|
showError = true
|
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: "no_options_found"))
|
|
} else {
|
|
tripOptions = options
|
|
gamesForDisplay = richGamesDict
|
|
showTripOptions = true
|
|
if let first = options.first {
|
|
AnalyticsManager.shared.track(.tripPlanned(
|
|
sportCount: viewModel.selectedSports.count,
|
|
stopCount: first.stops.count,
|
|
dayCount: first.stops.count,
|
|
mode: mode
|
|
))
|
|
}
|
|
}
|
|
case .failure(let failure):
|
|
planningError = failure.message
|
|
showError = true
|
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: failure.message))
|
|
}
|
|
} catch {
|
|
planningError = error.localizedDescription
|
|
showError = true
|
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
private func buildPreferences() -> TripPreferences {
|
|
// Determine which sports to use based on mode
|
|
let sports: Set<Sport>
|
|
if viewModel.planningMode == .gameFirst {
|
|
sports = viewModel.gamePickerSports
|
|
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
|
|
sports = [sport]
|
|
} else if viewModel.planningMode == .teamFirst, let sport = viewModel.teamFirstSport {
|
|
sports = [sport]
|
|
} else {
|
|
sports = viewModel.selectedSports
|
|
}
|
|
|
|
return TripPreferences(
|
|
planningMode: viewModel.planningMode ?? .dateRange,
|
|
startLocation: viewModel.startLocation,
|
|
endLocation: viewModel.endLocation,
|
|
sports: sports,
|
|
mustSeeGameIds: viewModel.selectedGameIds,
|
|
startDate: viewModel.startDate,
|
|
endDate: viewModel.endDate,
|
|
mustStopLocations: viewModel.mustStopLocations,
|
|
routePreference: viewModel.routePreference,
|
|
allowRepeatCities: viewModel.allowRepeatCities,
|
|
selectedRegions: viewModel.selectedRegions,
|
|
followTeamId: viewModel.selectedTeamId,
|
|
selectedTeamIds: viewModel.teamFirstSelectedTeamIds
|
|
)
|
|
}
|
|
|
|
private func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
|
let preferences = buildPreferences()
|
|
|
|
// Convert ItineraryStops to TripStops
|
|
let tripStops = option.stops.enumerated().map { index, stop in
|
|
TripStop(
|
|
stopNumber: index + 1,
|
|
city: stop.city,
|
|
state: stop.state,
|
|
coordinate: stop.coordinate,
|
|
arrivalDate: stop.arrivalDate,
|
|
departureDate: stop.departureDate,
|
|
games: stop.games,
|
|
isRestDay: stop.games.isEmpty
|
|
)
|
|
}
|
|
|
|
return Trip(
|
|
name: generateTripName(from: tripStops),
|
|
preferences: preferences,
|
|
stops: tripStops,
|
|
travelSegments: option.travelSegments,
|
|
totalGames: option.totalGames,
|
|
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
|
|
totalDrivingSeconds: option.totalDrivingHours * 3600
|
|
)
|
|
}
|
|
|
|
private func generateTripName(from stops: [TripStop]) -> String {
|
|
let cities = stops.compactMap { $0.city }.prefix(3)
|
|
if cities.count <= 1 {
|
|
return cities.first ?? "Road Trip"
|
|
}
|
|
return cities.joined(separator: " → ")
|
|
}
|
|
|
|
// MARK: - Marketing Video Auto-Fill
|
|
|
|
#if DEBUG
|
|
private func marketingAutoFill(proxy: ScrollViewProxy) {
|
|
// Pre-fetch data off the main thread, then run a clean sequential fill
|
|
Task {
|
|
let astros = AppDataProvider.shared.teams.first { $0.fullName.contains("Astros") }
|
|
let allGames = try? await AppDataProvider.shared.allGames(for: [.mlb])
|
|
let astrosGames = (allGames ?? []).filter {
|
|
$0.homeTeamId == astros?.id || $0.awayTeamId == astros?.id
|
|
}
|
|
let pickedGames = Array(astrosGames.shuffled().prefix(3))
|
|
let pickedIds = Set(pickedGames.map { $0.id })
|
|
|
|
// Sequential fill with generous pauses — no competing animations
|
|
await MainActor.run {
|
|
// Step 1: Select MLB
|
|
viewModel.gamePickerSports = [.mlb]
|
|
}
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
await MainActor.run {
|
|
// Step 2: Select Astros
|
|
if let astros {
|
|
viewModel.gamePickerTeamIds = [astros.id]
|
|
}
|
|
}
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
await MainActor.run {
|
|
// Step 3: Pick games + set date range
|
|
if !pickedIds.isEmpty {
|
|
viewModel.selectedGameIds = pickedIds
|
|
if let earliest = pickedGames.map({ $0.dateTime }).min(),
|
|
let latest = pickedGames.map({ $0.dateTime }).max() {
|
|
viewModel.startDate = Calendar.current.date(byAdding: .day, value: -1, to: earliest) ?? earliest
|
|
viewModel.endDate = Calendar.current.date(byAdding: .day, value: 1, to: latest) ?? latest
|
|
}
|
|
}
|
|
}
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
await MainActor.run {
|
|
// Step 4: Balanced route
|
|
viewModel.routePreference = .balanced
|
|
viewModel.hasSetRoutePreference = true
|
|
}
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
await MainActor.run {
|
|
// Step 5: Allow repeat cities
|
|
viewModel.allowRepeatCities = true
|
|
viewModel.hasSetRepeatCities = true
|
|
}
|
|
try? await Task.sleep(for: .seconds(0.5))
|
|
|
|
// Single smooth scroll to bottom after everything is laid out
|
|
await MainActor.run {
|
|
withAnimation(.easeInOut(duration: 5.0)) {
|
|
proxy.scrollTo("wizardBottom", anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
TripWizardView()
|
|
}
|