354 lines
10 KiB
Swift
354 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 = Calendar.current.startOfDay(for: Date())
|
|
var endDate: Date = {
|
|
let calendar = Calendar.current
|
|
let start = calendar.startOfDay(for: Date())
|
|
let endDay = calendar.date(byAdding: .day, value: 7, to: start) ?? start
|
|
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
|
}()
|
|
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] = [:]
|
|
let calendar = Calendar.current
|
|
let queryStart = calendar.startOfDay(for: startDate)
|
|
let endDay = calendar.startOfDay(for: endDate)
|
|
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
|
|
|
for sport in Sport.supported {
|
|
do {
|
|
let games = try await AppDataProvider.shared.filterGames(
|
|
sports: [sport],
|
|
startDate: queryStart,
|
|
endDate: queryEnd
|
|
)
|
|
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 }
|
|
}
|
|
}
|