Files
Sportstime/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
Trey t 5bbfd30a70 Redesign trip option cards and fix various UI/planning issues
TripOptionCard improvements:
- Replace horizontal route with vertical layout (start → end with arrow)
- Remove rank badges (1, 2, 3, etc.)
- Split stats into two rows: cities/miles and sports with game counts
- Clear selection when navigating back from detail view

Settings cleanup:
- Remove unused settings (preferred game time, playoff games, notifications)
- Convert remaining settings to sliders

Planning fixes:
- Fix multi-day driving calculation in canTransition
- Remove over-restrictive trip rejection in TravelEstimator
- Clear games cache when sport selection changes

UI polish:
- RoutePreviewStrip shows all cities (abbreviated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:05:25 -06:00

538 lines
19 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
// 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()
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<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
// 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
do {
// 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(let options):
guard !options.isEmpty else {
viewState = .error("No valid itinerary found")
return
}
// 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))
}
} catch {
viewState = .error("Trip planning failed: \(error.localizedDescription)")
}
}
func toggleMustSeeGame(_ gameId: UUID) {
if mustSeeGameIds.contains(gameId) {
mustSeeGameIds.remove(gameId)
} else {
mustSeeGameIds.insert(gameId)
}
}
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
}
}