// // TripCreationView.swift // SportsTime // import SwiftUI struct TripCreationView: View { @State private var viewModel = TripCreationViewModel() @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 completedTrip: Trip? enum CityInputType { case mustStop case preferred } var body: some View { NavigationStack { Form { // Planning Mode Selector planningModeSection // Location Permission Banner (only for locations mode) if viewModel.planningMode == .locations && showLocationBanner { Section { LocationPermissionBanner(isPresented: $showLocationBanner) .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } } // 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 { Section { Label(message, systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) } } } .navigationTitle("Plan Your Trip") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Plan") { Task { await viewModel.planTrip() } } .disabled(!viewModel.isFormValid) } } .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: $showTripDetail) { if let trip = completedTrip { TripDetailView(trip: trip, games: buildGamesDictionary()) } } .onChange(of: viewModel.viewState) { _, newState in if case .completed(let trip) = newState { completedTrip = trip showTripDetail = true } } .onChange(of: showTripDetail) { _, isShowing in if !isShowing { // User navigated back, reset to editing state viewModel.viewState = .editing completedTrip = nil } } .task { await viewModel.loadScheduleData() } } } // MARK: - Sections private var planningModeSection: some View { Section { Picker("Planning Mode", selection: $viewModel.planningMode) { ForEach(PlanningMode.allCases) { mode in Label(mode.displayName, systemImage: mode.iconName) .tag(mode) } } .pickerStyle(.segmented) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) Text(viewModel.planningMode.description) .font(.caption) .foregroundStyle(.secondary) } } private var locationSection: some View { Section("Locations") { TextField("Start Location", text: $viewModel.startLocationText) .textContentType(.addressCity) TextField("End Location", text: $viewModel.endLocationText) .textContentType(.addressCity) } } private var gameBrowserSection: some View { Section("Select Games") { if viewModel.isLoadingGames { HStack { ProgressView() Text("Loading games...") .foregroundStyle(.secondary) } } else if viewModel.availableGames.isEmpty { HStack { ProgressView() Text("Loading games...") .foregroundStyle(.secondary) } .task { await viewModel.loadGamesForBrowsing() } } else { Button { showGamePicker = true } label: { HStack { Image(systemName: "sportscourt") .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 2) { Text("Browse Teams & Games") .foregroundStyle(.primary) Text("\(viewModel.availableGames.count) games available") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) } } .buttonStyle(.plain) } // Show selected games summary if !viewModel.mustSeeGameIds.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("\(viewModel.mustSeeGameIds.count) game(s) selected") .fontWeight(.medium) } // Show selected games preview ForEach(viewModel.selectedGames.prefix(3)) { game in HStack(spacing: 8) { Image(systemName: game.game.sport.iconName) .font(.caption) .foregroundStyle(.secondary) Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)") .font(.caption) Spacer() Text(game.game.formattedDate) .font(.caption2) .foregroundStyle(.secondary) } } if viewModel.selectedGames.count > 3 { Text("+ \(viewModel.selectedGames.count - 3) more") .font(.caption) .foregroundStyle(.secondary) } } } } } private var tripBufferSection: some View { Section("Trip Duration") { Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7) if let dateRange = viewModel.gameFirstDateRange { HStack { Text("Trip window:") Spacer() Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))") .foregroundStyle(.secondary) } } Text("Days before first game and after last game for travel/rest") .font(.caption) .foregroundStyle(.secondary) } } private var sportsSection: some View { Section("Sports") { ForEach(Sport.supported) { sport in Toggle(isOn: binding(for: sport)) { Label(sport.rawValue, systemImage: sport.iconName) } } } } private var datesSection: some View { Section("Dates") { DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date) DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date) Text("\(viewModel.tripDurationDays) day trip") .foregroundStyle(.secondary) } } private var gamesSection: some View { Section("Must-See Games") { Button { showGamePicker = true } label: { HStack { Text("Select Games") Spacer() Text("\(viewModel.selectedGamesCount) selected") .foregroundStyle(.secondary) } } } } private var travelSection: some View { Section("Travel") { Picker("Travel Mode", selection: $viewModel.travelMode) { ForEach(TravelMode.allCases) { mode in Label(mode.displayName, systemImage: mode.iconName) .tag(mode) } } Picker("Route Preference", selection: $viewModel.routePreference) { ForEach(RoutePreference.allCases) { pref in Text(pref.displayName).tag(pref) } } } } private var constraintsSection: some View { Section("Trip Style") { Toggle("Use Stop Count", isOn: $viewModel.useStopCount) if viewModel.useStopCount { Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20) } Picker("Pace", selection: $viewModel.leisureLevel) { ForEach(LeisureLevel.allCases) { level in VStack(alignment: .leading) { Text(level.displayName) } .tag(level) } } Text(viewModel.leisureLevel.description) .font(.caption) .foregroundStyle(.secondary) } } private var optionalSection: some View { Section("Optional") { // Must-Stop Locations DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") { ForEach(viewModel.mustStopLocations, id: \.name) { location in HStack { VStack(alignment: .leading) { Text(location.name) if let address = location.address, !address.isEmpty { Text(address) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Button(role: .destructive) { viewModel.removeMustStopLocation(location) } label: { Image(systemName: "minus.circle.fill") } } } Button("Add Location") { cityInputType = .mustStop showCityInput = true } } // EV Charging if viewModel.travelMode == .drive { Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging) } // Lodging Picker("Lodging Type", selection: $viewModel.lodgingType) { ForEach(LodgingType.allCases) { type in Label(type.displayName, systemImage: type.iconName) .tag(type) } } // Drivers if viewModel.travelMode == .drive { Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4) HStack { Text("Max Hours/Driver/Day") Spacer() Text("\(Int(viewModel.maxDrivingHoursPerDriver))h") } Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1) } // Other Sports Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports) } } private var planningOverlay: some View { ZStack { Color.black.opacity(0.4) .ignoresSafeArea() VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) Text("Planning your trip...") .font(.headline) .foregroundStyle(.white) Text("Finding the best route and games") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } .padding(40) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) } } // MARK: - Helpers private func binding(for sport: Sport) -> Binding { Binding( get: { viewModel.selectedSports.contains(sport) }, set: { isSelected in if isSelected { viewModel.selectedSports.insert(sport) } else { viewModel.selectedSports.remove(sport) } } ) } private func buildGamesDictionary() -> [UUID: RichGame] { Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) }) } } // 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 @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 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) ?? .gray) .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 var body: some View { List { ForEach(teamData.sortedGames) { game in GameRow(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 GameRow: View { let game: RichGame let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack { VStack(alignment: .leading, spacing: 4) { Text(game.matchupDescription) .font(.headline) Text(game.venueDescription) .font(.caption) .foregroundStyle(.secondary) Text("\(game.game.formattedDate) • \(game.game.gameTime)") .font(.caption2) .foregroundStyle(.secondary) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? .blue : .gray) .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? 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 { ProgressView() .scaleEffect(0.8) } 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 } } #Preview { TripCreationView() }