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>
345 lines
10 KiB
Swift
345 lines
10 KiB
Swift
//
|
|
// TripWizardViewModel.swift
|
|
// SportsTime
|
|
//
|
|
// ViewModel for trip wizard.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor @Observable
|
|
final class TripWizardViewModel {
|
|
|
|
// MARK: - Planning Mode
|
|
|
|
var planningMode: PlanningMode? = nil {
|
|
didSet {
|
|
if oldValue != nil && oldValue != planningMode {
|
|
resetAllSelections()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sports Selection
|
|
|
|
var selectedSports: Set<Sport> = []
|
|
|
|
// MARK: - Dates
|
|
|
|
var startDate: Date = Date()
|
|
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
|
var hasSetDates: Bool = false
|
|
|
|
// MARK: - Regions
|
|
|
|
var selectedRegions: Set<Region> = []
|
|
|
|
// MARK: - Route Preferences
|
|
|
|
var routePreference: RoutePreference = .balanced
|
|
var hasSetRoutePreference: Bool = false
|
|
|
|
// MARK: - Repeat Cities
|
|
|
|
var allowRepeatCities: Bool = false
|
|
var hasSetRepeatCities: Bool = false
|
|
|
|
// MARK: - Must Stops
|
|
|
|
var mustStopLocations: [LocationInput] = []
|
|
|
|
// MARK: - Mode-Specific: gameFirst (cascading selection)
|
|
|
|
var gamePickerSports: Set<Sport> = []
|
|
var gamePickerTeamIds: Set<String> = []
|
|
var selectedGameIds: Set<String> = []
|
|
|
|
// MARK: - Mode-Specific: followTeam
|
|
|
|
var selectedTeamId: String? = nil
|
|
var teamPickerSport: Sport? = nil
|
|
|
|
// MARK: - Mode-Specific: locations
|
|
|
|
var startLocation: LocationInput? = nil
|
|
var endLocation: LocationInput? = nil
|
|
|
|
// MARK: - Mode-Specific: teamFirst
|
|
|
|
var teamFirstSport: Sport? = nil
|
|
var teamFirstSelectedTeamIds: Set<String> = []
|
|
|
|
// MARK: - Planning State
|
|
|
|
var isPlanning: Bool = false
|
|
|
|
// MARK: - Sport Availability
|
|
|
|
var sportAvailability: [Sport: Bool] = [:]
|
|
var isLoadingSportAvailability: Bool = false
|
|
|
|
// MARK: - Visibility
|
|
|
|
/// All steps visible once planning mode is selected
|
|
var areStepsVisible: Bool {
|
|
planningMode != nil
|
|
}
|
|
|
|
/// Mode-specific step visibility
|
|
var showDatesStep: Bool {
|
|
planningMode == .dateRange || planningMode == .followTeam || planningMode == .locations
|
|
}
|
|
|
|
var showSportsStep: Bool {
|
|
planningMode == .dateRange || planningMode == .locations
|
|
}
|
|
|
|
var showRegionsStep: Bool {
|
|
planningMode == .dateRange
|
|
}
|
|
|
|
var showGamePickerStep: Bool {
|
|
planningMode == .gameFirst
|
|
}
|
|
|
|
var showTeamPickerStep: Bool {
|
|
planningMode == .followTeam
|
|
}
|
|
|
|
var showLocationsStep: Bool {
|
|
planningMode == .locations
|
|
}
|
|
|
|
var showTeamFirstStep: Bool {
|
|
planningMode == .teamFirst
|
|
}
|
|
|
|
// MARK: - Validation
|
|
|
|
/// All required fields must be set before planning
|
|
var canPlanTrip: Bool {
|
|
guard let mode = planningMode else { return false }
|
|
|
|
// Common requirements for all modes
|
|
guard hasSetRoutePreference && hasSetRepeatCities else { return false }
|
|
|
|
// Date validation: endDate must not be before startDate for modes that use dates
|
|
if hasSetDates && endDate < startDate { return false }
|
|
|
|
switch mode {
|
|
case .dateRange:
|
|
return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty
|
|
case .gameFirst:
|
|
return !selectedGameIds.isEmpty
|
|
case .locations:
|
|
return startLocation != nil && endLocation != nil && hasSetDates && !selectedSports.isEmpty
|
|
case .followTeam:
|
|
return selectedTeamId != nil && hasSetDates
|
|
case .teamFirst:
|
|
return teamFirstSport != nil && teamFirstSelectedTeamIds.count >= 2
|
|
}
|
|
}
|
|
|
|
/// Field validation for the review step - shows which fields are missing
|
|
var fieldValidation: FieldValidation {
|
|
FieldValidation(
|
|
planningMode: planningMode,
|
|
sports: selectedSports.isEmpty ? .missing : .valid,
|
|
dates: hasSetDates ? .valid : .missing,
|
|
regions: selectedRegions.isEmpty ? .missing : .valid,
|
|
routePreference: hasSetRoutePreference ? .valid : .missing,
|
|
repeatCities: hasSetRepeatCities ? .valid : .missing,
|
|
selectedGames: selectedGameIds.isEmpty ? .missing : .valid,
|
|
selectedTeam: selectedTeamId == nil ? .missing : .valid,
|
|
startLocation: startLocation == nil ? .missing : .valid,
|
|
endLocation: endLocation == nil ? .missing : .valid,
|
|
teamFirstTeams: teamFirstSelectedTeamIds.count >= 2 ? .valid : .missing,
|
|
teamFirstTeamCount: teamFirstSelectedTeamIds.count
|
|
)
|
|
}
|
|
|
|
// MARK: - Sport Availability
|
|
|
|
/// Sports available at the selected start and/or end cities (union of both)
|
|
var sportsAtSelectedCities: Set<Sport> {
|
|
let stadiums = AppDataProvider.shared.stadiums
|
|
var sports: Set<Sport> = []
|
|
|
|
if let startCity = startLocation?.name {
|
|
let normalized = startCity.split(separator: ",").first?
|
|
.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
|
|
for stadium in stadiums where stadium.city.lowercased() == normalized {
|
|
sports.insert(stadium.sport)
|
|
}
|
|
}
|
|
if let endCity = endLocation?.name {
|
|
let normalized = endCity.split(separator: ",").first?
|
|
.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
|
|
for stadium in stadiums where stadium.city.lowercased() == normalized {
|
|
sports.insert(stadium.sport)
|
|
}
|
|
}
|
|
return sports
|
|
}
|
|
|
|
func canSelectSport(_ sport: Sport) -> Bool {
|
|
// Existing date-range availability check
|
|
let dateAvailable = sportAvailability[sport] ?? true
|
|
|
|
// For locations mode, also check if sport has stadiums at selected cities
|
|
if planningMode == .locations,
|
|
startLocation != nil || endLocation != nil {
|
|
let cityAvailable = sportsAtSelectedCities.contains(sport)
|
|
return dateAvailable && cityAvailable
|
|
}
|
|
|
|
return dateAvailable
|
|
}
|
|
|
|
/// Removes selected sports that are no longer available at the chosen cities
|
|
func validateSportsForLocations() {
|
|
guard planningMode == .locations else { return }
|
|
let available = sportsAtSelectedCities
|
|
guard !available.isEmpty else { return }
|
|
selectedSports = selectedSports.filter { available.contains($0) }
|
|
}
|
|
|
|
func fetchSportAvailability() async {
|
|
guard hasSetDates else { return }
|
|
|
|
isLoadingSportAvailability = true
|
|
defer { isLoadingSportAvailability = false }
|
|
|
|
var availability: [Sport: Bool] = [:]
|
|
|
|
for sport in Sport.supported {
|
|
do {
|
|
let games = try await AppDataProvider.shared.filterGames(
|
|
sports: [sport],
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
availability[sport] = !games.isEmpty
|
|
} catch {
|
|
availability[sport] = true // Default to available on error
|
|
}
|
|
}
|
|
|
|
self.sportAvailability = availability
|
|
}
|
|
|
|
// MARK: - Reset Logic
|
|
|
|
private func resetAllSelections() {
|
|
// Common fields
|
|
selectedSports = []
|
|
hasSetDates = false
|
|
selectedRegions = []
|
|
hasSetRoutePreference = false
|
|
hasSetRepeatCities = false
|
|
mustStopLocations = []
|
|
|
|
// gameFirst mode fields
|
|
gamePickerSports = []
|
|
gamePickerTeamIds = []
|
|
selectedGameIds = []
|
|
|
|
// followTeam mode fields
|
|
selectedTeamId = nil
|
|
teamPickerSport = nil
|
|
|
|
// locations mode fields
|
|
startLocation = nil
|
|
endLocation = nil
|
|
|
|
// teamFirst mode fields
|
|
teamFirstSport = nil
|
|
teamFirstSelectedTeamIds = []
|
|
}
|
|
}
|
|
|
|
// MARK: - Field Validation
|
|
|
|
struct FieldValidation {
|
|
enum Status {
|
|
case valid
|
|
case missing
|
|
}
|
|
|
|
let planningMode: PlanningMode?
|
|
|
|
// Common fields
|
|
let sports: Status
|
|
let dates: Status
|
|
let regions: Status
|
|
let routePreference: Status
|
|
let repeatCities: Status
|
|
|
|
// Mode-specific fields
|
|
let selectedGames: Status
|
|
let selectedTeam: Status
|
|
let startLocation: Status
|
|
let endLocation: Status
|
|
let teamFirstTeams: Status
|
|
let teamFirstTeamCount: Int
|
|
|
|
/// Returns only the fields that are required for the current planning mode
|
|
var requiredFields: [(name: String, status: Status)] {
|
|
var fields: [(String, Status)] = []
|
|
|
|
guard let mode = planningMode else { return fields }
|
|
|
|
switch mode {
|
|
case .dateRange:
|
|
fields = [
|
|
("Dates", dates),
|
|
("Sports", sports),
|
|
("Regions", regions),
|
|
("Route Preference", routePreference),
|
|
("Repeat Cities", repeatCities)
|
|
]
|
|
case .gameFirst:
|
|
fields = [
|
|
("Games", selectedGames),
|
|
("Route Preference", routePreference),
|
|
("Repeat Cities", repeatCities)
|
|
]
|
|
case .locations:
|
|
fields = [
|
|
("Start Location", startLocation),
|
|
("End Location", endLocation),
|
|
("Dates", dates),
|
|
("Sports", sports),
|
|
("Route Preference", routePreference),
|
|
("Repeat Cities", repeatCities)
|
|
]
|
|
case .followTeam:
|
|
fields = [
|
|
("Team", selectedTeam),
|
|
("Dates", dates),
|
|
("Route Preference", routePreference),
|
|
("Repeat Cities", repeatCities)
|
|
]
|
|
case .teamFirst:
|
|
fields = [
|
|
("Teams", teamFirstTeams),
|
|
("Route Preference", routePreference),
|
|
("Repeat Cities", repeatCities)
|
|
]
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
/// Returns only the missing required fields for the current mode
|
|
var missingFields: [String] {
|
|
requiredFields.filter { $0.status == .missing }.map { $0.name }
|
|
}
|
|
|
|
/// Whether all required fields for the current mode are valid
|
|
var allRequiredFieldsValid: Bool {
|
|
requiredFields.allSatisfy { $0.status == .valid }
|
|
}
|
|
}
|