Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Trey t 74fd21590b wip
2026-01-20 22:26:48 -06:00

627 lines
24 KiB
Swift

//
// 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>
@Binding var startDate: Date
@Binding var endDate: Date
@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
// Date Range Section - shown when games are selected
dateRangeSection
}
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.onChange(of: selectedGameIds) { _, newValue in
Task {
await updateDateRangeForSelectedGames()
}
}
.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: - Date Range Section
private var dateRangeSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "calendar")
.foregroundStyle(Theme.warmOrange)
Text("Trip Date Range")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
// Show auto-calculated indicator
Text("Auto-adjusted")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
DateRangePicker(startDate: $startDate, endDate: $endDate)
// Game date markers legend
if !summaryGames.isEmpty {
let selectedGamesWithDates = summaryGames.filter { selectedGameIds.contains($0.id) }
if !selectedGamesWithDates.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Game dates:")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
ForEach(selectedGamesWithDates.sorted { $0.game.dateTime < $1.game.dateTime }) { game in
HStack(spacing: 6) {
Circle()
.fill(game.game.sport.themeColor)
.frame(width: 6, height: 6)
Text("\(game.matchupDescription) - \(game.game.dateTime, style: .date)")
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
.padding(.top, Theme.Spacing.xs)
}
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
/// Updates the date range based on selected games
/// - Single game: 7-day span centered on game (position 4 of 7, so game is day 4)
/// - Multiple games: range from earliest to latest with 1-day buffer
private func updateDateRangeForSelectedGames() async {
let selectedGames = summaryGames.filter { selectedGameIds.contains($0.id) }
guard !selectedGames.isEmpty else { return }
let gameDates = selectedGames.map { $0.game.dateTime }.sorted()
let calendar = Calendar.current
await MainActor.run {
if gameDates.count == 1 {
// Single game: 7-day span centered on game
// Position 4 of 7 means: 3 days before, game day, 3 days after
let gameDate = gameDates[0]
let newStart = calendar.date(byAdding: .day, value: -3, to: gameDate) ?? gameDate
let newEnd = calendar.date(byAdding: .day, value: 3, to: gameDate) ?? gameDate
startDate = calendar.startOfDay(for: newStart)
endDate = calendar.startOfDay(for: newEnd)
} else {
// Multiple games: span from first to last with 1-day buffer
let firstGameDate = gameDates.first!
let lastGameDate = gameDates.last!
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
startDate = calendar.startOfDay(for: newStart)
endDate = calendar.startOfDay(for: newEnd)
}
}
}
}
// 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)
.contentShape(Rectangle())
}
.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)
.contentShape(Rectangle())
}
.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)
.contentShape(Rectangle())
}
.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([]),
startDate: .constant(Date()),
endDate: .constant(Date().addingTimeInterval(86400 * 7))
)
.padding()
.themedBackground()
}