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:
Trey t
2026-01-14 22:21:57 -06:00
parent 1301442604
commit aa34c6585a
7 changed files with 1277 additions and 63 deletions

View File

@@ -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 }
}
}

View 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()
}

View File

@@ -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) {

View 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()
}

View File

@@ -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: {}
)

View 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()
}

View File

@@ -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
)
}