Files
Sportstime/SportsTime/Features/Trip/Views/TripCreationView.swift
Trey t e70b9faab8 fix: show all team games (home and away) when browsing by team
Previously, browsing by team (e.g., Houston Astros) only showed home games.
Now games are associated with both home AND away teams, so selecting a team
shows all their games regardless of whether they're playing at home or away.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:09:50 -06:00

2567 lines
91 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(
games: viewModel.availableGames,
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) {
ThemedSpinnerCompact(size: 20)
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 {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
PlanningProgressView()
.padding(40)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
}
}
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)
struct GamePickerSheet: View {
let games: [RichGame]
@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> = []
// Group games by Sport Team (both home and away teams so browsing shows all team games)
private var gamesBySport: [Sport: [TeamWithGames]] {
var result: [Sport: [String: TeamWithGames]] = [:]
for game in games {
let sport = game.game.sport
if result[sport] == nil {
result[sport] = [:]
}
// Add game to home team's list
let homeTeam = game.homeTeam
if var teamData = result[sport]?[homeTeam.id] {
teamData.games.append(game)
result[sport]?[homeTeam.id] = teamData
} else {
result[sport]?[homeTeam.id] = TeamWithGames(
team: homeTeam,
sport: sport,
games: [game]
)
}
// Also add game to away team's list (so browsing by team shows all games)
let awayTeam = game.awayTeam
if var teamData = result[sport]?[awayTeam.id] {
teamData.games.append(game)
result[sport]?[awayTeam.id] = teamData
} else {
result[sport]?[awayTeam.id] = TeamWithGames(
team: awayTeam,
sport: sport,
games: [game]
)
}
}
// Convert to sorted arrays
var sortedResult: [Sport: [TeamWithGames]] = [:]
for (sport, teamsDict) in result {
sortedResult[sport] = teamsDict.values.sorted { $0.team.name < $1.team.name }
}
return sortedResult
}
private var sortedSports: [Sport] {
Sport.supported.filter { gamesBySport[$0] != nil }
}
private var selectedGamesCount: Int {
selectedIds.count
}
private func selectedCountForSport(_ sport: Sport) -> Int {
guard let teams = gamesBySport[sport] else { return 0 }
return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count
}
private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 0) {
// Selected games summary
if !selectedIds.isEmpty {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.font(.subheadline)
Spacer()
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
}
// Sport sections
ForEach(sortedSports) { sport in
SportSection(
sport: sport,
teams: gamesBySport[sport] ?? [],
selectedIds: $selectedIds,
expandedSports: $expandedSports,
expandedTeams: $expandedTeams,
selectedCount: selectedCountForSport(sport)
)
}
}
}
.themedBackground()
.navigationTitle("Select Games")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
selectedIds.removeAll()
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - Sport Section
struct SportSection: View {
let sport: Sport
let teams: [TeamWithGames]
@Binding var selectedIds: Set<String>
@Binding var expandedSports: Set<Sport>
@Binding var expandedTeams: Set<String>
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.flatMap { $0.games }.count) games")
.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) { teamData in
TeamSection(
teamData: teamData,
selectedIds: $selectedIds,
expandedTeams: $expandedTeams
)
}
}
.padding(.leading, Theme.Spacing.lg)
}
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
// MARK: - Team Section
struct TeamSection: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<String>
@Binding var expandedTeams: Set<String>
@Environment(\.colorScheme) private var colorScheme
private var isExpanded: Bool {
expandedTeams.contains(teamData.id)
}
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
// Group games by date
private var gamesByDate: [(date: String, games: [RichGame])] {
let grouped = Dictionary(grouping: teamData.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(teamData.id)
} else {
expandedTeams.insert(teamData.id)
}
}
} label: {
HStack(spacing: Theme.Spacing.sm) {
// Team color
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 10, height: 10)
}
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(teamData.games.count)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
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 {
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: {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
)
}
}
}
}
.padding(.leading, Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5))
}
}
}
}
// 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))
VStack(alignment: .leading, spacing: 2) {
Text("vs \(game.awayTeam.name)")
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.xs) {
Text(game.game.gameTime)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("")
.foregroundStyle(Theme.textMuted(colorScheme))
Text(game.stadium.name)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md)
.background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear)
}
.buttonStyle(.plain)
}
}
// MARK: - Team With Games Model
struct TeamWithGames: Identifiable {
let team: Team
let sport: Sport
var games: [RichGame]
var id: String { team.id }
var sortedGames: [RichGame] {
games.sorted { $0.game.dateTime < $1.game.dateTime }
}
}
// 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 {
ThemedSpinnerCompact(size: 16)
} 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"
}
}
}
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)
}
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
if filteredAndSortedOptions.isEmpty {
emptyFilterState
.padding(.top, Theme.Spacing.xl)
} else {
ForEach(filteredAndSortedOptions) { 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)
Text(paceFilter.rawValue)
.font(.subheadline)
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) {
ThemedSpinnerCompact(size: 12)
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())
}