Add region-based filtering and route length diversity
- Add RegionMapSelector UI for geographic trip filtering (East/Central/West) - Add RouteFilters module for allowRepeatCities preference - Improve GameDAGRouter to preserve route length diversity - Routes now grouped by city count before scoring - Ensures 2-city trips appear alongside longer trips - Increased beam width and max options for better coverage - Add TripOptionsView filters (max cities slider, pace filter) - Remove TravelStyle section from trip creation (replaced by region selector) - Clean up debug logging from DataProvider and ScenarioAPlanner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,10 +26,6 @@ final class SettingsViewModel {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
var maxTripOptions: Int {
|
||||
didSet { savePreferences() }
|
||||
}
|
||||
|
||||
// MARK: - Sync State
|
||||
|
||||
private(set) var isSyncing = false
|
||||
@@ -61,9 +57,6 @@ final class SettingsViewModel {
|
||||
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
|
||||
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
|
||||
|
||||
let savedMaxTripOptions = defaults.integer(forKey: "maxTripOptions")
|
||||
self.maxTripOptions = savedMaxTripOptions == 0 ? 10 : savedMaxTripOptions
|
||||
|
||||
// Last sync
|
||||
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
|
||||
|
||||
@@ -101,7 +94,6 @@ final class SettingsViewModel {
|
||||
selectedTheme = .teal
|
||||
selectedSports = Set(Sport.supported)
|
||||
maxDrivingHoursPerDay = 8
|
||||
maxTripOptions = 10
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
@@ -110,6 +102,5 @@ final class SettingsViewModel {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
|
||||
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
|
||||
defaults.set(maxTripOptions, forKey: "maxTripOptions")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,22 +141,6 @@ struct SettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Trip Options to Show")
|
||||
Spacer()
|
||||
Text("\(viewModel.maxTripOptions)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(viewModel.maxTripOptions) },
|
||||
set: { viewModel.maxTripOptions = Int($0) }
|
||||
),
|
||||
in: 1...20,
|
||||
step: 1
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Travel Preferences")
|
||||
} footer: {
|
||||
|
||||
@@ -86,6 +86,10 @@ final class TripCreationViewModel {
|
||||
var numberOfDrivers: Int = 1
|
||||
var maxDrivingHoursPerDriver: Double = 8
|
||||
|
||||
// Travel Preferences
|
||||
var allowRepeatCities: Bool = true
|
||||
var selectedRegions: Set<Region> = [.east, .central, .west]
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
@@ -273,10 +277,6 @@ final class TripCreationViewModel {
|
||||
await loadScheduleData()
|
||||
}
|
||||
|
||||
// Read max trip options from settings (default 10)
|
||||
let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions")
|
||||
let maxTripOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10
|
||||
|
||||
// Build preferences
|
||||
let preferences = TripPreferences(
|
||||
planningMode: planningMode,
|
||||
@@ -297,7 +297,8 @@ final class TripCreationViewModel {
|
||||
lodgingType: lodgingType,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
maxTripOptions: maxTripOptions
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
selectedRegions: selectedRegions
|
||||
)
|
||||
|
||||
// Build planning request
|
||||
@@ -451,6 +452,17 @@ final class TripCreationViewModel {
|
||||
availableGames = []
|
||||
isLoadingGames = false
|
||||
currentPreferences = nil
|
||||
allowRepeatCities = true
|
||||
selectedRegions = [.east, .central, .west]
|
||||
}
|
||||
|
||||
/// Toggles region selection. Any combination is allowed.
|
||||
func toggleRegion(_ region: Region) {
|
||||
if selectedRegions.contains(region) {
|
||||
selectedRegions.remove(region)
|
||||
} else {
|
||||
selectedRegions.insert(region)
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a specific itinerary option and navigate to its detail
|
||||
@@ -465,9 +477,6 @@ final class TripCreationViewModel {
|
||||
|
||||
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
|
||||
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
||||
let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions")
|
||||
let maxOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10
|
||||
|
||||
let preferences = currentPreferences ?? TripPreferences(
|
||||
planningMode: planningMode,
|
||||
startLocation: nil,
|
||||
@@ -487,7 +496,8 @@ final class TripCreationViewModel {
|
||||
lodgingType: lodgingType,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
maxTripOptions: maxOptions
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
selectedRegions: selectedRegions
|
||||
)
|
||||
return convertToTrip(option: option, preferences: preferences)
|
||||
}
|
||||
|
||||
213
SportsTime/Features/Trip/Views/RegionMapSelector.swift
Normal file
213
SportsTime/Features/Trip/Views/RegionMapSelector.swift
Normal file
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// RegionMapSelector.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Interactive map for selecting travel regions.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A map-based selector for choosing geographic regions.
|
||||
/// Shows North America with three selectable zones: West, Central, East.
|
||||
///
|
||||
/// Selection rules:
|
||||
/// - Can select: East, Central, West, East+Central, Central+West
|
||||
/// - Cannot select: East+West (must have Central between them)
|
||||
struct RegionMapSelector: View {
|
||||
@Binding var selectedRegions: Set<Region>
|
||||
let onToggle: (Region) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Map with regions
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background map outline
|
||||
mapBackground
|
||||
|
||||
// Selectable regions
|
||||
HStack(spacing: 0) {
|
||||
regionButton(.west)
|
||||
regionButton(.central)
|
||||
regionButton(.east)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.textMuted(colorScheme).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Legend
|
||||
selectionLegend
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map Background
|
||||
|
||||
private var mapBackground: some View {
|
||||
ZStack {
|
||||
// Simple gradient background representing land
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.green.opacity(0.15),
|
||||
Color.green.opacity(0.1),
|
||||
Color.green.opacity(0.15)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
|
||||
// Subtle grid lines for visual separation
|
||||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
Rectangle()
|
||||
.fill(Theme.textMuted(colorScheme).opacity(0.3))
|
||||
.frame(width: 1)
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
Rectangle()
|
||||
.fill(Theme.textMuted(colorScheme).opacity(0.3))
|
||||
.frame(width: 1)
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Region Button
|
||||
|
||||
private func regionButton(_ region: Region) -> some View {
|
||||
let isSelected = selectedRegions.contains(region)
|
||||
let isDisabled = isRegionDisabled(region)
|
||||
|
||||
return Button {
|
||||
onToggle(region)
|
||||
} label: {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
// Region icon
|
||||
Image(systemName: iconForRegion(region))
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textSecondary(colorScheme))
|
||||
|
||||
// Region name
|
||||
Text(region.shortName)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||
|
||||
// Cities hint
|
||||
Text(citiesForRegion(region))
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(isSelected ? .white.opacity(0.8) : Theme.textMuted(colorScheme))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
isSelected
|
||||
? regionColor(region)
|
||||
: Color.clear
|
||||
)
|
||||
.opacity(isDisabled ? 0.4 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDisabled)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func iconForRegion(_ region: Region) -> String {
|
||||
switch region {
|
||||
case .west: return "sun.max.fill"
|
||||
case .central: return "building.2.fill"
|
||||
case .east: return "building.columns.fill"
|
||||
case .crossCountry: return "arrow.left.arrow.right"
|
||||
}
|
||||
}
|
||||
|
||||
private func citiesForRegion(_ region: Region) -> String {
|
||||
switch region {
|
||||
case .west: return "LA, SF, Seattle"
|
||||
case .central: return "Chicago, Houston, Denver"
|
||||
case .east: return "NYC, Boston, Miami"
|
||||
case .crossCountry: return ""
|
||||
}
|
||||
}
|
||||
|
||||
private func regionColor(_ region: Region) -> Color {
|
||||
switch region {
|
||||
case .west: return .orange
|
||||
case .central: return .blue
|
||||
case .east: return .green
|
||||
case .crossCountry: return .purple
|
||||
}
|
||||
}
|
||||
|
||||
/// East and West cannot both be selected (not adjacent)
|
||||
private func isRegionDisabled(_ region: Region) -> Bool {
|
||||
// If trying to show East+West as disabled, we handle that in toggle logic instead
|
||||
// This is for visual indication only
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Legend
|
||||
|
||||
private var selectionLegend: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
if selectedRegions.isEmpty {
|
||||
Text("Tap regions to select")
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
} else {
|
||||
Text("Selected: \(selectedRegions.map { $0.shortName }.sorted().joined(separator: " + "))")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
selectedRegions.removeAll()
|
||||
} label: {
|
||||
Text("Clear")
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
struct PreviewWrapper: View {
|
||||
@State private var selected: Set<Region> = [.central]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
RegionMapSelector(selectedRegions: $selected) { region in
|
||||
if selected.contains(region) {
|
||||
selected.remove(region)
|
||||
} else {
|
||||
// Adjacency rule
|
||||
if region == .east {
|
||||
selected.remove(.west)
|
||||
} else if region == .west {
|
||||
selected.remove(.east)
|
||||
}
|
||||
selected.insert(region)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Text("Selected: \(selected.map { $0.shortName }.joined(separator: ", "))")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
return PreviewWrapper()
|
||||
}
|
||||
@@ -78,7 +78,6 @@ struct TripCreationView: View {
|
||||
|
||||
// Common sections
|
||||
travelSection
|
||||
constraintsSection
|
||||
optionalSection
|
||||
|
||||
// Validation message
|
||||
@@ -591,6 +590,32 @@ struct TripCreationView: View {
|
||||
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(.system(size: Theme.FontSize.caption))
|
||||
.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(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.padding(.top, Theme.Spacing.xxs)
|
||||
} else {
|
||||
Text("Games will be found in selected regions")
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.padding(.top, Theme.Spacing.xxs)
|
||||
}
|
||||
}
|
||||
|
||||
// Route preference
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Route Preference")
|
||||
@@ -604,53 +629,22 @@ struct TripCreationView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var constraintsSection: some View {
|
||||
ThemedSection(title: "Trip Style") {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Allow repeat cities
|
||||
ThemedToggle(
|
||||
label: "Limit Cities",
|
||||
isOn: $viewModel.useStopCount,
|
||||
icon: "mappin.and.ellipse"
|
||||
label: "Allow Repeat Cities",
|
||||
isOn: $viewModel.allowRepeatCities,
|
||||
icon: "arrow.triangle.2.circlepath"
|
||||
)
|
||||
|
||||
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)
|
||||
if !viewModel.allowRepeatCities {
|
||||
Text("Each city will only be visited on one day")
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.padding(.top, Theme.Spacing.xxs)
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: viewModel.selectedRegions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,6 +1214,48 @@ enum TripSortOption: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -1229,23 +1265,55 @@ struct TripOptionsView: View {
|
||||
@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
|
||||
|
||||
private var sortedOptions: [ItineraryOption] {
|
||||
// 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 options
|
||||
return filtered
|
||||
case .mostGames:
|
||||
return options.sorted { $0.totalGames > $1.totalGames }
|
||||
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||
case .leastGames:
|
||||
return options.sorted { $0.totalGames < $1.totalGames }
|
||||
return filtered.sorted { $0.totalGames < $1.totalGames }
|
||||
case .mostMiles:
|
||||
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||
return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||
case .leastMiles:
|
||||
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||
return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||
case .bestEfficiency:
|
||||
// Games per driving hour (higher is better)
|
||||
return options.sorted {
|
||||
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
|
||||
@@ -1253,42 +1321,48 @@ struct TripOptionsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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: 20) {
|
||||
LazyVStack(spacing: 16) {
|
||||
// Hero header
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||
.font(.system(size: 44))
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text("\(options.count) Routes Found")
|
||||
Text("\(filteredAndSortedOptions.count) of \(options.count) Routes")
|
||||
.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)
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
// Sort picker
|
||||
sortPicker
|
||||
// Filters section
|
||||
filtersSection
|
||||
.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)
|
||||
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)
|
||||
@@ -1337,6 +1411,115 @@ struct TripOptionsView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(.system(size: 12))
|
||||
Text(paceFilter.rawValue)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.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(.system(size: 13, weight: .medium))
|
||||
.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(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
citiesFilter = .noLimit
|
||||
paceFilter = .all
|
||||
}
|
||||
} label: {
|
||||
Text("Reset Filters")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.xxl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Option Card
|
||||
|
||||
Reference in New Issue
Block a user