Files
Sportstime/SportsTime/Features/Trip/Views/TripCreationView.swift
Trey t 7efcea7bd4 Add canonical ID pipeline and fix UUID consistency for CloudKit sync
- Add local canonicalization pipeline (stadiums, teams, games) that generates
  deterministic canonical IDs before CloudKit upload
- Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs
  instead of random UUIDs from CloudKit records
- Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve
  canonical ID relationships during sync
- Add canonical ID field keys to CKModels for reading from CloudKit records
- Bundle canonical JSON files (stadiums_canonical, teams_canonical,
  games_canonical, stadium_aliases) for consistent bootstrap data
- Update BootstrapService to prefer canonical format files over legacy format

This ensures all entities use consistent deterministic UUIDs derived from
their canonical IDs, preventing duplicate records when syncing CloudKit
data with bootstrapped local data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

1986 lines
71 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
tripBufferSection
case .locations:
// Locations + Sports + optional games
locationSection
sportsSection
datesSection
gamesSection
}
// Common sections
travelSection
constraintsSection
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(.system(size: 40))
.foregroundStyle(Theme.warmOrange)
Text("Plan Your Adventure")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Select your games, set your route, and hit the road")
.font(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.caption))
.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(.system(size: 14))
VStack(alignment: .leading, spacing: 2) {
Text(result.name)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
if !result.address.isEmpty {
Text(result.address)
.font(.system(size: Theme.FontSize.micro))
.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(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, Theme.Spacing.md)
.task(id: viewModel.selectedSports) {
// Re-run when sports selection changes
if viewModel.availableGames.isEmpty {
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(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(viewModel.availableGames.count) games available")
.font(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
viewModel.deselectAllGames()
} label: {
Text("Deselect All")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.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(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text(game.game.formattedDate)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
if viewModel.selectedGames.count > 3 {
Text("+ \(viewModel.selectedGames.count - 3) more")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
}
}
private var tripBufferSection: some View {
ThemedSection(title: "Trip Duration") {
ThemedStepper(
label: "Buffer Days",
value: viewModel.tripBufferDays,
range: 0...7,
onIncrement: { viewModel.tripBufferDays += 1 },
onDecrement: { viewModel.tripBufferDays -= 1 }
)
if let dateRange = viewModel.gameFirstDateRange {
HStack {
Text("Trip window:")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.warmOrange)
}
}
Text("Days before first game and after last game for travel/rest")
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
private var sportsSection: some View {
ThemedSection(title: "Sports") {
HStack(spacing: Theme.Spacing.sm) {
ForEach(Sport.supported) { 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)
}
}
)
}
}
}
}
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(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(viewModel.selectedGamesCount) selected")
.font(.system(size: Theme.FontSize.caption))
.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) {
// Route preference
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Route Preference")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
Picker("Route Preference", selection: $viewModel.routePreference) {
ForEach(RoutePreference.allCases) { pref in
Text(pref.displayName).tag(pref)
}
}
.pickerStyle(.segmented)
}
}
}
}
private var constraintsSection: some View {
ThemedSection(title: "Trip Style") {
VStack(spacing: Theme.Spacing.md) {
ThemedToggle(
label: "Limit Cities",
isOn: $viewModel.useStopCount,
icon: "mappin.and.ellipse"
)
if viewModel.useStopCount {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
ThemedStepper(
label: "Number of Cities",
value: viewModel.numberOfStops,
range: 1...20,
onIncrement: { viewModel.numberOfStops += 1 },
onDecrement: { viewModel.numberOfStops -= 1 }
)
Text("How many different cities to visit on your trip. More cities = more variety, but more driving between them.")
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Trip Pace")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
Picker("Pace", selection: $viewModel.leisureLevel) {
ForEach(LeisureLevel.allCases) { level in
Text(level.displayName).tag(level)
}
}
.pickerStyle(.segmented)
Text(viewModel.leisureLevel.description)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.top, Theme.Spacing.xxs)
}
}
}
}
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(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(viewModel.mustStopLocations.count)")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
ForEach(viewModel.mustStopLocations, id: \.name) { location in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(location.name)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textPrimary(colorScheme))
if let address = location.address, !address.isEmpty {
Text(address)
.font(.system(size: Theme.FontSize.micro))
.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(.system(size: Theme.FontSize.caption, weight: .medium))
.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(.system(size: Theme.FontSize.caption))
.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 (Team-based selection)
struct GamePickerSheet: View {
let games: [RichGame]
@Binding var selectedIds: Set<UUID>
@Environment(\.dismiss) private var dismiss
// Group games by team (both home and away)
private var teamsList: [TeamWithGames] {
var teamsDict: [UUID: TeamWithGames] = [:]
for game in games {
// Add to home team
if var teamData = teamsDict[game.homeTeam.id] {
teamData.games.append(game)
teamsDict[game.homeTeam.id] = teamData
} else {
teamsDict[game.homeTeam.id] = TeamWithGames(
team: game.homeTeam,
sport: game.game.sport,
games: [game]
)
}
// Add to away team
if var teamData = teamsDict[game.awayTeam.id] {
teamData.games.append(game)
teamsDict[game.awayTeam.id] = teamData
} else {
teamsDict[game.awayTeam.id] = TeamWithGames(
team: game.awayTeam,
sport: game.game.sport,
games: [game]
)
}
}
return teamsDict.values
.sorted { $0.team.name < $1.team.name }
}
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
let grouped = Dictionary(grouping: teamsList) { $0.sport }
return Sport.supported
.filter { grouped[$0] != nil }
.map { sport in
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
}
}
private var selectedGamesCount: Int {
selectedIds.count
}
var body: some View {
NavigationStack {
List {
// Selected games summary
if !selectedIds.isEmpty {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.fontWeight(.medium)
Spacer()
}
}
}
// Teams by sport
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
Section(sportGroup.sport.rawValue) {
ForEach(sportGroup.teams) { teamData in
NavigationLink {
TeamGamesView(
teamData: teamData,
selectedIds: $selectedIds
)
} label: {
TeamRow(teamData: teamData, selectedIds: selectedIds)
}
}
}
}
}
.navigationTitle("Select Teams")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
selectedIds.removeAll()
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// 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: - Team Row
struct TeamRow: View {
let teamData: TeamWithGames
let selectedIds: Set<UUID>
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
HStack(spacing: 12) {
// Team color indicator
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(teamData.games.count) game(s) available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue)
.clipShape(Capsule())
}
}
}
}
// MARK: - Team Games View
struct TeamGamesView: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
var body: some View {
List {
ForEach(teamData.sortedGames) { game in
GamePickerRow(game: game, isSelected: selectedIds.contains(game.id)) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
}
}
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
.navigationBarTitleDisplayMode(.inline)
}
}
struct GamePickerRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport color bar
SportColorBar(sport: game.game.sport)
VStack(alignment: .leading, spacing: 4) {
Text(game.matchupDescription)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(game.venueDescription)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme))
.font(.title2)
}
}
.buttonStyle(.plain)
}
}
struct GameSelectRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport icon
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .blue : .secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption)
.foregroundStyle(.secondary)
Text(game.stadium.city)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: TripCreationView.CityInputType
let onAdd: (LocationInput) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var searchResults: [LocationSearchResult] = []
@State private var isSearching = false
@State private var searchTask: Task<Void, Never>?
private let locationService = LocationService.shared
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
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"
}
}
}
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
@Environment(\.colorScheme) private var colorScheme
private var sortedOptions: [ItineraryOption] {
switch sortOption {
case .recommended:
return options
case .mostGames:
return options.sorted { $0.totalGames > $1.totalGames }
case .leastGames:
return options.sorted { $0.totalGames < $1.totalGames }
case .mostMiles:
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
case .leastMiles:
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
case .bestEfficiency:
// Games per driving hour (higher is better)
return options.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
}
}
}
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
// Hero header
VStack(spacing: 12) {
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
.font(.system(size: 44))
.foregroundStyle(Theme.warmOrange)
Text("\(options.count) Routes Found")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Each route offers a unique adventure")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(.top, Theme.Spacing.xl)
.padding(.bottom, Theme.Spacing.sm)
// Sort picker
sortPicker
.padding(.horizontal, Theme.Spacing.md)
.padding(.bottom, Theme.Spacing.sm)
// Options list
ForEach(sortedOptions) { 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(.system(size: 14))
Text(sortOption.rawValue)
.font(.system(size: 14, weight: .medium))
Image(systemName: "chevron.down")
.font(.system(size: 12))
}
.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: - 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(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(spacing: 0) {
Text("|")
.font(.system(size: 10))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.foregroundStyle(Theme.warmOrange)
Text(uniqueCities.last ?? "")
.font(.system(size: 15, weight: .semibold))
.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(.system(size: 12))
.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(.system(size: 9))
Text("\(item.sport.rawValue.uppercased()) \(item.count)")
.font(.system(size: 9, weight: .semibold))
}
.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(.system(size: 11))
.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(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.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(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: icon)
.foregroundStyle(Theme.warmOrange)
.frame(width: 24)
TextField(placeholder, text: $text)
.font(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.body, weight: .bold))
.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(.system(size: Theme.FontSize.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(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
Text(startDate.formatted(.dateTime.month(.abbreviated).day().year()))
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.warmOrange)
}
.frame(maxWidth: .infinity, alignment: .leading)
// Arrow
Image(systemName: "arrow.right")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
// End date
VStack(alignment: .trailing, spacing: 4) {
Text("END")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.textMuted(colorScheme))
Text(endDate.formatted(.dateTime.month(.abbreviated).day().year()))
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.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(.system(size: 16, weight: .semibold))
.foregroundStyle(Theme.warmOrange)
.frame(width: 36, height: 36)
.background(Theme.warmOrange.opacity(0.15))
.clipShape(Circle())
}
Spacer()
Text(monthYearString)
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
.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(.system(size: 16, weight: .semibold))
.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(.system(size: 12, weight: .semibold))
.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(.system(size: Theme.FontSize.caption, weight: .medium))
.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
var body: some View {
Button(action: onTap) {
VStack(spacing: Theme.Spacing.xs) {
ZStack {
Circle()
.fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .white : sport.themeColor)
}
Text(sport.rawValue)
.font(.system(size: Theme.FontSize.micro, weight: .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
}
#Preview {
TripCreationView(viewModel: TripCreationViewModel())
}