627 lines
24 KiB
Swift
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()
|
|
}
|