- Add a11y label to ProgressMapView reset button and progress bar values - Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit - Add [weak self] to PhotoGalleryViewModel Task closure - Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop - Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated() - Cache itinerarySections via ItinerarySectionBuilder static extraction + @State - Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class - Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
8.3 KiB
Swift
305 lines
8.3 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
|
|
|
|
func canSelectSport(_ sport: Sport) -> Bool {
|
|
sportAvailability[sport] ?? true // Default to available if not checked
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|