// // TripCreationViewModel.swift // SportsTime // import Foundation import SwiftUI import Observation import CoreLocation @Observable final class TripCreationViewModel { // MARK: - State enum ViewState: Equatable { case editing case planning case selectingOption([ItineraryOption]) // Multiple options to choose from case completed(Trip) case error(String) static func == (lhs: ViewState, rhs: ViewState) -> Bool { switch (lhs, rhs) { case (.editing, .editing): return true case (.planning, .planning): return true case (.selectingOption(let o1), .selectingOption(let o2)): return o1.count == o2.count case (.completed(let t1), .completed(let t2)): return t1.id == t2.id case (.error(let e1), .error(let e2)): return e1 == e2 default: return false } } } var viewState: ViewState = .editing // MARK: - Planning Mode var planningMode: PlanningMode = .dateRange // MARK: - Form Fields // Locations (used in .locations mode) var startLocationText: String = "" var endLocationText: String = "" var startLocation: LocationInput? var endLocation: LocationInput? // Sports var selectedSports: Set = [.mlb] { didSet { // Clear cached games when sports selection changes if selectedSports != oldValue { availableGames = [] games = [] } } } // Dates var startDate: Date = Date() var endDate: Date = Date().addingTimeInterval(86400 * 7) // Trip duration for game-first mode (days before/after selected games) var tripBufferDays: Int = 2 // Games var mustSeeGameIds: Set = [] var availableGames: [RichGame] = [] var isLoadingGames: Bool = false // Travel var travelMode: TravelMode = .drive var routePreference: RoutePreference = .balanced // Constraints var useStopCount: Bool = true var numberOfStops: Int = 5 var leisureLevel: LeisureLevel = .moderate // Optional var mustStopLocations: [LocationInput] = [] var preferredCities: [String] = [] var needsEVCharging: Bool = false var lodgingType: LodgingType = .hotel var numberOfDrivers: Int = 1 var maxDrivingHoursPerDriver: Double = 8 // MARK: - Dependencies private let planningEngine = TripPlanningEngine() private let locationService = LocationService.shared private let dataProvider = AppDataProvider.shared // MARK: - Cached Data private var teams: [UUID: Team] = [:] private var stadiums: [UUID: Stadium] = [:] private var games: [Game] = [] private(set) var currentPreferences: TripPreferences? // MARK: - Computed Properties var isFormValid: Bool { switch planningMode { case .dateRange: // Need: sports + valid date range return !selectedSports.isEmpty && endDate > startDate case .gameFirst: // Need: at least one selected game + sports return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty case .locations: // Need: start + end locations + sports return !startLocationText.isEmpty && !endLocationText.isEmpty && !selectedSports.isEmpty } } var formValidationMessage: String? { switch planningMode { case .dateRange: if selectedSports.isEmpty { return "Select at least one sport" } if endDate <= startDate { return "End date must be after start date" } case .gameFirst: if mustSeeGameIds.isEmpty { return "Select at least one game" } if selectedSports.isEmpty { return "Select at least one sport" } case .locations: if startLocationText.isEmpty { return "Enter a starting location" } if endLocationText.isEmpty { return "Enter an ending location" } if selectedSports.isEmpty { return "Select at least one sport" } } return nil } var tripDurationDays: Int { let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0 return max(1, days) } var selectedGamesCount: Int { mustSeeGameIds.count } var selectedGames: [RichGame] { availableGames.filter { mustSeeGameIds.contains($0.game.id) } } /// Computed date range for game-first mode based on selected games var gameFirstDateRange: (start: Date, end: Date)? { guard !selectedGames.isEmpty else { return nil } let gameDates = selectedGames.map { $0.game.dateTime } guard let earliest = gameDates.min(), let latest = gameDates.max() else { return nil } let calendar = Calendar.current let bufferedStart = calendar.date(byAdding: .day, value: -tripBufferDays, to: earliest) ?? earliest let bufferedEnd = calendar.date(byAdding: .day, value: tripBufferDays, to: latest) ?? latest return (bufferedStart, bufferedEnd) } // MARK: - Actions func loadScheduleData() async { do { // Ensure initial data is loaded if dataProvider.teams.isEmpty { await dataProvider.loadInitialData() } // Use cached teams and stadiums from data provider for team in dataProvider.teams { teams[team.id] = team } for stadium in dataProvider.stadiums { stadiums[stadium.id] = stadium } // Fetch games games = try await dataProvider.fetchGames( sports: selectedSports, startDate: startDate, endDate: endDate ) // Build rich games for display availableGames = games.compactMap { game -> RichGame? in guard let homeTeam = teams[game.homeTeamId], let awayTeam = teams[game.awayTeamId], let stadium = stadiums[game.stadiumId] else { return nil } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } } catch { viewState = .error("Failed to load schedule data: \(error.localizedDescription)") } } func resolveLocations() async { do { if !startLocationText.isEmpty { startLocation = try await locationService.resolveLocation( LocationInput(name: startLocationText, address: startLocationText) ) } if !endLocationText.isEmpty { endLocation = try await locationService.resolveLocation( LocationInput(name: endLocationText, address: endLocationText) ) } } catch { viewState = .error("Failed to resolve locations: \(error.localizedDescription)") } } func planTrip() async { guard isFormValid else { return } viewState = .planning // Mode-specific setup var effectiveStartDate = startDate var effectiveEndDate = endDate var resolvedStartLocation: LocationInput? var resolvedEndLocation: LocationInput? switch planningMode { case .dateRange: // Use provided date range, no location needed // Games will be found within the date range across all regions effectiveStartDate = startDate effectiveEndDate = endDate case .gameFirst: // Calculate date range from selected games + buffer if let dateRange = gameFirstDateRange { effectiveStartDate = dateRange.start effectiveEndDate = dateRange.end } // Derive start/end locations from first/last game stadiums if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first, let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last { resolvedStartLocation = LocationInput( name: firstGame.stadium.city, coordinate: firstGame.stadium.coordinate, address: "\(firstGame.stadium.city), \(firstGame.stadium.state)" ) resolvedEndLocation = LocationInput( name: lastGame.stadium.city, coordinate: lastGame.stadium.coordinate, address: "\(lastGame.stadium.city), \(lastGame.stadium.state)" ) } case .locations: // Resolve provided locations await resolveLocations() resolvedStartLocation = startLocation resolvedEndLocation = endLocation guard resolvedStartLocation != nil, resolvedEndLocation != nil else { viewState = .error("Could not resolve start or end location") return } } // Ensure we have games data if games.isEmpty { await loadScheduleData() } // Read max trip options from settings (default 10) let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions") let maxTripOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10 // Build preferences let preferences = TripPreferences( planningMode: planningMode, startLocation: resolvedStartLocation, endLocation: resolvedEndLocation, sports: selectedSports, mustSeeGameIds: mustSeeGameIds, travelMode: travelMode, startDate: effectiveStartDate, endDate: effectiveEndDate, numberOfStops: useStopCount ? numberOfStops : nil, tripDuration: useStopCount ? nil : tripDurationDays, leisureLevel: leisureLevel, mustStopLocations: mustStopLocations, preferredCities: preferredCities, routePreference: routePreference, needsEVCharging: needsEVCharging, lodgingType: lodgingType, numberOfDrivers: numberOfDrivers, maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, maxTripOptions: maxTripOptions ) // Build planning request let request = PlanningRequest( preferences: preferences, availableGames: games, teams: teams, stadiums: stadiums ) // Plan the trip let result = planningEngine.planItineraries(request: request) switch result { case .success(var options): guard !options.isEmpty else { viewState = .error("No valid itinerary found") return } // Enrich with EV chargers if requested and feature is enabled if FeatureFlags.enableEVCharging && needsEVCharging { options = await ItineraryBuilder.enrichWithEVChargers(options) } // Store preferences for later conversion currentPreferences = preferences if options.count == 1 { // Only one option - go directly to detail let trip = convertToTrip(option: options[0], preferences: preferences) viewState = .completed(trip) } else { // Multiple options - show selection view viewState = .selectingOption(options) } case .failure(let failure): viewState = .error(failureMessage(for: failure)) } } func toggleMustSeeGame(_ gameId: UUID) { if mustSeeGameIds.contains(gameId) { mustSeeGameIds.remove(gameId) } else { mustSeeGameIds.insert(gameId) } } func deselectAllGames() { mustSeeGameIds.removeAll() } func switchPlanningMode(_ mode: PlanningMode) { planningMode = mode // Clear mode-specific selections when switching switch mode { case .dateRange: startLocationText = "" endLocationText = "" startLocation = nil endLocation = nil case .gameFirst: // Keep games, clear locations startLocationText = "" endLocationText = "" startLocation = nil endLocation = nil case .locations: // Keep locations, optionally keep selected games break } } /// Load games for browsing in game-first mode func loadGamesForBrowsing() async { isLoadingGames = true do { // Ensure initial data is loaded if dataProvider.teams.isEmpty { await dataProvider.loadInitialData() } // Use cached teams and stadiums from data provider for team in dataProvider.teams { teams[team.id] = team } for stadium in dataProvider.stadiums { stadiums[stadium.id] = stadium } // Fetch games for next 90 days for browsing let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate games = try await dataProvider.fetchGames( sports: selectedSports, startDate: Date(), endDate: browseEndDate ) // Build rich games for display availableGames = games.compactMap { game -> RichGame? in guard let homeTeam = teams[game.homeTeamId], let awayTeam = teams[game.awayTeamId], let stadium = stadiums[game.stadiumId] else { return nil } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) }.sorted { $0.game.dateTime < $1.game.dateTime } } catch { viewState = .error("Failed to load games: \(error.localizedDescription)") } isLoadingGames = false } func addMustStopLocation(_ location: LocationInput) { guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return } mustStopLocations.append(location) } func removeMustStopLocation(_ location: LocationInput) { mustStopLocations.removeAll { $0.name == location.name } } func addPreferredCity(_ city: String) { guard !city.isEmpty, !preferredCities.contains(city) else { return } preferredCities.append(city) } func removePreferredCity(_ city: String) { preferredCities.removeAll { $0 == city } } func reset() { viewState = .editing planningMode = .dateRange startLocationText = "" endLocationText = "" startLocation = nil endLocation = nil selectedSports = [.mlb] startDate = Date() endDate = Date().addingTimeInterval(86400 * 7) tripBufferDays = 2 mustSeeGameIds = [] numberOfStops = 5 leisureLevel = .moderate mustStopLocations = [] preferredCities = [] availableGames = [] isLoadingGames = false currentPreferences = nil } /// Select a specific itinerary option and navigate to its detail func selectOption(_ option: ItineraryOption) { guard let preferences = currentPreferences else { viewState = .error("Unable to load trip preferences") return } let trip = convertToTrip(option: option, preferences: preferences) viewState = .completed(trip) } /// Convert an itinerary option to a Trip (public for use by TripOptionsView) func convertOptionToTrip(_ option: ItineraryOption) -> Trip { let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions") let maxOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10 let preferences = currentPreferences ?? TripPreferences( planningMode: planningMode, startLocation: nil, endLocation: nil, sports: selectedSports, mustSeeGameIds: mustSeeGameIds, travelMode: travelMode, startDate: startDate, endDate: endDate, numberOfStops: useStopCount ? numberOfStops : nil, tripDuration: useStopCount ? nil : tripDurationDays, leisureLevel: leisureLevel, mustStopLocations: mustStopLocations, preferredCities: preferredCities, routePreference: routePreference, needsEVCharging: needsEVCharging, lodgingType: lodgingType, numberOfDrivers: numberOfDrivers, maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, maxTripOptions: maxOptions ) return convertToTrip(option: option, preferences: preferences) } /// Go back to option selection from trip detail func backToOptions() { if case .completed = viewState { // We'd need to store options to go back - for now, restart planning viewState = .editing } } // MARK: - Conversion Helpers private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip { // 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: " → ") } private func failureMessage(for failure: PlanningFailure) -> String { failure.message } }