Files
Sportstime/SportsTime/Features/Trip/Views/TripCreationView.swift
Trey t c0f1645434 feat(ui): replace loading indicators with Apple-style LoadingSpinner
- Add LoadingSpinner component with small/medium/large sizes using system gray color
- Add LoadingPlaceholder for skeleton loading states
- Add LoadingSheet for full-screen blocking overlays
- Replace ThemedSpinner/ThemedSpinnerCompact across all views
- Remove deprecated loading components from AnimatedComponents.swift
- Delete LoadingTextGenerator.swift
- Fix PhotoImportView layout to fill full width

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:43:33 -06:00

2711 lines
99 KiB
Swift

//
// TripCreationView.swift
// SportsTime
//
import SwiftUI
struct TripCreationView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Bindable var viewModel: TripCreationViewModel
let initialSport: Sport?
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
self.viewModel = viewModel
self.initialSport = initialSport
}
@State private var showGamePicker = false
@State private var showCityInput = false
@State private var cityInputType: CityInputType = .mustStop
@State private var showLocationBanner = true
@State private var showTripDetail = false
@State private var showTripOptions = false
@State private var completedTrip: Trip?
@State private var tripOptions: [ItineraryOption] = []
// Location search state
@State private var startLocationSuggestions: [LocationSearchResult] = []
@State private var endLocationSuggestions: [LocationSearchResult] = []
@State private var startSearchTask: Task<Void, Never>?
@State private var endSearchTask: Task<Void, Never>?
@State private var isSearchingStart = false
@State private var isSearchingEnd = false
private let locationService = LocationService.shared
enum CityInputType {
case mustStop
case preferred
case homeLocation
case startLocation
case endLocation
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Hero header
heroHeader
// Planning Mode Selector
planningModeSection
// Location Permission Banner (only for locations mode)
if viewModel.planningMode == .locations && showLocationBanner {
LocationPermissionBanner(isPresented: $showLocationBanner)
}
// Mode-specific sections
switch viewModel.planningMode {
case .dateRange:
// Sports + Dates
sportsSection
datesSection
case .gameFirst:
// Sports + Game Picker + Trip Duration
sportsSection
gameBrowserSection
tripDurationSection
case .locations:
// Locations + Sports + optional games
locationSection
sportsSection
datesSection
gamesSection
case .followTeam:
// Team picker + Dates + Home location toggle
teamPickerSection
datesSection
homeLocationSection
}
// Common sections
travelSection
optionalSection
// Validation message
if let message = viewModel.formValidationMessage {
validationBanner(message: message)
}
// Plan button
planButton
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.overlay {
if case .planning = viewModel.viewState {
planningOverlay
}
}
.sheet(isPresented: $showGamePicker) {
GamePickerSheet(
selectedSports: viewModel.selectedSports,
selectedIds: $viewModel.mustSeeGameIds
)
}
.sheet(isPresented: $showCityInput) {
LocationSearchSheet(inputType: cityInputType) { location in
switch cityInputType {
case .mustStop:
viewModel.addMustStopLocation(location)
case .preferred:
viewModel.addPreferredCity(location.name)
case .homeLocation, .startLocation:
viewModel.startLocationText = location.name
viewModel.startLocation = location
case .endLocation:
viewModel.endLocationText = location.name
viewModel.endLocation = location
}
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.viewState.isError },
set: { if !$0 { viewModel.viewState = .editing } }
)) {
Button("OK") {
viewModel.viewState = .editing
}
} message: {
if case .error(let message) = viewModel.viewState {
Text(message)
}
}
.navigationDestination(isPresented: $showTripOptions) {
TripOptionsView(
options: tripOptions,
games: buildGamesDictionary(),
preferences: viewModel.currentPreferences,
convertToTrip: { option in
viewModel.convertOptionToTrip(option)
}
)
}
.navigationDestination(isPresented: $showTripDetail) {
if let trip = completedTrip {
TripDetailView(trip: trip, games: buildGamesDictionary())
}
}
.onChange(of: viewModel.viewState) { _, newState in
switch newState {
case .selectingOption(let options):
tripOptions = options
showTripOptions = true
case .completed(let trip):
completedTrip = trip
showTripDetail = true
default:
break
}
}
.onChange(of: showTripOptions) { _, isShowing in
if !isShowing {
// User navigated back from options to editing
viewModel.viewState = .editing
tripOptions = []
}
}
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
// User navigated back from single-option detail to editing
completedTrip = nil
viewModel.viewState = .editing
}
}
.task {
await viewModel.loadScheduleData()
}
.onAppear {
if let sport = initialSport {
viewModel.selectedSports = [sport]
}
}
}
}
// MARK: - Hero Header
private var heroHeader: some View {
VStack(spacing: Theme.Spacing.sm) {
Image(systemName: "map.fill")
.font(.largeTitle)
.foregroundStyle(Theme.warmOrange)
Text("Plan Your Adventure")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Select your games, set your route, and hit the road")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
}
.padding(.vertical, Theme.Spacing.md)
}
// MARK: - Sections
private var planningModeSection: some View {
ThemedSection(title: "How do you want to plan?") {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: Theme.Spacing.sm),
GridItem(.flexible(), spacing: Theme.Spacing.sm)
],
spacing: Theme.Spacing.sm
) {
ForEach(PlanningMode.allCases) { mode in
PlanningModeCard(
mode: mode,
isSelected: viewModel.planningMode == mode,
colorScheme: colorScheme
) {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.planningMode = mode
}
}
}
}
}
}
private var locationSection: some View {
ThemedSection(title: "Locations") {
// Start Location - opens search sheet
locationButton(
label: "Start Location",
icon: "location.circle.fill",
location: viewModel.startLocation,
placeholder: "Where are you starting from?"
) {
cityInputType = .startLocation
showCityInput = true
}
// End Location - opens search sheet
locationButton(
label: "End Location",
icon: "mappin.circle.fill",
location: viewModel.endLocation,
placeholder: "Where do you want to end up?"
) {
cityInputType = .endLocation
showCityInput = true
}
}
}
private func locationButton(
label: String,
icon: String,
location: LocationInput?,
placeholder: String,
action: @escaping () -> Void
) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(label)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.warmOrange)
Button(action: action) {
HStack(spacing: Theme.Spacing.md) {
Image(systemName: icon)
.foregroundStyle(Theme.warmOrange)
.frame(width: 24)
if let location = location {
Text(location.name)
.foregroundStyle(Theme.textPrimary(colorScheme))
} else {
Text(placeholder)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
}
}
private func searchLocation(query: String, isStart: Bool) {
// Cancel previous search
if isStart {
startSearchTask?.cancel()
} else {
endSearchTask?.cancel()
}
guard query.count >= 2 else {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
return
}
let task = Task {
// Debounce
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
if isStart {
isSearchingStart = true
} else {
isSearchingEnd = true
}
do {
let results = try await locationService.searchLocations(query)
guard !Task.isCancelled else { return }
if isStart {
startLocationSuggestions = Array(results.prefix(5))
isSearchingStart = false
} else {
endLocationSuggestions = Array(results.prefix(5))
isSearchingEnd = false
}
} catch {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
}
}
if isStart {
startSearchTask = task
} else {
endSearchTask = task
}
}
@ViewBuilder
private func locationSuggestionsList(
suggestions: [LocationSearchResult],
isLoading: Bool,
onSelect: @escaping (LocationSearchResult) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(suggestions) { result in
Button {
onSelect(result)
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.subheadline)
VStack(alignment: .leading, spacing: 2) {
Text(result.name)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
if !result.address.isEmpty {
Text(result.address)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.xs)
}
.buttonStyle(.plain)
if result.id != suggestions.last?.id {
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
.padding(.top, Theme.Spacing.xs)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
private var gameBrowserSection: some View {
ThemedSection(title: "Select Games") {
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
HStack(spacing: Theme.Spacing.sm) {
LoadingSpinner(size: .small)
Text("Loading games...")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, Theme.Spacing.md)
.task(id: viewModel.selectedSports) {
// Always load 90-day browsing window for gameFirst mode
await viewModel.loadGamesForBrowsing()
}
} else {
Button {
showGamePicker = true
} label: {
HStack(spacing: Theme.Spacing.md) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "sportscourt.fill")
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 4) {
Text("Browse Teams & Games")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(viewModel.availableGames.count) games available")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
}
// Show selected games summary
if !viewModel.mustSeeGameIds.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
viewModel.deselectAllGames()
} label: {
Text("Deselect All")
.font(.subheadline)
.foregroundStyle(.red)
}
}
// Show selected games preview
ForEach(viewModel.selectedGames.prefix(3)) { game in
HStack(spacing: Theme.Spacing.sm) {
SportColorBar(sport: game.game.sport)
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text(game.game.formattedDate)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
if viewModel.selectedGames.count > 3 {
Text("+ \(viewModel.selectedGames.count - 3) more")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
}
}
private var tripDurationSection: some View {
ThemedSection(title: "Trip Duration") {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "calendar.badge.clock")
.foregroundStyle(Theme.warmOrange)
Text("Days")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Stepper(
value: $viewModel.gameFirstTripDuration,
in: 2...21,
step: 1
) {
Text("\(viewModel.gameFirstTripDuration) days")
.font(.body.monospacedDigit())
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
Text("We'll find all possible \(viewModel.gameFirstTripDuration)-day trips that include your selected games")
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
private var sportsSection: some View {
ThemedSection(title: "Sports") {
SportSelectorGrid(
selectedSports: viewModel.selectedSports
) { sport in
if viewModel.selectedSports.contains(sport) {
viewModel.selectedSports.remove(sport)
} else {
viewModel.selectedSports.insert(sport)
}
}
.padding(.vertical, Theme.Spacing.xs)
}
}
private var datesSection: some View {
ThemedSection(title: "Dates") {
DateRangePicker(
startDate: $viewModel.startDate,
endDate: $viewModel.endDate
)
}
}
// MARK: - Follow Team Mode
@State private var showTeamPicker = false
private var teamPickerSection: some View {
ThemedSection(title: "Select Team") {
Button {
showTeamPicker = true
} label: {
HStack(spacing: Theme.Spacing.md) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: "person.3.fill")
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 2) {
if let team = viewModel.followedTeam {
Text(team.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(team.sport.displayName)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
} else {
Text("Choose a team")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Pick the team to follow")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showTeamPicker) {
TeamPickerSheet(
selectedTeamId: $viewModel.followTeamId,
teamsBySport: viewModel.teamsBySport
)
}
}
private var homeLocationSection: some View {
ThemedSection(title: "Trip Start/End") {
VStack(spacing: Theme.Spacing.md) {
ThemedToggle(
label: "Start and end from home",
isOn: $viewModel.useHomeLocation,
icon: "house.fill"
)
if viewModel.useHomeLocation {
// Show button to open location search sheet (same as must-stop)
Button {
cityInputType = .homeLocation
showCityInput = true
} label: {
HStack(spacing: Theme.Spacing.md) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: "house.fill")
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 2) {
if let location = viewModel.startLocation {
Text(location.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
} else {
Text("Choose home location")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Tap to search cities")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
} else {
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.leading, 32)
}
}
}
}
private var gamesSection: some View {
ThemedSection(title: "Must-See Games") {
Button {
showGamePicker = true
} label: {
HStack(spacing: Theme.Spacing.md) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: "star.fill")
.foregroundStyle(Theme.warmOrange)
}
Text("Select Games")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(viewModel.selectedGamesCount) selected")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.buttonStyle(.plain)
}
}
private var travelSection: some View {
ThemedSection(title: "Travel") {
VStack(spacing: Theme.Spacing.md) {
// Region selector
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Regions")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
RegionMapSelector(
selectedRegions: $viewModel.selectedRegions,
onToggle: { region in
viewModel.toggleRegion(region)
}
)
if viewModel.selectedRegions.isEmpty {
Text("Select at least one region")
.font(.caption)
.foregroundStyle(Theme.warmOrange)
.padding(.top, Theme.Spacing.xxs)
} else {
Text("Games will be found in selected regions")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.top, Theme.Spacing.xxs)
}
}
// Route preference
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Route Preference")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Picker("Route Preference", selection: $viewModel.routePreference) {
ForEach(RoutePreference.allCases) { pref in
Text(pref.displayName).tag(pref)
}
}
.pickerStyle(.segmented)
}
// Allow repeat cities
ThemedToggle(
label: "Allow Repeat Cities",
isOn: $viewModel.allowRepeatCities,
icon: "arrow.triangle.2.circlepath"
)
if !viewModel.allowRepeatCities {
Text("Each city will only be visited on one day")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.leading, 32)
}
}
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedRegions)
}
}
private var optionalSection: some View {
ThemedSection(title: "More Options") {
VStack(spacing: Theme.Spacing.md) {
// Must-Stop Locations
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "mappin.circle")
.foregroundStyle(Theme.warmOrange)
Text("Must-Stop Locations")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(viewModel.mustStopLocations.count)")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
ForEach(viewModel.mustStopLocations, id: \.name) { location in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(location.name)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
Button {
viewModel.removeMustStopLocation(location)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
Button {
cityInputType = .mustStop
showCityInput = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add Location")
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
// EV Charging (feature flagged)
if FeatureFlags.enableEVCharging {
ThemedToggle(
label: "EV Charging Needed",
isOn: $viewModel.needsEVCharging,
icon: "bolt.car"
)
}
// Drivers
ThemedStepper(
label: "Number of Drivers",
value: viewModel.numberOfDrivers,
range: 1...4,
onIncrement: { viewModel.numberOfDrivers += 1 },
onDecrement: { viewModel.numberOfDrivers -= 1 }
)
}
}
}
private var planningOverlay: some View {
LoadingSheet(label: "Planning trip")
}
private var planButton: some View {
Button {
Task {
await viewModel.planTrip()
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "map.fill")
Text("Plan My Trip")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(viewModel.isFormValid ? Theme.warmOrange : Theme.textMuted(colorScheme))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(!viewModel.isFormValid)
.padding(.top, Theme.Spacing.md)
.glowEffect(color: viewModel.isFormValid ? Theme.warmOrange : .clear, radius: 12)
}
private func validationBanner(message: String) -> some View {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(message)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
.padding(Theme.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
// MARK: - Helpers
private func binding(for sport: Sport) -> Binding<Bool> {
Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { isSelected in
if isSelected {
viewModel.selectedSports.insert(sport)
} else {
viewModel.selectedSports.remove(sport)
}
}
)
}
private func buildGamesDictionary() -> [String: RichGame] {
viewModel.availableGames.reduce(into: [:]) { $0[$1.id] = $1 }
}
}
// MARK: - View State Extensions
extension TripCreationViewModel.ViewState {
var isError: Bool {
if case .error = self { return true }
return false
}
var isCompleted: Bool {
if case .completed = self { return true }
return false
}
}
// MARK: - Game Picker Sheet (Calendar view: Sport Team Date with lazy loading)
struct GamePickerSheet: View {
let selectedSports: Set<Sport>
@Binding var selectedIds: Set<String>
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var expandedSports: Set<Sport> = []
@State private var expandedTeams: Set<String> = []
@State private var gamesCache: [String: [RichGame]] = [:] // teamId -> games
@State private var loadingTeams: Set<String> = []
@State private var selectedGamesCache: [String: RichGame] = [:] // gameId -> game (for count display)
private let dataProvider = AppDataProvider.shared
// Get teams for a sport (from in-memory cache, no fetching needed)
private func teamsForSport(_ sport: Sport) -> [Team] {
dataProvider.teams(for: sport).sorted { $0.name < $1.name }
}
private var sortedSports: [Sport] {
Sport.supported.filter { selectedSports.contains($0) }
}
private var selectedGamesCount: Int {
selectedIds.count
}
private func selectedCountForSport(_ sport: Sport) -> Int {
let teamIds = Set(teamsForSport(sport).map { $0.id })
return selectedGamesCache.values.filter { game in
teamIds.contains(game.homeTeam.id) || teamIds.contains(game.awayTeam.id)
}.count
}
private func selectedCountForTeam(_ teamId: String) -> Int {
guard let games = gamesCache[teamId] else { return 0 }
return games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 0) {
// Selected games summary (always visible to prevent layout jump)
HStack {
Image(systemName: selectedIds.isEmpty ? "circle" : "checkmark.circle.fill")
.foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : .green)
Text("\(selectedGamesCount) game(s) selected")
.font(.subheadline)
.foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : Theme.textPrimary(colorScheme))
Spacer()
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
// Sport sections
ForEach(sortedSports) { sport in
LazySportSection(
sport: sport,
teams: teamsForSport(sport),
selectedIds: $selectedIds,
expandedSports: $expandedSports,
expandedTeams: $expandedTeams,
gamesCache: $gamesCache,
loadingTeams: $loadingTeams,
selectedGamesCache: $selectedGamesCache,
selectedCount: selectedCountForSport(sport)
)
}
}
}
.themedBackground()
.navigationTitle("Select Games")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
selectedIds.removeAll()
selectedGamesCache.removeAll()
}
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - Lazy Sport Section (loads teams from memory, games loaded on-demand per team)
struct LazySportSection: View {
let sport: Sport
let teams: [Team]
@Binding var selectedIds: Set<String>
@Binding var expandedSports: Set<Sport>
@Binding var expandedTeams: Set<String>
@Binding var gamesCache: [String: [RichGame]]
@Binding var loadingTeams: Set<String>
@Binding var selectedGamesCache: [String: RichGame]
let selectedCount: Int
@Environment(\.colorScheme) private var colorScheme
private var isExpanded: Bool {
expandedSports.contains(sport)
}
var body: some View {
VStack(spacing: 0) {
// Sport header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedSports.remove(sport)
} else {
expandedSports.insert(sport)
}
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(sport.themeColor)
.frame(width: 32)
Text(sport.rawValue)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(teams.count) teams")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(sport.themeColor)
.clipShape(Capsule())
}
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
}
.buttonStyle(.plain)
// Teams list (when expanded)
if isExpanded {
VStack(spacing: 0) {
ForEach(teams) { team in
LazyTeamSection(
team: team,
sport: sport,
selectedIds: $selectedIds,
expandedTeams: $expandedTeams,
gamesCache: $gamesCache,
loadingTeams: $loadingTeams,
selectedGamesCache: $selectedGamesCache
)
}
}
.padding(.leading, Theme.Spacing.lg)
}
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
// MARK: - Lazy Team Section (loads games on-demand when expanded)
struct LazyTeamSection: View {
let team: Team
let sport: Sport
@Binding var selectedIds: Set<String>
@Binding var expandedTeams: Set<String>
@Binding var gamesCache: [String: [RichGame]]
@Binding var loadingTeams: Set<String>
@Binding var selectedGamesCache: [String: RichGame]
@Environment(\.colorScheme) private var colorScheme
private let dataProvider = AppDataProvider.shared
private var isExpanded: Bool {
expandedTeams.contains(team.id)
}
private var isLoading: Bool {
loadingTeams.contains(team.id)
}
private var games: [RichGame] {
gamesCache[team.id] ?? []
}
private var selectedCount: Int {
games.filter { selectedIds.contains($0.id) }.count
}
private var gamesCount: Int? {
gamesCache[team.id]?.count
}
// Group games by date
private var gamesByDate: [(date: String, games: [RichGame])] {
let sortedGames = games.sorted { $0.game.dateTime < $1.game.dateTime }
let grouped = Dictionary(grouping: sortedGames) { game in
game.game.formattedDate
}
return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime }
.map { (date: $0.key, games: $0.value) }
}
var body: some View {
VStack(spacing: 0) {
// Team header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedTeams.remove(team.id)
} else {
expandedTeams.insert(team.id)
// Load games if not cached
if gamesCache[team.id] == nil && !loadingTeams.contains(team.id) {
loadGames()
}
}
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
// Team color
if let colorHex = team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 10, height: 10)
}
Text("\(team.city) \(team.name)")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
if let count = gamesCount {
Text("\(count)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
if isLoading {
LoadingSpinner(size: .small)
} else if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption2)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Theme.warmOrange)
.clipShape(Capsule())
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
}
.buttonStyle(.plain)
// Games grouped by date (when expanded)
if isExpanded {
if isLoading {
HStack {
LoadingSpinner(size: .small)
Text("Loading games...")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
} else if games.isEmpty {
Text("No games scheduled")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(Theme.Spacing.md)
} else {
VStack(spacing: 0) {
ForEach(gamesByDate, id: \.date) { dateGroup in
VStack(alignment: .leading, spacing: 0) {
// Date header
Text(dateGroup.date)
.font(.caption)
.foregroundStyle(Theme.warmOrange)
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.sm)
.padding(.bottom, Theme.Spacing.xs)
// Games on this date
ForEach(dateGroup.games) { game in
GameCalendarRow(
game: game,
isSelected: selectedIds.contains(game.id),
onTap: {
// Disable implicit animation to prevent weird morphing effect
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
selectedGamesCache.removeValue(forKey: game.id)
} else {
selectedIds.insert(game.id)
selectedGamesCache[game.id] = game
}
}
}
)
}
}
}
}
.padding(.leading, Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
}
}
}
}
private func loadGames() {
loadingTeams.insert(team.id)
Task {
do {
let loadedGames = try await dataProvider.gamesForTeam(teamId: team.id)
await MainActor.run {
gamesCache[team.id] = loadedGames
loadingTeams.remove(team.id)
}
} catch {
await MainActor.run {
gamesCache[team.id] = []
loadingTeams.remove(team.id)
}
}
}
}
}
// MARK: - Game Calendar Row
struct GameCalendarRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
HStack(spacing: Theme.Spacing.sm) {
// Selection indicator
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
.animation(nil, value: isSelected)
VStack(alignment: .leading, spacing: 2) {
Text("vs \(game.awayTeam.name)")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.xs) {
Label(game.game.gameTime, systemImage: "clock")
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("")
.foregroundStyle(Theme.textMuted(colorScheme))
Label(game.stadium.name, systemImage: "building.2")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
HStack(spacing: Theme.Spacing.xs) {
Label(game.stadium.fullAddress, systemImage: "mappin.circle.fill")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
if let broadcast = game.game.broadcastInfo, !broadcast.isEmpty {
Text("")
.foregroundStyle(Theme.textMuted(colorScheme))
Label(broadcast, systemImage: "tv")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md)
.background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear)
.animation(nil, value: isSelected)
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: TripCreationView.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: - Trip Options View
// 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"
}
}
}
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"
}
}
}
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)
}
}
}
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 {
withAnimation(.easeInOut(duration: 0.2)) {
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)
)
.animation(nil, value: paceFilter)
}
}
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 }
}
}
// MARK: - Themed Form Components
struct ThemedSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text(title)
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
content()
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
}
}
struct ThemedTextField: View {
let label: String
let placeholder: String
@Binding var text: String
var icon: String = "mappin"
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(label)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: icon)
.foregroundStyle(Theme.warmOrange)
.frame(width: 24)
TextField(placeholder, text: $text)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
}
}
struct ThemedToggle: View {
let label: String
@Binding var isOn: Bool
var icon: String = "checkmark.circle"
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: icon)
.foregroundStyle(isOn ? Theme.warmOrange : Theme.textMuted(colorScheme))
.frame(width: 24)
Text(label)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(Theme.warmOrange)
}
}
}
struct ThemedStepper: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let onIncrement: () -> Void
let onDecrement: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack {
Text(label)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
HStack(spacing: Theme.Spacing.sm) {
Button {
if value > range.lowerBound {
onDecrement()
}
} label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(value > range.lowerBound ? Theme.warmOrange : Theme.textMuted(colorScheme))
}
.disabled(value <= range.lowerBound)
Text("\(value)")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
.frame(minWidth: 30)
Button {
if value < range.upperBound {
onIncrement()
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(value < range.upperBound ? Theme.warmOrange : Theme.textMuted(colorScheme))
}
.disabled(value >= range.upperBound)
}
}
}
}
struct ThemedDatePicker: View {
let label: String
@Binding var selection: Date
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "calendar")
.foregroundStyle(Theme.warmOrange)
Text(label)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Spacer()
DatePicker("", selection: $selection, displayedComponents: .date)
.labelsHidden()
.tint(Theme.warmOrange)
}
}
}
// MARK: - Date Range Picker
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: - Team Picker Sheet
struct TeamPickerSheet: View {
@Binding var selectedTeamId: String?
let teamsBySport: [Sport: [Team]]
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var searchText = ""
private var sortedSports: [Sport] {
Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty }
}
private var filteredTeamsBySport: [Sport: [Team]] {
guard !searchText.isEmpty else { return teamsBySport }
var filtered: [Sport: [Team]] = [:]
for (sport, teams) in teamsBySport {
let matchingTeams = teams.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.city.localizedCaseInsensitiveContains(searchText) ||
$0.abbreviation.localizedCaseInsensitiveContains(searchText)
}
if !matchingTeams.isEmpty {
filtered[sport] = matchingTeams
}
}
return filtered
}
var body: some View {
NavigationStack {
List {
ForEach(sortedSports, id: \.self) { sport in
if let teams = filteredTeamsBySport[sport], !teams.isEmpty {
Section(sport.displayName) {
ForEach(teams) { team in
TeamRow(
team: team,
isSelected: selectedTeamId == team.id,
colorScheme: colorScheme
) {
selectedTeamId = team.id
dismiss()
}
}
}
}
}
}
.searchable(text: $searchText, prompt: "Search teams")
.navigationTitle("Select Team")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
// MARK: - Planning Mode Card
struct PlanningModeCard: View {
let mode: PlanningMode
let isSelected: Bool
let colorScheme: ColorScheme
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: Theme.Spacing.sm) {
// Icon
ZStack {
Circle()
.fill(isSelected ? Theme.warmOrange : Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: mode.iconName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isSelected ? .white : Theme.warmOrange)
}
// Title
Text(mode.displayName)
.font(.subheadline.weight(.medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.md)
.padding(.horizontal, Theme.Spacing.sm)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.cardBackgroundElevated(colorScheme))
)
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.strokeBorder(
isSelected ? Theme.warmOrange : Color.clear,
lineWidth: 2
)
)
}
.buttonStyle(.plain)
.accessibilityLabel("\(mode.displayName): \(mode.description)")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
struct TeamRow: View {
let team: Team
let isSelected: Bool
let colorScheme: ColorScheme
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Theme.Spacing.md) {
// Team color indicator
if let colorHex = team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 12, height: 12)
} else {
Circle()
.fill(Theme.warmOrange)
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text(team.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(team.city)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
#Preview {
TripCreationView(viewModel: TripCreationViewModel())
}