refactor: remove legacy trip creation flow, extract shared components

- Delete TripCreationView.swift and TripCreationViewModel.swift (unused)
- Extract TripOptionsView to standalone file
- Extract DateRangePicker and DayCell to standalone file
- Extract LocationSearchSheet and CityInputType to standalone file
- Fix TripWizardView to pass games dictionary to TripOptionsView
- Remove debug print statements from TripDetailView

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-14 11:02:39 -06:00
parent d034ee8612
commit b5aea31b1a
7 changed files with 1086 additions and 3355 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -384,7 +384,9 @@ struct TripDetailView: View {
let dayStart = calendar.startOfDay(for: date)
let allGameIds = trip.stops.flatMap { $0.games }
return allGameIds.compactMap { games[$0] }.filter { richGame in
let foundGames = allGameIds.compactMap { games[$0] }
return foundGames.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}.sorted { $0.game.dateTime < $1.game.dateTime }
}

View File

@@ -0,0 +1,600 @@
//
// TripOptionsView.swift
// SportsTime
//
// Displays trip options for user selection after planning completes.
//
import SwiftUI
// MARK: - Sort Options
enum TripSortOption: String, CaseIterable, Identifiable {
case recommended = "Recommended"
case mostCities = "Most Cities"
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 .mostCities: return "mappin.and.ellipse"
case .mostGames, .leastGames: return "sportscourt"
case .mostMiles, .leastMiles: return "road.lanes"
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
}
}
}
// MARK: - Pace Filter
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"
}
}
}
// MARK: - Cities Filter
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"
}
}
}
// MARK: - Trip Options Grouper
enum TripOptionsGrouper {
typealias GroupedOptions = (header: String, options: [ItineraryOption])
static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
let grouped = Dictionary(grouping: options) { option in
Set(option.stops.map { $0.city }).count
}
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
return sorted.map { count, opts in
("\(count) \(count == 1 ? "city" : "cities")", opts)
}
}
static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
let grouped = Dictionary(grouping: options) { $0.totalGames }
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
return sorted.map { count, opts in
("\(count) \(count == 1 ? "game" : "games")", opts)
}
}
static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
let ranges: [(min: Int, max: Int, label: String)] = [
(0, 500, "0-500 mi"),
(500, 1000, "500-1000 mi"),
(1000, 1500, "1000-1500 mi"),
(1500, 2000, "1500-2000 mi"),
(2000, Int.max, "2000+ mi")
]
var groupedDict: [String: [ItineraryOption]] = [:]
for option in options {
let miles = Int(option.totalDistanceMiles)
for range in ranges {
if miles >= range.min && miles < range.max {
groupedDict[range.label, default: []].append(option)
break
}
}
}
// Sort by range order
let rangeOrder = ascending ? ranges : ranges.reversed()
return rangeOrder.compactMap { range in
guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil }
return (range.label, opts)
}
}
}
// MARK: - Trip Options View
struct TripOptionsView: View {
let options: [ItineraryOption]
let games: [String: 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 .mostCities:
return filtered.sorted { $0.stops.count > $1.stops.count }
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)
}
private var groupedOptions: [TripOptionsGrouper.GroupedOptions] {
switch sortOption {
case .recommended, .bestEfficiency:
// Flat list, no grouping
return [("", filteredAndSortedOptions)]
case .mostCities:
return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false)
case .mostGames:
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false)
case .leastGames:
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true)
case .mostMiles:
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false)
case .leastMiles:
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true)
}
}
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 (grouped when applicable)
if filteredAndSortedOptions.isEmpty {
emptyFilterState
.padding(.top, Theme.Spacing.xl)
} else {
ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Section header (only if non-empty)
if !group.header.isEmpty {
HStack {
Text(group.header)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(group.options.count)")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.md)
}
// Options in this group
ForEach(group.options) { 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 {
paceFilter = pace
} label: {
Label(pace.rawValue, systemImage: pace.icon)
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: paceFilter.icon)
.font(.caption)
.contentTransition(.identity)
Text(paceFilter.rawValue)
.font(.subheadline)
.contentTransition(.identity)
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: [String: 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) {
LoadingSpinner(size: .small)
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 }
}
}

View File

@@ -0,0 +1,328 @@
//
// DateRangePicker.swift
// SportsTime
//
// Extracted from TripCreationView - reusable date range picker component.
//
import SwiftUI
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
}
.onAppear {
// Initialize displayed month to show the start date's month
displayedMonth = calendar.startOfDay(for: startDate)
// If dates are already selected (endDate > startDate), show complete state
if endDate > startDate {
selectionState = .complete
}
}
}
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)
}
}
// MARK: - Preview
#Preview {
DateRangePicker(
startDate: .constant(Date()),
endDate: .constant(Date().addingTimeInterval(86400 * 7))
)
.padding()
}

View File

@@ -0,0 +1,141 @@
//
// LocationSearchSheet.swift
// SportsTime
//
// Extracted from TripCreationView - location search sheet for adding cities/places.
//
import SwiftUI
// MARK: - City Input Type
enum CityInputType {
case mustStop
case preferred
case homeLocation
case startLocation
case endLocation
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: 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 {
LoadingSpinner(size: .small)
} 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: - Preview
#Preview {
LocationSearchSheet(inputType: .mustStop) { _ in }
}

View File

@@ -13,6 +13,7 @@ struct TripWizardView: View {
@State private var viewModel = TripWizardViewModel()
@State private var showTripOptions = false
@State private var tripOptions: [ItineraryOption] = []
@State private var gamesForDisplay: [String: RichGame] = [:]
@State private var planningError: String?
@State private var showError = false
@@ -91,7 +92,7 @@ struct TripWizardView: View {
.navigationDestination(isPresented: $showTripOptions) {
TripOptionsView(
options: tripOptions,
games: [:],
games: gamesForDisplay,
preferences: buildPreferences(),
convertToTrip: { option in
convertOptionToTrip(option)
@@ -126,6 +127,17 @@ struct TripWizardView: View {
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
// Build RichGame dictionary for display
var richGamesDict: [String: RichGame] = [:]
for game in games {
if let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] {
let richGame = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
richGamesDict[game.id] = richGame
}
}
// Build planning request
let request = PlanningRequest(
preferences: preferences,
@@ -144,6 +156,7 @@ struct TripWizardView: View {
showError = true
} else {
tripOptions = options
gamesForDisplay = richGamesDict
showTripOptions = true
}
case .failure(let failure):