Files
Sportstime/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
Trey t 3f80a16201 fix: preserve LocationSearchSheet coordinates in By Route mode
The resolveLocations() function was overwriting valid LocationInput
objects (with coordinates) from LocationSearchSheet. Now it only
resolves locations that don't already have coordinates.

Added debug logging to trace scenario selection.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:59:44 -06:00

645 lines
23 KiB
Swift

//
// 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 {
didSet {
// Reset state when mode changes to ensure clean UI transitions
if oldValue != planningMode {
viewState = .editing
// Clear mode-specific selections
switch planningMode {
case .dateRange, .gameFirst:
// Clear locations for date-based modes
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .locations:
// Keep locations (user needs to enter them)
break
case .followTeam:
// Clear locations and must-see games for follow team mode
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
mustSeeGameIds.removeAll()
}
}
}
}
// MARK: - Form Fields
// Locations (used in .locations mode)
var startLocationText: String = ""
var endLocationText: String = ""
var startLocation: LocationInput?
var endLocation: LocationInput?
// Sports
var selectedSports: Set<Sport> = [.mlb] {
didSet {
// Clear cached games when sports selection changes
if selectedSports != oldValue {
availableGames = []
games = []
}
}
}
// Dates
var startDate: Date = Date() {
didSet {
// Clear cached games when start date changes
// BUT: In gameFirst mode, games are the source of truth for dates,
// so don't clear them (fixes "date range required" error)
if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
availableGames = []
games = []
}
}
}
var endDate: Date = Date().addingTimeInterval(86400 * 7) {
didSet {
// Clear cached games when end date changes
// BUT: In gameFirst mode, games are the source of truth for dates,
// so don't clear them (fixes "date range required" error)
if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) && planningMode != .gameFirst {
availableGames = []
games = []
}
}
}
// Games
var mustSeeGameIds: Set<UUID> = []
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
// Travel Preferences
var allowRepeatCities: Bool = true
var selectedRegions: Set<Region> = [.east, .central, .west]
// Follow Team Mode
var followTeamId: UUID?
var useHomeLocation: Bool = true
// 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
case .followTeam:
// Need: team selected + valid date range
guard followTeamId != nil else { return false }
guard endDate > startDate else { return false }
// If using home location, need a valid start location
if useHomeLocation && startLocationText.isEmpty { return false }
return true
}
}
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" }
case .followTeam:
if followTeamId == nil { return "Select a team to follow" }
if endDate <= startDate { return "End date must be after start date" }
if useHomeLocation && startLocationText.isEmpty { return "Enter your home location" }
}
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 }
return (earliest, latest)
}
/// Teams grouped by sport for Follow Team picker
var teamsBySport: [Sport: [Team]] {
var grouped: [Sport: [Team]] = [:]
for team in dataProvider.teams {
grouped[team.sport, default: []].append(team)
}
// Sort teams alphabetically within each sport
for sport in grouped.keys {
grouped[sport]?.sort { $0.name < $1.name }
}
return grouped
}
/// The currently followed team (for display)
var followedTeam: Team? {
guard let teamId = followTeamId else { return nil }
return dataProvider.teams.first { $0.id == teamId }
}
// 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 {
// Only resolve if we don't already have a location with coordinates
// (LocationSearchSheet already provides full LocationInput with coordinates)
if !startLocationText.isEmpty && startLocation?.coordinate == nil {
print("🔍 resolveLocations: Resolving startLocation from text '\(startLocationText)'")
startLocation = try await locationService.resolveLocation(
LocationInput(name: startLocationText, address: startLocationText)
)
} else if startLocation?.coordinate != nil {
print("🔍 resolveLocations: startLocation already has coordinates, skipping resolve")
}
if !endLocationText.isEmpty && endLocation?.coordinate == nil {
print("🔍 resolveLocations: Resolving endLocation from text '\(endLocationText)'")
endLocation = try await locationService.resolveLocation(
LocationInput(name: endLocationText, address: endLocationText)
)
} else if endLocation?.coordinate != nil {
print("🔍 resolveLocations: endLocation already has coordinates, skipping resolve")
}
} 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
print("🔍 ViewModel.planTrip: .locations mode")
print(" - startLocationText: '\(startLocationText)'")
print(" - endLocationText: '\(endLocationText)'")
print(" - startLocation BEFORE resolve: \(startLocation?.name ?? "nil")")
print(" - endLocation BEFORE resolve: \(endLocation?.name ?? "nil")")
await resolveLocations()
resolvedStartLocation = startLocation
resolvedEndLocation = endLocation
print(" - startLocation AFTER resolve: \(startLocation?.name ?? "nil")")
print(" - endLocation AFTER resolve: \(endLocation?.name ?? "nil")")
print(" - resolvedStartLocation: \(resolvedStartLocation?.name ?? "nil")")
print(" - resolvedEndLocation: \(resolvedEndLocation?.name ?? "nil")")
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
viewState = .error("Could not resolve start or end location")
return
}
case .followTeam:
// Use provided date range
effectiveStartDate = startDate
effectiveEndDate = endDate
// If using home location, resolve it
if useHomeLocation && !startLocationText.isEmpty {
await resolveLocations()
resolvedStartLocation = startLocation
resolvedEndLocation = startLocation // Round trip - same start/end
}
// Otherwise, planner will use first/last game locations (fly-in/fly-out)
}
// Ensure we have games data
if games.isEmpty {
await loadScheduleData()
}
// 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,
allowRepeatCities: allowRepeatCities,
selectedRegions: selectedRegions,
followTeamId: followTeamId,
useHomeLocation: useHomeLocation
)
// 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) {
// Just set the mode - didSet observer handles state reset
planningMode = mode
}
/// 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)
mustSeeGameIds = []
numberOfStops = 5
leisureLevel = .moderate
mustStopLocations = []
preferredCities = []
availableGames = []
isLoadingGames = false
currentPreferences = nil
allowRepeatCities = true
selectedRegions = [.east, .central, .west]
}
/// Toggles region selection. Any combination is allowed.
func toggleRegion(_ region: Region) {
if selectedRegions.contains(region) {
selectedRegions.remove(region)
} else {
selectedRegions.insert(region)
}
}
/// 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 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,
allowRepeatCities: allowRepeatCities,
selectedRegions: selectedRegions
)
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
}
}