feat(wizard): add mode-specific trip wizard inputs
- Add GamePickerStep with sheet-based Sport → Team → Game selection - Add TeamPickerStep with sheet-based Sport → Team selection - Add LocationsStep for start/end location selection with round trip toggle - Update TripWizardViewModel with mode-specific fields and validation - Update TripWizardView with conditional step rendering per mode - Update ReviewStep with mode-aware validation display - Fix gameFirst mode to derive date range from selected games Each planning mode now shows only relevant steps: - By Dates: Dates → Sports → Regions → Route → Repeat → Must Stops - By Games: Game Picker → Route → Repeat → Must Stops - By Route: Locations → Dates → Sports → Route → Repeat → Must Stops - Follow Team: Team Picker → Dates → Route → Repeat → Must Stops Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,22 @@ final class TripWizardViewModel {
|
||||
|
||||
var mustStopLocations: [LocationInput] = []
|
||||
|
||||
// MARK: - Mode-Specific: gameFirst (cascading selection)
|
||||
|
||||
var gamePickerSports: Set<Sport> = []
|
||||
var gamePickerTeamIds: Set<String> = []
|
||||
var selectedGameIds: Set<String> = []
|
||||
|
||||
// MARK: - Mode-Specific: followTeam
|
||||
|
||||
var selectedTeamId: String? = nil
|
||||
var teamPickerSport: Sport? = nil
|
||||
|
||||
// MARK: - Mode-Specific: locations
|
||||
|
||||
var startLocation: LocationInput? = nil
|
||||
var endLocation: LocationInput? = nil
|
||||
|
||||
// MARK: - Planning State
|
||||
|
||||
var isPlanning: Bool = false
|
||||
@@ -65,26 +81,65 @@ final class TripWizardViewModel {
|
||||
planningMode != nil
|
||||
}
|
||||
|
||||
/// Mode-specific step visibility
|
||||
var showDatesStep: Bool {
|
||||
planningMode == .dateRange || planningMode == .followTeam || planningMode == .locations
|
||||
}
|
||||
|
||||
var showSportsStep: Bool {
|
||||
planningMode == .dateRange || planningMode == .locations
|
||||
}
|
||||
|
||||
var showRegionsStep: Bool {
|
||||
planningMode == .dateRange
|
||||
}
|
||||
|
||||
var showGamePickerStep: Bool {
|
||||
planningMode == .gameFirst
|
||||
}
|
||||
|
||||
var showTeamPickerStep: Bool {
|
||||
planningMode == .followTeam
|
||||
}
|
||||
|
||||
var showLocationsStep: Bool {
|
||||
planningMode == .locations
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// All required fields must be set before planning
|
||||
var canPlanTrip: Bool {
|
||||
planningMode != nil &&
|
||||
hasSetDates &&
|
||||
!selectedSports.isEmpty &&
|
||||
!selectedRegions.isEmpty &&
|
||||
hasSetRoutePreference &&
|
||||
hasSetRepeatCities
|
||||
guard let mode = planningMode else { return false }
|
||||
|
||||
// Common requirements for all modes
|
||||
guard hasSetRoutePreference && hasSetRepeatCities else { return false }
|
||||
|
||||
switch mode {
|
||||
case .dateRange:
|
||||
return hasSetDates && !selectedSports.isEmpty && !selectedRegions.isEmpty
|
||||
case .gameFirst:
|
||||
return !selectedGameIds.isEmpty
|
||||
case .locations:
|
||||
return startLocation != nil && endLocation != nil && hasSetDates && !selectedSports.isEmpty
|
||||
case .followTeam:
|
||||
return selectedTeamId != nil && hasSetDates
|
||||
}
|
||||
}
|
||||
|
||||
/// Field validation for the review step - shows which fields are missing
|
||||
var fieldValidation: FieldValidation {
|
||||
FieldValidation(
|
||||
planningMode: planningMode,
|
||||
sports: selectedSports.isEmpty ? .missing : .valid,
|
||||
dates: hasSetDates ? .valid : .missing,
|
||||
regions: selectedRegions.isEmpty ? .missing : .valid,
|
||||
routePreference: hasSetRoutePreference ? .valid : .missing,
|
||||
repeatCities: hasSetRepeatCities ? .valid : .missing
|
||||
repeatCities: hasSetRepeatCities ? .valid : .missing,
|
||||
selectedGames: selectedGameIds.isEmpty ? .missing : .valid,
|
||||
selectedTeam: selectedTeamId == nil ? .missing : .valid,
|
||||
startLocation: startLocation == nil ? .missing : .valid,
|
||||
endLocation: endLocation == nil ? .missing : .valid
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,12 +178,26 @@ final class TripWizardViewModel {
|
||||
// MARK: - Reset Logic
|
||||
|
||||
private func resetAllSelections() {
|
||||
// Common fields
|
||||
selectedSports = []
|
||||
hasSetDates = false
|
||||
selectedRegions = []
|
||||
hasSetRoutePreference = false
|
||||
hasSetRepeatCities = false
|
||||
mustStopLocations = []
|
||||
|
||||
// gameFirst mode fields
|
||||
gamePickerSports = []
|
||||
gamePickerTeamIds = []
|
||||
selectedGameIds = []
|
||||
|
||||
// followTeam mode fields
|
||||
selectedTeamId = nil
|
||||
teamPickerSport = nil
|
||||
|
||||
// locations mode fields
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +209,70 @@ struct FieldValidation {
|
||||
case missing
|
||||
}
|
||||
|
||||
let planningMode: PlanningMode?
|
||||
|
||||
// Common fields
|
||||
let sports: Status
|
||||
let dates: Status
|
||||
let regions: Status
|
||||
let routePreference: Status
|
||||
let repeatCities: Status
|
||||
|
||||
// Mode-specific fields
|
||||
let selectedGames: Status
|
||||
let selectedTeam: Status
|
||||
let startLocation: Status
|
||||
let endLocation: Status
|
||||
|
||||
/// Returns only the fields that are required for the current planning mode
|
||||
var requiredFields: [(name: String, status: Status)] {
|
||||
var fields: [(String, Status)] = []
|
||||
|
||||
guard let mode = planningMode else { return fields }
|
||||
|
||||
switch mode {
|
||||
case .dateRange:
|
||||
fields = [
|
||||
("Dates", dates),
|
||||
("Sports", sports),
|
||||
("Regions", regions),
|
||||
("Route Preference", routePreference),
|
||||
("Repeat Cities", repeatCities)
|
||||
]
|
||||
case .gameFirst:
|
||||
fields = [
|
||||
("Games", selectedGames),
|
||||
("Route Preference", routePreference),
|
||||
("Repeat Cities", repeatCities)
|
||||
]
|
||||
case .locations:
|
||||
fields = [
|
||||
("Start Location", startLocation),
|
||||
("End Location", endLocation),
|
||||
("Dates", dates),
|
||||
("Sports", sports),
|
||||
("Route Preference", routePreference),
|
||||
("Repeat Cities", repeatCities)
|
||||
]
|
||||
case .followTeam:
|
||||
fields = [
|
||||
("Team", selectedTeam),
|
||||
("Dates", dates),
|
||||
("Route Preference", routePreference),
|
||||
("Repeat Cities", repeatCities)
|
||||
]
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/// Returns only the missing required fields for the current mode
|
||||
var missingFields: [String] {
|
||||
requiredFields.filter { $0.status == .missing }.map { $0.name }
|
||||
}
|
||||
|
||||
/// Whether all required fields for the current mode are valid
|
||||
var allRequiredFieldsValid: Bool {
|
||||
requiredFields.allSatisfy { $0.status == .valid }
|
||||
}
|
||||
}
|
||||
|
||||
524
SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Normal file
524
SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Normal file
@@ -0,0 +1,524 @@
|
||||
//
|
||||
// GamePickerStep.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Game selection step for "By Games" planning mode.
|
||||
// Uses sheet-based drill-down: Sports → Teams → Games.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GamePickerStep: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var selectedSports: Set<Sport>
|
||||
@Binding var selectedTeamIds: Set<String>
|
||||
@Binding var selectedGameIds: Set<String>
|
||||
|
||||
@State private var showSportsPicker = false
|
||||
@State private var showTeamsPicker = false
|
||||
@State private var showGamesPicker = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
StepHeader(
|
||||
title: "Select games for your trip",
|
||||
subtitle: "Pick sports, then teams, then games"
|
||||
)
|
||||
|
||||
// Step 1: Sports Selection
|
||||
selectionRow(
|
||||
icon: "sportscourt.fill",
|
||||
label: "Sports",
|
||||
value: selectedSports.isEmpty ? nil : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
|
||||
placeholder: "Select sports",
|
||||
onTap: { showSportsPicker = true },
|
||||
onClear: {
|
||||
selectedSports = []
|
||||
selectedTeamIds = []
|
||||
selectedGameIds = []
|
||||
}
|
||||
)
|
||||
|
||||
// Step 2: Teams Selection (enabled after sports)
|
||||
selectionRow(
|
||||
icon: "person.2.fill",
|
||||
label: "Teams",
|
||||
value: selectedTeamIds.isEmpty ? nil : "\(selectedTeamIds.count) team\(selectedTeamIds.count == 1 ? "" : "s")",
|
||||
placeholder: "Select teams",
|
||||
isEnabled: !selectedSports.isEmpty,
|
||||
onTap: { showTeamsPicker = true },
|
||||
onClear: {
|
||||
selectedTeamIds = []
|
||||
selectedGameIds = []
|
||||
}
|
||||
)
|
||||
|
||||
// Step 3: Games Selection (enabled after teams)
|
||||
selectionRow(
|
||||
icon: "ticket.fill",
|
||||
label: "Games",
|
||||
value: selectedGameIds.isEmpty ? nil : "\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s")",
|
||||
placeholder: "Select games",
|
||||
isEnabled: !selectedTeamIds.isEmpty,
|
||||
onTap: { showGamesPicker = true },
|
||||
onClear: { selectedGameIds = [] }
|
||||
)
|
||||
|
||||
// Selected Games Summary
|
||||
if !selectedGameIds.isEmpty {
|
||||
selectedGamesSummary
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.sheet(isPresented: $showSportsPicker) {
|
||||
SportsPickerSheet(selectedSports: $selectedSports) {
|
||||
// Clear downstream when sports change
|
||||
selectedTeamIds = []
|
||||
selectedGameIds = []
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showTeamsPicker) {
|
||||
TeamsPickerSheet(
|
||||
selectedSports: selectedSports,
|
||||
selectedTeamIds: $selectedTeamIds
|
||||
) {
|
||||
// Clear games when teams change
|
||||
selectedGameIds = []
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showGamesPicker) {
|
||||
GamesPickerSheet(
|
||||
selectedTeamIds: selectedTeamIds,
|
||||
selectedGameIds: $selectedGameIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Row
|
||||
|
||||
private func selectionRow(
|
||||
icon: String,
|
||||
label: String,
|
||||
value: String?,
|
||||
placeholder: String,
|
||||
isEnabled: Bool = true,
|
||||
onTap: @escaping () -> Void,
|
||||
onClear: @escaping () -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Button {
|
||||
if isEnabled { onTap() }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
||||
|
||||
if let value = value {
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onClear) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
} else {
|
||||
Text(placeholder)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(value != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: value != nil ? 2 : 1)
|
||||
)
|
||||
.opacity(isEnabled ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selected Games Summary
|
||||
|
||||
@State private var summaryGames: [RichGame] = []
|
||||
|
||||
private var selectedGamesSummary: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
ForEach(summaryGames.filter { selectedGameIds.contains($0.id) }) { game in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(game.matchupDescription)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("\(game.stadium.city) • \(game.game.dateTime, style: .date)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
selectedGameIds.remove(game.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.warmOrange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.task(id: selectedGameIds) {
|
||||
await loadSummaryGames()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSummaryGames() async {
|
||||
var games: [RichGame] = []
|
||||
for teamId in selectedTeamIds {
|
||||
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
|
||||
games.append(contentsOf: teamGames)
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
summaryGames = Array(Set(games))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sports Picker Sheet
|
||||
|
||||
private struct SportsPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var selectedSports: Set<Sport>
|
||||
let onChanged: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(Sport.supported, id: \.self) { sport in
|
||||
Button {
|
||||
if selectedSports.contains(sport) {
|
||||
selectedSports.remove(sport)
|
||||
} else {
|
||||
selectedSports.insert(sport)
|
||||
}
|
||||
onChanged()
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(sport.themeColor)
|
||||
.frame(width: 32)
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedSports.contains(sport) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Select Sports")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Teams Picker Sheet
|
||||
|
||||
private struct TeamsPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let selectedSports: Set<Sport>
|
||||
@Binding var selectedTeamIds: Set<String>
|
||||
let onChanged: () -> Void
|
||||
|
||||
@State private var searchText = ""
|
||||
|
||||
private var teams: [Team] {
|
||||
let allTeams = AppDataProvider.shared.teams
|
||||
.filter { selectedSports.contains($0.sport) }
|
||||
.sorted { $0.fullName < $1.fullName }
|
||||
|
||||
if searchText.isEmpty {
|
||||
return allTeams
|
||||
}
|
||||
|
||||
return allTeams.filter {
|
||||
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
private var groupedTeams: [(Sport, [Team])] {
|
||||
let grouped = Dictionary(grouping: teams) { $0.sport }
|
||||
return selectedSports.sorted { $0.rawValue < $1.rawValue }
|
||||
.compactMap { sport in
|
||||
guard let sportTeams = grouped[sport], !sportTeams.isEmpty else { return nil }
|
||||
return (sport, sportTeams)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(groupedTeams, id: \.0) { sport, sportTeams in
|
||||
Section {
|
||||
ForEach(sportTeams) { team in
|
||||
Button {
|
||||
if selectedTeamIds.contains(team.id) {
|
||||
selectedTeamIds.remove(team.id)
|
||||
} else {
|
||||
selectedTeamIds.insert(team.id)
|
||||
}
|
||||
onChanged()
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Circle()
|
||||
.fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(team.fullName)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(team.city)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedTeamIds.contains(team.id) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: sport.iconName)
|
||||
.foregroundStyle(sport.themeColor)
|
||||
Text(sport.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(text: $searchText, prompt: "Search teams")
|
||||
.navigationTitle("Select Teams")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Games Picker Sheet
|
||||
|
||||
private struct GamesPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let selectedTeamIds: Set<String>
|
||||
@Binding var selectedGameIds: Set<String>
|
||||
|
||||
@State private var games: [RichGame] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
private var groupedGames: [(Date, [RichGame])] {
|
||||
let grouped = Dictionary(grouping: games) { game in
|
||||
Calendar.current.startOfDay(for: game.game.dateTime)
|
||||
}
|
||||
return grouped.keys.sorted().map { date in
|
||||
(date, grouped[date] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading games...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if games.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Games Found",
|
||||
systemImage: "ticket",
|
||||
description: Text("No upcoming games found for the selected teams")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(groupedGames, id: \.0) { date, dateGames in
|
||||
Section {
|
||||
ForEach(dateGames) { game in
|
||||
Button {
|
||||
if selectedGameIds.contains(game.id) {
|
||||
selectedGameIds.remove(game.id)
|
||||
} else {
|
||||
selectedGameIds.insert(game.id)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: game.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(game.game.sport.themeColor)
|
||||
|
||||
Text(game.matchupDescription)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
Text("\(game.stadium.name) • \(game.localGameTimeShort)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedGameIds.contains(game.id) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
Text(date, style: .date)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Games")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.task {
|
||||
await loadGames()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadGames() async {
|
||||
var allGames: [RichGame] = []
|
||||
for teamId in selectedTeamIds {
|
||||
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
|
||||
let futureGames = teamGames.filter { $0.game.dateTime > Date() }
|
||||
allGames.append(contentsOf: futureGames)
|
||||
}
|
||||
}
|
||||
|
||||
let uniqueGames = Array(Set(allGames)).sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
await MainActor.run {
|
||||
games = uniqueGames
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
GamePickerStep(
|
||||
selectedSports: .constant([.mlb]),
|
||||
selectedTeamIds: .constant([]),
|
||||
selectedGameIds: .constant([])
|
||||
)
|
||||
.padding()
|
||||
.themedBackground()
|
||||
}
|
||||
@@ -31,6 +31,16 @@ struct LocationSearchSheet: View {
|
||||
|
||||
private let locationService = LocationService.shared
|
||||
|
||||
private var navigationTitle: String {
|
||||
switch inputType {
|
||||
case .mustStop: return "Add Must-Stop"
|
||||
case .preferred: return "Add Preferred Location"
|
||||
case .homeLocation: return "Set Home Location"
|
||||
case .startLocation: return "Set Start Location"
|
||||
case .endLocation: return "Set End Location"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
@@ -96,7 +106,7 @@ struct LocationSearchSheet: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
||||
.navigationTitle(navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
175
SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift
Normal file
175
SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// LocationsStep.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Start and end location selection for "By Route" planning mode.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsStep: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var startLocation: LocationInput?
|
||||
@Binding var endLocation: LocationInput?
|
||||
|
||||
@State private var showStartLocationSearch = false
|
||||
@State private var showEndLocationSearch = false
|
||||
@State private var isRoundTrip = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
StepHeader(
|
||||
title: "Where are you traveling?",
|
||||
subtitle: "Set your start and end points"
|
||||
)
|
||||
|
||||
// Start Location
|
||||
locationRow(
|
||||
label: "Starting from",
|
||||
location: startLocation,
|
||||
placeholder: "Select start city",
|
||||
onTap: { showStartLocationSearch = true },
|
||||
onClear: { startLocation = nil }
|
||||
)
|
||||
|
||||
// End Location
|
||||
if !isRoundTrip {
|
||||
locationRow(
|
||||
label: "Ending at",
|
||||
location: endLocation,
|
||||
placeholder: "Select end city",
|
||||
onTap: { showEndLocationSearch = true },
|
||||
onClear: { endLocation = nil }
|
||||
)
|
||||
}
|
||||
|
||||
// Round Trip Toggle
|
||||
Toggle(isOn: $isRoundTrip) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("Round trip (return to start)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: Theme.warmOrange))
|
||||
.onChange(of: isRoundTrip) { _, newValue in
|
||||
if newValue {
|
||||
endLocation = startLocation
|
||||
} else {
|
||||
endLocation = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: startLocation) { _, newValue in
|
||||
if isRoundTrip {
|
||||
endLocation = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.sheet(isPresented: $showStartLocationSearch) {
|
||||
LocationSearchSheet(inputType: .startLocation) { location in
|
||||
startLocation = location
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEndLocationSearch) {
|
||||
LocationSearchSheet(inputType: .endLocation) { location in
|
||||
endLocation = location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location Row
|
||||
|
||||
private func locationRow(
|
||||
label: String,
|
||||
location: LocationInput?,
|
||||
placeholder: String,
|
||||
onTap: @escaping () -> Void,
|
||||
onClear: @escaping () -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if let location = location {
|
||||
// Selected location
|
||||
HStack {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(location.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
if let address = location.address, !address.isEmpty {
|
||||
Text(address)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onClear) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
} else {
|
||||
// Empty state - tap to add
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text(placeholder)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
LocationsStep(
|
||||
startLocation: .constant(nil),
|
||||
endLocation: .constant(nil)
|
||||
)
|
||||
.padding()
|
||||
.themedBackground()
|
||||
}
|
||||
@@ -22,6 +22,12 @@ struct ReviewStep: View {
|
||||
let fieldValidation: FieldValidation
|
||||
let onPlan: () -> Void
|
||||
|
||||
// Mode-specific display values (passed from parent)
|
||||
var selectedGameCount: Int = 0
|
||||
var selectedTeamName: String? = nil
|
||||
var startLocationName: String? = nil
|
||||
var endLocationName: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
StepHeader(
|
||||
@@ -30,33 +36,19 @@ struct ReviewStep: View {
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Mode (always shown)
|
||||
ReviewRow(label: "Mode", value: planningMode.displayName)
|
||||
ReviewRow(
|
||||
label: "Sports",
|
||||
value: selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
|
||||
isMissing: fieldValidation.sports == .missing
|
||||
)
|
||||
ReviewRow(
|
||||
label: "Dates",
|
||||
value: dateRangeText,
|
||||
isMissing: fieldValidation.dates == .missing
|
||||
)
|
||||
ReviewRow(
|
||||
label: "Regions",
|
||||
value: selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", "),
|
||||
isMissing: fieldValidation.regions == .missing
|
||||
)
|
||||
ReviewRow(
|
||||
label: "Route",
|
||||
value: routePreference.displayName,
|
||||
isMissing: fieldValidation.routePreference == .missing
|
||||
)
|
||||
ReviewRow(
|
||||
label: "Repeat cities",
|
||||
value: allowRepeatCities ? "Yes" : "No",
|
||||
isMissing: fieldValidation.repeatCities == .missing
|
||||
)
|
||||
|
||||
// Mode-specific required fields
|
||||
ForEach(fieldValidation.requiredFields, id: \.name) { field in
|
||||
ReviewRow(
|
||||
label: field.name,
|
||||
value: displayValue(for: field.name),
|
||||
isMissing: field.status == .missing
|
||||
)
|
||||
}
|
||||
|
||||
// Optional: Must-stops (shown if any selected)
|
||||
if !mustStopLocations.isEmpty {
|
||||
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
||||
}
|
||||
@@ -65,6 +57,17 @@ struct ReviewStep: View {
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
|
||||
// Missing fields warning
|
||||
if !fieldValidation.missingFields.isEmpty {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Complete all required fields to continue")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onPlan) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
if isPlanning {
|
||||
@@ -91,6 +94,31 @@ struct ReviewStep: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func displayValue(for fieldName: String) -> String {
|
||||
switch fieldName {
|
||||
case "Sports":
|
||||
return selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", ")
|
||||
case "Dates":
|
||||
return dateRangeText
|
||||
case "Regions":
|
||||
return selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", ")
|
||||
case "Route Preference":
|
||||
return routePreference.displayName
|
||||
case "Repeat Cities":
|
||||
return allowRepeatCities ? "Yes" : "No"
|
||||
case "Games":
|
||||
return selectedGameCount > 0 ? "\(selectedGameCount) game\(selectedGameCount == 1 ? "" : "s") selected" : "Not selected"
|
||||
case "Team":
|
||||
return selectedTeamName ?? "Not selected"
|
||||
case "Start Location":
|
||||
return startLocationName ?? "Not selected"
|
||||
case "End Location":
|
||||
return endLocationName ?? "Not selected"
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
private var dateRangeText: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
@@ -143,11 +171,16 @@ private struct ReviewRow: View {
|
||||
isPlanning: false,
|
||||
canPlanTrip: true,
|
||||
fieldValidation: FieldValidation(
|
||||
planningMode: .dateRange,
|
||||
sports: .valid,
|
||||
dates: .valid,
|
||||
regions: .valid,
|
||||
routePreference: .valid,
|
||||
repeatCities: .valid
|
||||
repeatCities: .valid,
|
||||
selectedGames: .valid,
|
||||
selectedTeam: .valid,
|
||||
startLocation: .valid,
|
||||
endLocation: .valid
|
||||
),
|
||||
onPlan: {}
|
||||
)
|
||||
@@ -168,11 +201,16 @@ private struct ReviewRow: View {
|
||||
isPlanning: false,
|
||||
canPlanTrip: false,
|
||||
fieldValidation: FieldValidation(
|
||||
planningMode: .dateRange,
|
||||
sports: .missing,
|
||||
dates: .valid,
|
||||
regions: .missing,
|
||||
routePreference: .valid,
|
||||
repeatCities: .missing
|
||||
repeatCities: .missing,
|
||||
selectedGames: .valid,
|
||||
selectedTeam: .valid,
|
||||
startLocation: .valid,
|
||||
endLocation: .valid
|
||||
),
|
||||
onPlan: {}
|
||||
)
|
||||
|
||||
240
SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift
Normal file
240
SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift
Normal file
@@ -0,0 +1,240 @@
|
||||
//
|
||||
// TeamPickerStep.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Team selection step for "Follow Team" planning mode.
|
||||
// Uses sheet-based drill-down: Sport → Team.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TeamPickerStep: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var selectedSport: Sport?
|
||||
@Binding var selectedTeamId: String?
|
||||
|
||||
@State private var showTeamPicker = false
|
||||
|
||||
private var selectedTeam: Team? {
|
||||
guard let teamId = selectedTeamId else { return nil }
|
||||
return AppDataProvider.shared.teams.first { $0.id == teamId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
StepHeader(
|
||||
title: "Which team do you want to follow?",
|
||||
subtitle: "See their home and away games"
|
||||
)
|
||||
|
||||
// Selection button
|
||||
Button {
|
||||
showTeamPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
if let team = selectedTeam {
|
||||
// Show selected team
|
||||
Circle()
|
||||
.fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor)
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(team.fullName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(team.sport.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
selectedTeamId = nil
|
||||
selectedSport = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
} else {
|
||||
// Empty state
|
||||
Image(systemName: "person.2.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text("Select a team")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(selectedTeam != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: selectedTeam != nil ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.sheet(isPresented: $showTeamPicker) {
|
||||
TeamPickerSheet(
|
||||
selectedSport: $selectedSport,
|
||||
selectedTeamId: $selectedTeamId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Picker Sheet
|
||||
|
||||
private struct TeamPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Binding var selectedSport: Sport?
|
||||
@Binding var selectedTeamId: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(Sport.supported, id: \.self) { sport in
|
||||
NavigationLink {
|
||||
TeamListView(
|
||||
sport: sport,
|
||||
selectedTeamId: $selectedTeamId,
|
||||
onSelect: { teamId in
|
||||
selectedSport = sport
|
||||
selectedTeamId = teamId
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(sport.themeColor)
|
||||
.frame(width: 32)
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(teamsCount(for: sport)) teams")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Select Sport")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private func teamsCount(for sport: Sport) -> Int {
|
||||
AppDataProvider.shared.teams.filter { $0.sport == sport }.count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team List View
|
||||
|
||||
private struct TeamListView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let sport: Sport
|
||||
@Binding var selectedTeamId: String?
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
@State private var searchText = ""
|
||||
|
||||
private var teams: [Team] {
|
||||
let allTeams = AppDataProvider.shared.teams
|
||||
.filter { $0.sport == sport }
|
||||
.sorted { $0.fullName < $1.fullName }
|
||||
|
||||
if searchText.isEmpty {
|
||||
return allTeams
|
||||
}
|
||||
|
||||
return allTeams.filter {
|
||||
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(teams) { team in
|
||||
Button {
|
||||
onSelect(team.id)
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Circle()
|
||||
.fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(team.fullName)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(team.city)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedTeamId == team.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(text: $searchText, prompt: "Search teams")
|
||||
.navigationTitle(sport.rawValue)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
TeamPickerStep(
|
||||
selectedSport: .constant(.mlb),
|
||||
selectedTeamId: .constant(nil)
|
||||
)
|
||||
.padding()
|
||||
.themedBackground()
|
||||
}
|
||||
@@ -19,6 +19,12 @@ struct TripWizardView: View {
|
||||
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
|
||||
/// Selected team name for display in ReviewStep
|
||||
private var selectedTeamName: String? {
|
||||
guard let teamId = viewModel.selectedTeamId else { return nil }
|
||||
return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
@@ -29,26 +35,57 @@ struct TripWizardView: View {
|
||||
// All other steps appear together after planning mode selected
|
||||
if viewModel.areStepsVisible {
|
||||
Group {
|
||||
DatesStep(
|
||||
startDate: $viewModel.startDate,
|
||||
endDate: $viewModel.endDate,
|
||||
hasSetDates: $viewModel.hasSetDates,
|
||||
onDatesChanged: {
|
||||
Task {
|
||||
await viewModel.fetchSportAvailability()
|
||||
// Mode-specific steps
|
||||
if viewModel.showGamePickerStep {
|
||||
GamePickerStep(
|
||||
selectedSports: $viewModel.gamePickerSports,
|
||||
selectedTeamIds: $viewModel.gamePickerTeamIds,
|
||||
selectedGameIds: $viewModel.selectedGameIds
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.showTeamPickerStep {
|
||||
TeamPickerStep(
|
||||
selectedSport: $viewModel.teamPickerSport,
|
||||
selectedTeamId: $viewModel.selectedTeamId
|
||||
)
|
||||
}
|
||||
|
||||
if viewModel.showLocationsStep {
|
||||
LocationsStep(
|
||||
startLocation: $viewModel.startLocation,
|
||||
endLocation: $viewModel.endLocation
|
||||
)
|
||||
}
|
||||
|
||||
// Common steps (conditionally shown)
|
||||
if viewModel.showDatesStep {
|
||||
DatesStep(
|
||||
startDate: $viewModel.startDate,
|
||||
endDate: $viewModel.endDate,
|
||||
hasSetDates: $viewModel.hasSetDates,
|
||||
onDatesChanged: {
|
||||
Task {
|
||||
await viewModel.fetchSportAvailability()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SportsStep(
|
||||
selectedSports: $viewModel.selectedSports,
|
||||
sportAvailability: viewModel.sportAvailability,
|
||||
isLoading: viewModel.isLoadingSportAvailability,
|
||||
canSelectSport: viewModel.canSelectSport
|
||||
)
|
||||
if viewModel.showSportsStep {
|
||||
SportsStep(
|
||||
selectedSports: $viewModel.selectedSports,
|
||||
sportAvailability: viewModel.sportAvailability,
|
||||
isLoading: viewModel.isLoadingSportAvailability,
|
||||
canSelectSport: viewModel.canSelectSport
|
||||
)
|
||||
}
|
||||
|
||||
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
||||
if viewModel.showRegionsStep {
|
||||
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
||||
}
|
||||
|
||||
// Always shown steps
|
||||
RoutePreferenceStep(
|
||||
routePreference: $viewModel.routePreference,
|
||||
hasSetRoutePreference: $viewModel.hasSetRoutePreference
|
||||
@@ -73,7 +110,11 @@ struct TripWizardView: View {
|
||||
isPlanning: viewModel.isPlanning,
|
||||
canPlanTrip: viewModel.canPlanTrip,
|
||||
fieldValidation: viewModel.fieldValidation,
|
||||
onPlan: { Task { await planTrip() } }
|
||||
onPlan: { Task { await planTrip() } },
|
||||
selectedGameCount: viewModel.selectedGameIds.count,
|
||||
selectedTeamName: selectedTeamName,
|
||||
startLocationName: viewModel.startLocation?.name,
|
||||
endLocationName: viewModel.endLocation?.name
|
||||
)
|
||||
}
|
||||
.transition(.opacity)
|
||||
@@ -115,19 +156,61 @@ struct TripWizardView: View {
|
||||
defer { viewModel.isPlanning = false }
|
||||
|
||||
do {
|
||||
let preferences = buildPreferences()
|
||||
|
||||
// Fetch games for selected sports and date range
|
||||
let games = try await AppDataProvider.shared.filterGames(
|
||||
sports: preferences.sports,
|
||||
startDate: preferences.startDate,
|
||||
endDate: preferences.endDate
|
||||
)
|
||||
var preferences = buildPreferences()
|
||||
|
||||
// Build dictionaries from arrays
|
||||
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
||||
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
||||
|
||||
// For gameFirst mode, derive date range from selected games
|
||||
var games: [Game]
|
||||
if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
|
||||
// Fetch all games for the selected sports to find the must-see games
|
||||
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
|
||||
|
||||
// Find the selected must-see games
|
||||
let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) }
|
||||
|
||||
if mustSeeGames.isEmpty {
|
||||
planningError = "Could not find the selected games. Please try again."
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
|
||||
// Derive date range from must-see games (with buffer)
|
||||
let gameDates = mustSeeGames.map { $0.dateTime }
|
||||
let minDate = gameDates.min() ?? Date()
|
||||
let maxDate = gameDates.max() ?? Date()
|
||||
|
||||
// Update preferences with derived date range
|
||||
preferences = TripPreferences(
|
||||
planningMode: preferences.planningMode,
|
||||
startLocation: preferences.startLocation,
|
||||
endLocation: preferences.endLocation,
|
||||
sports: preferences.sports,
|
||||
mustSeeGameIds: preferences.mustSeeGameIds,
|
||||
startDate: Calendar.current.startOfDay(for: minDate),
|
||||
endDate: Calendar.current.date(byAdding: .day, value: 1, to: maxDate) ?? maxDate,
|
||||
mustStopLocations: preferences.mustStopLocations,
|
||||
routePreference: preferences.routePreference,
|
||||
allowRepeatCities: preferences.allowRepeatCities,
|
||||
selectedRegions: preferences.selectedRegions,
|
||||
followTeamId: preferences.followTeamId
|
||||
)
|
||||
|
||||
// Use all games within the derived date range
|
||||
games = allGames.filter {
|
||||
$0.dateTime >= preferences.startDate && $0.dateTime <= preferences.endDate
|
||||
}
|
||||
} else {
|
||||
// Standard mode: fetch games for date range
|
||||
games = try await AppDataProvider.shared.filterGames(
|
||||
sports: preferences.sports,
|
||||
startDate: preferences.startDate,
|
||||
endDate: preferences.endDate
|
||||
)
|
||||
}
|
||||
|
||||
// Build RichGame dictionary for display
|
||||
var richGamesDict: [String: RichGame] = [:]
|
||||
for game in games {
|
||||
@@ -171,15 +254,29 @@ struct TripWizardView: View {
|
||||
}
|
||||
|
||||
private func buildPreferences() -> TripPreferences {
|
||||
TripPreferences(
|
||||
// Determine which sports to use based on mode
|
||||
let sports: Set<Sport>
|
||||
if viewModel.planningMode == .gameFirst {
|
||||
sports = viewModel.gamePickerSports
|
||||
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
|
||||
sports = [sport]
|
||||
} else {
|
||||
sports = viewModel.selectedSports
|
||||
}
|
||||
|
||||
return TripPreferences(
|
||||
planningMode: viewModel.planningMode ?? .dateRange,
|
||||
sports: viewModel.selectedSports,
|
||||
startLocation: viewModel.startLocation,
|
||||
endLocation: viewModel.endLocation,
|
||||
sports: sports,
|
||||
mustSeeGameIds: viewModel.selectedGameIds,
|
||||
startDate: viewModel.startDate,
|
||||
endDate: viewModel.endDate,
|
||||
mustStopLocations: viewModel.mustStopLocations,
|
||||
routePreference: viewModel.routePreference,
|
||||
allowRepeatCities: viewModel.allowRepeatCities,
|
||||
selectedRegions: viewModel.selectedRegions
|
||||
selectedRegions: viewModel.selectedRegions,
|
||||
followTeamId: viewModel.selectedTeamId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user