Files
Sportstime/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
Trey t a4e9327b18 fix: restrict By Route wizard to stadium cities and filter sports by selected cities
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>
2026-02-20 22:47:46 -06:00

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