refactor: remove legacy trip creation flow, extract shared components
- Delete TripCreationView.swift and TripCreationViewModel.swift (unused) - Extract TripOptionsView to standalone file - Extract DateRangePicker and DayCell to standalone file - Extract LocationSearchSheet and CityInputType to standalone file - Fix TripWizardView to pass games dictionary to TripOptionsView - Remove debug print statements from TripDetailView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,646 +0,0 @@
|
|||||||
//
|
|
||||||
// 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<String> = []
|
|
||||||
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: String?
|
|
||||||
var useHomeLocation: Bool = true
|
|
||||||
|
|
||||||
// Game First Mode - Trip duration for sliding windows
|
|
||||||
var gameFirstTripDuration: Int = 7
|
|
||||||
|
|
||||||
// MARK: - Dependencies
|
|
||||||
|
|
||||||
private let planningEngine = TripPlanningEngine()
|
|
||||||
private let locationService = LocationService.shared
|
|
||||||
private let dataProvider = AppDataProvider.shared
|
|
||||||
|
|
||||||
// MARK: - Cached Data
|
|
||||||
|
|
||||||
private var teams: [String: Team] = [:]
|
|
||||||
private var stadiums: [String: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter games within date range
|
|
||||||
games = try await dataProvider.filterGames(
|
|
||||||
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,
|
|
||||||
gameFirstTripDuration: gameFirstTripDuration
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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: String) {
|
|
||||||
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 all games for browsing (no date filter)
|
|
||||||
games = try await dataProvider.allGames(for: selectedSports)
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
followTeamId: followTeamId,
|
|
||||||
useHomeLocation: useHomeLocation,
|
|
||||||
gameFirstTripDuration: gameFirstTripDuration
|
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -384,7 +384,9 @@ struct TripDetailView: View {
|
|||||||
let dayStart = calendar.startOfDay(for: date)
|
let dayStart = calendar.startOfDay(for: date)
|
||||||
let allGameIds = trip.stops.flatMap { $0.games }
|
let allGameIds = trip.stops.flatMap { $0.games }
|
||||||
|
|
||||||
return allGameIds.compactMap { games[$0] }.filter { richGame in
|
let foundGames = allGameIds.compactMap { games[$0] }
|
||||||
|
|
||||||
|
return foundGames.filter { richGame in
|
||||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
}
|
}
|
||||||
|
|||||||
600
SportsTime/Features/Trip/Views/TripOptionsView.swift
Normal file
600
SportsTime/Features/Trip/Views/TripOptionsView.swift
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
//
|
||||||
|
// TripOptionsView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Displays trip options for user selection after planning completes.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Sort Options
|
||||||
|
|
||||||
|
enum TripSortOption: String, CaseIterable, Identifiable {
|
||||||
|
case recommended = "Recommended"
|
||||||
|
case mostCities = "Most Cities"
|
||||||
|
case mostGames = "Most Games"
|
||||||
|
case leastGames = "Least Games"
|
||||||
|
case mostMiles = "Most Miles"
|
||||||
|
case leastMiles = "Least Miles"
|
||||||
|
case bestEfficiency = "Best Efficiency"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .recommended: return "star.fill"
|
||||||
|
case .mostCities: return "mappin.and.ellipse"
|
||||||
|
case .mostGames, .leastGames: return "sportscourt"
|
||||||
|
case .mostMiles, .leastMiles: return "road.lanes"
|
||||||
|
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pace Filter
|
||||||
|
|
||||||
|
enum TripPaceFilter: String, CaseIterable, Identifiable {
|
||||||
|
case all = "All"
|
||||||
|
case packed = "Packed"
|
||||||
|
case moderate = "Moderate"
|
||||||
|
case relaxed = "Relaxed"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .all: return "rectangle.stack"
|
||||||
|
case .packed: return "flame"
|
||||||
|
case .moderate: return "equal.circle"
|
||||||
|
case .relaxed: return "leaf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cities Filter
|
||||||
|
|
||||||
|
enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||||
|
case noLimit = 100
|
||||||
|
case fifteen = 15
|
||||||
|
case ten = 10
|
||||||
|
case five = 5
|
||||||
|
case four = 4
|
||||||
|
case three = 3
|
||||||
|
case two = 2
|
||||||
|
|
||||||
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .noLimit: return "No Limit"
|
||||||
|
case .fifteen: return "15"
|
||||||
|
case .ten: return "10"
|
||||||
|
case .five: return "5"
|
||||||
|
case .four: return "4"
|
||||||
|
case .three: return "3"
|
||||||
|
case .two: return "2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trip Options Grouper
|
||||||
|
|
||||||
|
enum TripOptionsGrouper {
|
||||||
|
typealias GroupedOptions = (header: String, options: [ItineraryOption])
|
||||||
|
|
||||||
|
static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||||
|
let grouped = Dictionary(grouping: options) { option in
|
||||||
|
Set(option.stops.map { $0.city }).count
|
||||||
|
}
|
||||||
|
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||||
|
return sorted.map { count, opts in
|
||||||
|
("\(count) \(count == 1 ? "city" : "cities")", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||||
|
let grouped = Dictionary(grouping: options) { $0.totalGames }
|
||||||
|
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||||
|
return sorted.map { count, opts in
|
||||||
|
("\(count) \(count == 1 ? "game" : "games")", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||||
|
let ranges: [(min: Int, max: Int, label: String)] = [
|
||||||
|
(0, 500, "0-500 mi"),
|
||||||
|
(500, 1000, "500-1000 mi"),
|
||||||
|
(1000, 1500, "1000-1500 mi"),
|
||||||
|
(1500, 2000, "1500-2000 mi"),
|
||||||
|
(2000, Int.max, "2000+ mi")
|
||||||
|
]
|
||||||
|
|
||||||
|
var groupedDict: [String: [ItineraryOption]] = [:]
|
||||||
|
for option in options {
|
||||||
|
let miles = Int(option.totalDistanceMiles)
|
||||||
|
for range in ranges {
|
||||||
|
if miles >= range.min && miles < range.max {
|
||||||
|
groupedDict[range.label, default: []].append(option)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by range order
|
||||||
|
let rangeOrder = ascending ? ranges : ranges.reversed()
|
||||||
|
return rangeOrder.compactMap { range in
|
||||||
|
guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil }
|
||||||
|
return (range.label, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trip Options View
|
||||||
|
|
||||||
|
struct TripOptionsView: View {
|
||||||
|
let options: [ItineraryOption]
|
||||||
|
let games: [String: RichGame]
|
||||||
|
let preferences: TripPreferences?
|
||||||
|
let convertToTrip: (ItineraryOption) -> Trip
|
||||||
|
|
||||||
|
@State private var selectedTrip: Trip?
|
||||||
|
@State private var showTripDetail = false
|
||||||
|
@State private var sortOption: TripSortOption = .recommended
|
||||||
|
@State private var citiesFilter: CitiesFilter = .noLimit
|
||||||
|
@State private var paceFilter: TripPaceFilter = .all
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private func uniqueCityCount(for option: ItineraryOption) -> Int {
|
||||||
|
Set(option.stops.map { $0.city }).count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredAndSortedOptions: [ItineraryOption] {
|
||||||
|
// Apply filters first
|
||||||
|
let filtered = options.filter { option in
|
||||||
|
let cityCount = uniqueCityCount(for: option)
|
||||||
|
|
||||||
|
// City filter
|
||||||
|
guard cityCount <= citiesFilter.rawValue else { return false }
|
||||||
|
|
||||||
|
// Pace filter based on games per day ratio
|
||||||
|
switch paceFilter {
|
||||||
|
case .all:
|
||||||
|
return true
|
||||||
|
case .packed:
|
||||||
|
// High game density: > 0.8 games per day
|
||||||
|
return gamesPerDay(for: option) >= 0.8
|
||||||
|
case .moderate:
|
||||||
|
// Medium density: 0.4-0.8 games per day
|
||||||
|
let gpd = gamesPerDay(for: option)
|
||||||
|
return gpd >= 0.4 && gpd < 0.8
|
||||||
|
case .relaxed:
|
||||||
|
// Low density: < 0.4 games per day
|
||||||
|
return gamesPerDay(for: option) < 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then apply sorting
|
||||||
|
switch sortOption {
|
||||||
|
case .recommended:
|
||||||
|
return filtered
|
||||||
|
case .mostCities:
|
||||||
|
return filtered.sorted { $0.stops.count > $1.stops.count }
|
||||||
|
case .mostGames:
|
||||||
|
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||||
|
case .leastGames:
|
||||||
|
return filtered.sorted { $0.totalGames < $1.totalGames }
|
||||||
|
case .mostMiles:
|
||||||
|
return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||||
|
case .leastMiles:
|
||||||
|
return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||||
|
case .bestEfficiency:
|
||||||
|
return filtered.sorted {
|
||||||
|
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
||||||
|
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
||||||
|
return effA > effB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gamesPerDay(for option: ItineraryOption) -> Double {
|
||||||
|
guard let first = option.stops.first,
|
||||||
|
let last = option.stops.last else { return 0 }
|
||||||
|
let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1)
|
||||||
|
return Double(option.totalGames) / Double(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groupedOptions: [TripOptionsGrouper.GroupedOptions] {
|
||||||
|
switch sortOption {
|
||||||
|
case .recommended, .bestEfficiency:
|
||||||
|
// Flat list, no grouping
|
||||||
|
return [("", filteredAndSortedOptions)]
|
||||||
|
|
||||||
|
case .mostCities:
|
||||||
|
return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false)
|
||||||
|
|
||||||
|
case .mostGames:
|
||||||
|
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false)
|
||||||
|
|
||||||
|
case .leastGames:
|
||||||
|
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true)
|
||||||
|
|
||||||
|
case .mostMiles:
|
||||||
|
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false)
|
||||||
|
|
||||||
|
case .leastMiles:
|
||||||
|
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
|
// Hero header
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
|
Text("\(filteredAndSortedOptions.count) of \(options.count) Routes")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
.padding(.top, Theme.Spacing.lg)
|
||||||
|
|
||||||
|
// Filters section
|
||||||
|
filtersSection
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
|
||||||
|
// Options list (grouped when applicable)
|
||||||
|
if filteredAndSortedOptions.isEmpty {
|
||||||
|
emptyFilterState
|
||||||
|
.padding(.top, Theme.Spacing.xl)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
|
// Section header (only if non-empty)
|
||||||
|
if !group.header.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Text(group.header)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(group.options.count)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
.padding(.top, Theme.Spacing.md)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options in this group
|
||||||
|
ForEach(group.options) { option in
|
||||||
|
TripOptionCard(
|
||||||
|
option: option,
|
||||||
|
games: games,
|
||||||
|
onSelect: {
|
||||||
|
selectedTrip = convertToTrip(option)
|
||||||
|
showTripDetail = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Theme.Spacing.md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
|
}
|
||||||
|
.themedBackground()
|
||||||
|
.navigationDestination(isPresented: $showTripDetail) {
|
||||||
|
if let trip = selectedTrip {
|
||||||
|
TripDetailView(trip: trip, games: games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: showTripDetail) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
selectedTrip = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortPicker: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(TripSortOption.allCases) { option in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
sortOption = option
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(option.rawValue, systemImage: option.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: sortOption.icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(sortOption.rawValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filters Section
|
||||||
|
|
||||||
|
private var filtersSection: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Sort and Pace row
|
||||||
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
|
sortPicker
|
||||||
|
Spacer()
|
||||||
|
pacePicker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities picker
|
||||||
|
citiesPicker
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pacePicker: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(TripPaceFilter.allCases) { pace in
|
||||||
|
Button {
|
||||||
|
paceFilter = pace
|
||||||
|
} label: {
|
||||||
|
Label(pace.rawValue, systemImage: pace.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: paceFilter.icon)
|
||||||
|
.font(.caption)
|
||||||
|
.contentTransition(.identity)
|
||||||
|
Text(paceFilter.rawValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
.contentTransition(.identity)
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var citiesPicker: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||||
|
Label("Max Cities", systemImage: "mappin.circle")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(CitiesFilter.allCases) { filter in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
citiesFilter = filter
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(filter.displayName)
|
||||||
|
.font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium))
|
||||||
|
.foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyFilterState: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
Text("No routes match your filters")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
citiesFilter = .noLimit
|
||||||
|
paceFilter = .all
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Reset Filters")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Theme.Spacing.xxl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trip Option Card
|
||||||
|
|
||||||
|
struct TripOptionCard: View {
|
||||||
|
let option: ItineraryOption
|
||||||
|
let games: [String: RichGame]
|
||||||
|
let onSelect: () -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@State private var aiDescription: String?
|
||||||
|
@State private var isLoadingDescription = false
|
||||||
|
|
||||||
|
private var uniqueCities: [String] {
|
||||||
|
option.stops.map { $0.city }.removingDuplicates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalGames: Int {
|
||||||
|
option.stops.flatMap { $0.games }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var uniqueSports: [Sport] {
|
||||||
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
|
let sports = gameIds.compactMap { games[$0]?.game.sport }
|
||||||
|
return Array(Set(sports)).sorted { $0.rawValue < $1.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gamesPerSport: [(sport: Sport, count: Int)] {
|
||||||
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
|
var countsBySport: [Sport: Int] = [:]
|
||||||
|
for gameId in gameIds {
|
||||||
|
if let sport = games[gameId]?.game.sport {
|
||||||
|
countsBySport[sport, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countsBySport.sorted { $0.key.rawValue < $1.key.rawValue }
|
||||||
|
.map { (sport: $0.key, count: $0.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Route info
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Vertical route display
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(uniqueCities.first ?? "")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("|")
|
||||||
|
.font(.caption2)
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
|
Text(uniqueCities.last ?? "")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top stats row: cities and miles
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label("\(uniqueCities.count) cities", systemImage: "mappin")
|
||||||
|
if option.totalDistanceMiles > 0 {
|
||||||
|
Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
|
// Bottom row: sports with game counts
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(gamesPerSport, id: \.sport) { item in
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: item.sport.iconName)
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(item.sport.rawValue.uppercased()) \(item.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(item.sport.themeColor.opacity(0.15))
|
||||||
|
.foregroundStyle(item.sport.themeColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI-generated description (after stats)
|
||||||
|
if let description = aiDescription {
|
||||||
|
Text(description)
|
||||||
|
.font(.system(size: 13, weight: .regular))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.transition(.opacity)
|
||||||
|
} else if isLoadingDescription {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
LoadingSpinner(size: .small)
|
||||||
|
Text("Generating...")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Right: Chevron
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.task(id: option.id) {
|
||||||
|
// Reset state when option changes
|
||||||
|
aiDescription = nil
|
||||||
|
isLoadingDescription = false
|
||||||
|
await generateDescription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateDescription() async {
|
||||||
|
guard RouteDescriptionGenerator.shared.isAvailable else { return }
|
||||||
|
|
||||||
|
isLoadingDescription = true
|
||||||
|
|
||||||
|
// Build input from THIS specific option
|
||||||
|
let input = RouteDescriptionInput(from: option, games: games)
|
||||||
|
|
||||||
|
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
aiDescription = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoadingDescription = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array Extension for Removing Duplicates
|
||||||
|
|
||||||
|
extension Array where Element: Hashable {
|
||||||
|
func removingDuplicates() -> [Element] {
|
||||||
|
var seen = Set<Element>()
|
||||||
|
return filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
//
|
||||||
|
// DateRangePicker.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Extracted from TripCreationView - reusable date range picker component.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DateRangePicker: View {
|
||||||
|
@Binding var startDate: Date
|
||||||
|
@Binding var endDate: Date
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@State private var displayedMonth: Date = Date()
|
||||||
|
@State private var selectionState: SelectionState = .none
|
||||||
|
|
||||||
|
enum SelectionState {
|
||||||
|
case none
|
||||||
|
case startSelected
|
||||||
|
case complete
|
||||||
|
}
|
||||||
|
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
|
||||||
|
|
||||||
|
private var monthYearString: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM yyyy"
|
||||||
|
return formatter.string(from: displayedMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var daysInMonth: [Date?] {
|
||||||
|
guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth),
|
||||||
|
let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var days: [Date?] = []
|
||||||
|
let startOfMonth = monthInterval.start
|
||||||
|
let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end)!
|
||||||
|
|
||||||
|
// Get the first day of the week containing the first day of the month
|
||||||
|
var currentDate = monthFirstWeek.start
|
||||||
|
|
||||||
|
// Add days until we've covered the month
|
||||||
|
while currentDate <= endOfMonth || days.count % 7 != 0 {
|
||||||
|
if currentDate >= startOfMonth && currentDate <= endOfMonth {
|
||||||
|
days.append(currentDate)
|
||||||
|
} else if currentDate < startOfMonth {
|
||||||
|
days.append(nil) // Placeholder for days before month starts
|
||||||
|
} else if days.count % 7 != 0 {
|
||||||
|
days.append(nil) // Placeholder to complete the last week
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tripDuration: Int {
|
||||||
|
let components = calendar.dateComponents([.day], from: startDate, to: endDate)
|
||||||
|
return (components.day ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Selected range summary
|
||||||
|
selectedRangeSummary
|
||||||
|
|
||||||
|
// Month navigation
|
||||||
|
monthNavigation
|
||||||
|
|
||||||
|
// Days of week header
|
||||||
|
daysOfWeekHeader
|
||||||
|
|
||||||
|
// Calendar grid
|
||||||
|
calendarGrid
|
||||||
|
|
||||||
|
// Trip duration
|
||||||
|
tripDurationBadge
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Initialize displayed month to show the start date's month
|
||||||
|
displayedMonth = calendar.startOfDay(for: startDate)
|
||||||
|
// If dates are already selected (endDate > startDate), show complete state
|
||||||
|
if endDate > startDate {
|
||||||
|
selectionState = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedRangeSummary: some View {
|
||||||
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
|
// Start date
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("START")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
|
// End date
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("END")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthNavigation: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Theme.warmOrange.opacity(0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(monthYearString)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Theme.warmOrange.opacity(0.15))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var daysOfWeekHeader: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in
|
||||||
|
Text(day)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var calendarGrid: some View {
|
||||||
|
let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
||||||
|
|
||||||
|
return LazyVGrid(columns: columns, spacing: 4) {
|
||||||
|
ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in
|
||||||
|
if let date = date {
|
||||||
|
DayCell(
|
||||||
|
date: date,
|
||||||
|
isStart: calendar.isDate(date, inSameDayAs: startDate),
|
||||||
|
isEnd: calendar.isDate(date, inSameDayAs: endDate),
|
||||||
|
isInRange: isDateInRange(date),
|
||||||
|
isToday: calendar.isDateInToday(date),
|
||||||
|
onTap: { handleDateTap(date) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tripDurationBadge: some View {
|
||||||
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.top, Theme.Spacing.xs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isDateInRange(_ date: Date) -> Bool {
|
||||||
|
let start = calendar.startOfDay(for: startDate)
|
||||||
|
let end = calendar.startOfDay(for: endDate)
|
||||||
|
let current = calendar.startOfDay(for: date)
|
||||||
|
return current > start && current < end
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleDateTap(_ date: Date) {
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let tappedDate = calendar.startOfDay(for: date)
|
||||||
|
|
||||||
|
// Don't allow selecting dates in the past
|
||||||
|
if tappedDate < today {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch selectionState {
|
||||||
|
case .none, .complete:
|
||||||
|
// First tap: set start date, reset end to same day
|
||||||
|
startDate = date
|
||||||
|
endDate = date
|
||||||
|
selectionState = .startSelected
|
||||||
|
|
||||||
|
case .startSelected:
|
||||||
|
// Second tap: set end date (if after start)
|
||||||
|
if date >= startDate {
|
||||||
|
endDate = date
|
||||||
|
} else {
|
||||||
|
// If tapped date is before start, make it the new start
|
||||||
|
endDate = startDate
|
||||||
|
startDate = date
|
||||||
|
}
|
||||||
|
selectionState = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Cell
|
||||||
|
|
||||||
|
struct DayCell: View {
|
||||||
|
let date: Date
|
||||||
|
let isStart: Bool
|
||||||
|
let isEnd: Bool
|
||||||
|
let isInRange: Bool
|
||||||
|
let isToday: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
|
||||||
|
private var dayNumber: String {
|
||||||
|
"\(calendar.component(.day, from: date))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPast: Bool {
|
||||||
|
calendar.startOfDay(for: date) < calendar.startOfDay(for: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
ZStack {
|
||||||
|
// Range highlight background (stretches edge to edge)
|
||||||
|
if isInRange || isStart || isEnd {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.warmOrange.opacity(0.15))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.opacity(isStart && !isEnd ? 0 : 1)
|
||||||
|
.offset(x: isStart ? 20 : 0)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.warmOrange.opacity(0.15))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.opacity(isEnd && !isStart ? 0 : 1)
|
||||||
|
.offset(x: isEnd ? -20 : 0)
|
||||||
|
}
|
||||||
|
.opacity(isStart && isEnd ? 0 : 1) // Hide when start == end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day circle
|
||||||
|
ZStack {
|
||||||
|
if isStart || isEnd {
|
||||||
|
Circle()
|
||||||
|
.fill(Theme.warmOrange)
|
||||||
|
} else if isToday {
|
||||||
|
Circle()
|
||||||
|
.stroke(Theme.warmOrange, lineWidth: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(dayNumber)
|
||||||
|
.font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium))
|
||||||
|
.foregroundStyle(
|
||||||
|
isPast ? Theme.textMuted(colorScheme).opacity(0.5) :
|
||||||
|
(isStart || isEnd) ? .white :
|
||||||
|
isToday ? Theme.warmOrange :
|
||||||
|
Theme.textPrimary(colorScheme)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isPast)
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DateRangePicker(
|
||||||
|
startDate: .constant(Date()),
|
||||||
|
endDate: .constant(Date().addingTimeInterval(86400 * 7))
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// LocationSearchSheet.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Extracted from TripCreationView - location search sheet for adding cities/places.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - City Input Type
|
||||||
|
|
||||||
|
enum CityInputType {
|
||||||
|
case mustStop
|
||||||
|
case preferred
|
||||||
|
case homeLocation
|
||||||
|
case startLocation
|
||||||
|
case endLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Location Search Sheet
|
||||||
|
|
||||||
|
struct LocationSearchSheet: View {
|
||||||
|
let inputType: CityInputType
|
||||||
|
let onAdd: (LocationInput) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var searchResults: [LocationSearchResult] = []
|
||||||
|
@State private var isSearching = false
|
||||||
|
@State private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private let locationService = LocationService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Search field
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search cities, addresses, places...", text: $searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if isSearching {
|
||||||
|
LoadingSpinner(size: .small)
|
||||||
|
} else if !searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
searchResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
// Results list
|
||||||
|
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Results",
|
||||||
|
systemImage: "mappin.slash",
|
||||||
|
description: Text("Try a different search term")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(searchResults) { result in
|
||||||
|
Button {
|
||||||
|
onAdd(result.toLocationInput())
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.title2)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(result.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if !result.address.isEmpty {
|
||||||
|
Text(result.address)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.onChange(of: searchText) { _, newValue in
|
||||||
|
// Debounce search
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await performSearch(query: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch(query: String) async {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
do {
|
||||||
|
searchResults = try await locationService.searchLocations(query)
|
||||||
|
} catch {
|
||||||
|
searchResults = []
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LocationSearchSheet(inputType: .mustStop) { _ in }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ struct TripWizardView: View {
|
|||||||
@State private var viewModel = TripWizardViewModel()
|
@State private var viewModel = TripWizardViewModel()
|
||||||
@State private var showTripOptions = false
|
@State private var showTripOptions = false
|
||||||
@State private var tripOptions: [ItineraryOption] = []
|
@State private var tripOptions: [ItineraryOption] = []
|
||||||
|
@State private var gamesForDisplay: [String: RichGame] = [:]
|
||||||
@State private var planningError: String?
|
@State private var planningError: String?
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ struct TripWizardView: View {
|
|||||||
.navigationDestination(isPresented: $showTripOptions) {
|
.navigationDestination(isPresented: $showTripOptions) {
|
||||||
TripOptionsView(
|
TripOptionsView(
|
||||||
options: tripOptions,
|
options: tripOptions,
|
||||||
games: [:],
|
games: gamesForDisplay,
|
||||||
preferences: buildPreferences(),
|
preferences: buildPreferences(),
|
||||||
convertToTrip: { option in
|
convertToTrip: { option in
|
||||||
convertOptionToTrip(option)
|
convertOptionToTrip(option)
|
||||||
@@ -126,6 +127,17 @@ struct TripWizardView: View {
|
|||||||
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
||||||
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
// Build RichGame dictionary for display
|
||||||
|
var richGamesDict: [String: RichGame] = [:]
|
||||||
|
for game in games {
|
||||||
|
if let homeTeam = teamsById[game.homeTeamId],
|
||||||
|
let awayTeam = teamsById[game.awayTeamId],
|
||||||
|
let stadium = stadiumsById[game.stadiumId] {
|
||||||
|
let richGame = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
|
richGamesDict[game.id] = richGame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build planning request
|
// Build planning request
|
||||||
let request = PlanningRequest(
|
let request = PlanningRequest(
|
||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
@@ -144,6 +156,7 @@ struct TripWizardView: View {
|
|||||||
showError = true
|
showError = true
|
||||||
} else {
|
} else {
|
||||||
tripOptions = options
|
tripOptions = options
|
||||||
|
gamesForDisplay = richGamesDict
|
||||||
showTripOptions = true
|
showTripOptions = true
|
||||||
}
|
}
|
||||||
case .failure(let failure):
|
case .failure(let failure):
|
||||||
|
|||||||
Reference in New Issue
Block a user