Files
Sportstime/SportsTime/Features/Trip/Views/TripCreationView.swift
Trey t 475f444288 refactor: extract reusable SportSelectorGrid component
Create unified sport selector grid used across Home (Quick Start),
Trip Creation, and Progress views. Removes duplicate button implementations
and ensures consistent grid layout with centered bottom row.

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

2211 lines
78 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
}
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 {
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
)
}
}
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() -> [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<UUID>
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var expandedSports: Set<Sport> = []
@State private var expandedTeams: Set<UUID> = []
// 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<UUID>
@Binding var expandedSports: Set<Sport>
@Binding var expandedTeams: Set<UUID>
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<UUID>
@Binding var expandedTeams: Set<UUID>
@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<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 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<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
}
}
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)
}
}
#Preview {
TripCreationView(viewModel: TripCreationViewModel())
}