// // 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? @State private var endSearchTask: Task? @State private var isSearchingStart = false @State private var isSearchingEnd = false private let locationService = LocationService.shared enum CityInputType { case mustStop case preferred } 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 sportsSection gameBrowserSection case .locations: // Locations + Sports + optional games locationSection sportsSection datesSection gamesSection } // 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) } } } .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?") { Picker("Planning Mode", selection: $viewModel.planningMode) { ForEach(PlanningMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) Text(viewModel.planningMode.description) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) .padding(.top, Theme.Spacing.xs) } } private var locationSection: some View { ThemedSection(title: "Locations") { // Start Location with suggestions VStack(alignment: .leading, spacing: 0) { ThemedTextField( label: "Start Location", placeholder: "Where are you starting from?", text: $viewModel.startLocationText, icon: "location.circle.fill" ) .onChange(of: viewModel.startLocationText) { _, newValue in searchLocation(query: newValue, isStart: true) } // Suggestions for start location if !startLocationSuggestions.isEmpty { locationSuggestionsList( suggestions: startLocationSuggestions, isLoading: isSearchingStart ) { result in viewModel.startLocationText = result.name viewModel.startLocation = result.toLocationInput() startLocationSuggestions = [] } } else if isSearchingStart { HStack { ThemedSpinnerCompact(size: 14) Text("Searching...") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(.top, Theme.Spacing.xs) } } // End Location with suggestions VStack(alignment: .leading, spacing: 0) { ThemedTextField( label: "End Location", placeholder: "Where do you want to end up?", text: $viewModel.endLocationText, icon: "mappin.circle.fill" ) .onChange(of: viewModel.endLocationText) { _, newValue in searchLocation(query: newValue, isStart: false) } // Suggestions for end location if !endLocationSuggestions.isEmpty { locationSuggestionsList( suggestions: endLocationSuggestions, isLoading: isSearchingEnd ) { result in viewModel.endLocationText = result.name viewModel.endLocation = result.toLocationInput() endLocationSuggestions = [] } } else if isSearchingEnd { HStack { ThemedSpinnerCompact(size: 14) Text("Searching...") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(.top, Theme.Spacing.xs) } } } } 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 sportsSection: some View { let sports = Sport.supported let rows = sports.chunked(into: 4) return ThemedSection(title: "Sports") { VStack(spacing: Theme.Spacing.sm) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(spacing: Theme.Spacing.sm) { ForEach(row) { sport in SportSelectionChip( sport: sport, isSelected: viewModel.selectedSports.contains(sport), onTap: { if viewModel.selectedSports.contains(sport) { viewModel.selectedSports.remove(sport) } else { viewModel.selectedSports.insert(sport) } } ) } // Fill remaining space if row has fewer than 4 items if row.count < 4 { ForEach(0..<(4 - row.count), id: \.self) { _ in Color.clear.frame(maxWidth: .infinity) } } } } } .padding(.vertical, Theme.Spacing.xs) } } private var datesSection: some View { ThemedSection(title: "Dates") { DateRangePicker( startDate: $viewModel.startDate, endDate: $viewModel.endDate ) } } 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 { Binding( get: { viewModel.selectedSports.contains(sport) }, set: { isSelected in if isSelected { viewModel.selectedSports.insert(sport) } else { viewModel.selectedSports.remove(sport) } } ) } private func buildGamesDictionary() -> [UUID: 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 @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @State private var expandedSports: Set = [] @State private var expandedTeams: Set = [] // Group games by Sport → Team (home team only to avoid duplicates) private var gamesBySport: [Sport: [TeamWithGames]] { var result: [Sport: [UUID: TeamWithGames]] = [:] for game in games { let sport = game.game.sport let team = game.homeTeam if result[sport] == nil { result[sport] = [:] } if var teamData = result[sport]?[team.id] { teamData.games.append(game) result[sport]?[team.id] = teamData } else { result[sport]?[team.id] = TeamWithGames( team: team, 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 @Binding var expandedSports: Set @Binding var expandedTeams: Set 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 @Binding var expandedTeams: Set @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: UUID { 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? 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 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 .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: [UUID: 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 .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: [UUID: 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() return filter { seen.insert($0).inserted } } } // MARK: - Themed Form Components struct ThemedSection: 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 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 } } 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) } } struct SportSelectionChip: View { let sport: Sport let isSelected: Bool let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme @State private var isPressed = false var body: some View { Button(action: onTap) { VStack(spacing: 6) { ZStack { Circle() .fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15)) .frame(width: 48, height: 48) .overlay { if isSelected { Circle() .stroke(sport.themeColor.opacity(0.3), lineWidth: 3) .frame(width: 54, height: 54) } } Image(systemName: sport.iconName) .font(.title3) .foregroundStyle(isSelected ? .white : sport.themeColor) } Text(sport.rawValue) .font(.system(size: 10, weight: isSelected ? .semibold : .medium)) .foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity) .scaleEffect(isPressed ? 0.9 : 1.0) } .buttonStyle(.plain) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in withAnimation(Theme.Animation.spring) { isPressed = true } } .onEnded { _ in withAnimation(Theme.Animation.spring) { isPressed = false } } ) } } #Preview { TripCreationView(viewModel: TripCreationViewModel()) }