493 lines
16 KiB
Swift
493 lines
16 KiB
Swift
//
|
|
// ScheduleListView.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ScheduleListView: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var viewModel = ScheduleViewModel()
|
|
@State private var showDatePicker = false
|
|
@State private var showDiagnostics = false
|
|
|
|
private var hasGames: Bool {
|
|
!viewModel.gamesByDate.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if viewModel.isLoading && !hasGames {
|
|
loadingView
|
|
} else if let errorMessage = viewModel.errorMessage {
|
|
errorView(message: errorMessage)
|
|
} else if !hasGames {
|
|
emptyView
|
|
} else {
|
|
gamesList
|
|
}
|
|
}
|
|
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
|
|
.scrollContentBackground(.hidden)
|
|
.themedBackground()
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
Button {
|
|
showDatePicker = true
|
|
} label: {
|
|
Label("Date Range", systemImage: "calendar")
|
|
}
|
|
|
|
if viewModel.hasFilters {
|
|
Button(role: .destructive) {
|
|
viewModel.resetFilters()
|
|
} label: {
|
|
Label("Clear Filters", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button {
|
|
showDiagnostics = true
|
|
} label: {
|
|
Label("Diagnostics", systemImage: "info.circle")
|
|
}
|
|
} label: {
|
|
Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
|
.accessibilityLabel(viewModel.hasFilters ? "Filter options, filters active" : "Filter options")
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showDiagnostics) {
|
|
ScheduleDiagnosticsSheet(diagnostics: viewModel.diagnostics)
|
|
}
|
|
.sheet(isPresented: $showDatePicker) {
|
|
DateRangePickerSheet(
|
|
startDate: viewModel.startDate,
|
|
endDate: viewModel.endDate
|
|
) { start, end in
|
|
viewModel.updateDateRange(start: start, end: end)
|
|
}
|
|
.presentationDetents([.medium])
|
|
}
|
|
.task {
|
|
await viewModel.loadGames()
|
|
}
|
|
.onChange(of: viewModel.searchText) {
|
|
viewModel.updateFilteredGames()
|
|
}
|
|
}
|
|
|
|
// MARK: - Sport Filter
|
|
|
|
private var sportFilter: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Sport.supported) { sport in
|
|
SportFilterChip(
|
|
sport: sport,
|
|
isSelected: viewModel.selectedSports.contains(sport)
|
|
) {
|
|
viewModel.toggleSport(sport)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// MARK: - Games List
|
|
|
|
private var gamesList: some View {
|
|
List {
|
|
Section {
|
|
sportFilter
|
|
.listRowInsets(EdgeInsets())
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
|
|
ForEach(viewModel.gamesByDate, id: \.date) { dateGroup in
|
|
Section {
|
|
ForEach(dateGroup.games) { richGame in
|
|
GameRowView(game: richGame, showSport: true, showLocation: true)
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text(formatSectionDate(dateGroup.date))
|
|
|
|
if Calendar.current.isDateInToday(dateGroup.date) {
|
|
Text("TODAY")
|
|
.font(.caption2)
|
|
.fontWeight(.bold)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.orange)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(dateGroup.games.count) games")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.font(.headline)
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.refreshable {
|
|
await viewModel.loadGames()
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
private var emptyView: some View {
|
|
VStack(spacing: 16) {
|
|
sportFilter
|
|
|
|
ContentUnavailableView {
|
|
Label("No Games Found", systemImage: "sportscourt")
|
|
} description: {
|
|
Text("Try adjusting your filters or date range")
|
|
} actions: {
|
|
Button("Reset Filters") {
|
|
viewModel.resetFilters()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.accessibilityIdentifier("schedule.resetFiltersButton")
|
|
}
|
|
}
|
|
.accessibilityIdentifier("schedule.emptyState")
|
|
}
|
|
|
|
// MARK: - Loading State
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: 16) {
|
|
LoadingSpinner(size: .large)
|
|
Text("Loading schedule...")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error State
|
|
|
|
private func errorView(message: String) -> some View {
|
|
ContentUnavailableView {
|
|
Label("Unable to Load", systemImage: "exclamationmark.icloud")
|
|
} description: {
|
|
Text(message)
|
|
} actions: {
|
|
Button {
|
|
viewModel.clearError()
|
|
Task {
|
|
await viewModel.loadGames()
|
|
}
|
|
} label: {
|
|
Text("Try Again")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func formatSectionDate(_ date: Date) -> String {
|
|
let calendar = Calendar.current
|
|
let formatter = DateFormatter()
|
|
|
|
if calendar.isDateInToday(date) {
|
|
return "Today"
|
|
} else if calendar.isDateInTomorrow(date) {
|
|
return "Tomorrow"
|
|
} else {
|
|
formatter.dateFormat = "EEEE, MMM d"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sport Filter Chip
|
|
|
|
struct SportFilterChip: View {
|
|
let sport: Sport
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: sport.iconName)
|
|
.font(.caption)
|
|
Text(sport.rawValue)
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(isSelected ? Color.blue : Color(.secondarySystemBackground))
|
|
.foregroundStyle(isSelected ? .white : .primary)
|
|
.clipShape(Capsule())
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("schedule.sport.\(sport.rawValue.lowercased())")
|
|
.accessibilityLabel(sport.rawValue)
|
|
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
|
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
|
}
|
|
}
|
|
|
|
// MARK: - Game Row View
|
|
|
|
struct GameRowView: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let game: RichGame
|
|
var showSport: Bool = false
|
|
var showLocation: Bool = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Sport icon
|
|
if showSport {
|
|
Image(systemName: game.game.sport.iconName)
|
|
.font(.title3)
|
|
.foregroundStyle(game.game.sport.themeColor)
|
|
.frame(width: 28)
|
|
.accessibilityLabel(game.game.sport.rawValue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// Teams
|
|
HStack(spacing: 8) {
|
|
TeamBadge(team: game.awayTeam, isHome: false)
|
|
Text("@")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
TeamBadge(team: game.homeTeam, isHome: true)
|
|
}
|
|
|
|
// Game info
|
|
HStack(spacing: 12) {
|
|
Label(game.localGameTimeShort, systemImage: "clock")
|
|
Label(game.stadium.name, systemImage: "building.2")
|
|
.lineLimit(1)
|
|
|
|
if let broadcast = game.game.broadcastInfo {
|
|
Label(broadcast, systemImage: "tv")
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
// Location
|
|
if showLocation {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "mappin.circle.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.accessibilityHidden(true)
|
|
Text(game.stadium.city)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(gameAccessibilityLabel)
|
|
}
|
|
|
|
private var gameAccessibilityLabel: String {
|
|
var parts: [String] = []
|
|
if showSport { parts.append(game.game.sport.rawValue) }
|
|
parts.append("\(game.awayTeam.name) at \(game.homeTeam.name)")
|
|
parts.append(game.localGameTimeShort)
|
|
parts.append(game.stadium.name)
|
|
return parts.joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
// MARK: - Team Badge
|
|
|
|
struct TeamBadge: View {
|
|
let team: Team
|
|
let isHome: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
if let colorHex = team.primaryColor {
|
|
Circle()
|
|
.fill(Color(hex: colorHex))
|
|
.frame(width: 8, height: 8)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
Text(team.abbreviation)
|
|
.font(.subheadline)
|
|
.fontWeight(isHome ? .bold : .regular)
|
|
|
|
Text(team.city)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Date Range Picker Sheet
|
|
|
|
struct DateRangePickerSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State var startDate: Date
|
|
@State var endDate: Date
|
|
let onApply: (Date, Date) -> Void
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Date Range") {
|
|
DatePicker("Start", selection: $startDate, displayedComponents: .date)
|
|
DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date)
|
|
}
|
|
|
|
Section {
|
|
Button("Next 7 Days") {
|
|
applyQuickRange(days: 7)
|
|
}
|
|
|
|
Button("Next 14 Days") {
|
|
applyQuickRange(days: 14)
|
|
}
|
|
|
|
Button("Next 30 Days") {
|
|
applyQuickRange(days: 30)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Select Dates")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Apply") {
|
|
onApply(startDate, endDate)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyQuickRange(days: Int) {
|
|
let calendar = Calendar.current
|
|
let start = calendar.startOfDay(for: Date())
|
|
let endDay = calendar.date(byAdding: .day, value: days, to: start) ?? start
|
|
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
|
startDate = start
|
|
endDate = end
|
|
}
|
|
}
|
|
|
|
// MARK: - Schedule Diagnostics Sheet
|
|
|
|
struct ScheduleDiagnosticsSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let diagnostics: ScheduleDiagnostics
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Query Details") {
|
|
if let start = diagnostics.lastQueryStartDate,
|
|
let end = diagnostics.lastQueryEndDate {
|
|
LabeledContent("Start Date") {
|
|
Text(start.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
LabeledContent("End Date") {
|
|
Text(end.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
} else {
|
|
Text("No query executed")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if !diagnostics.lastQuerySports.isEmpty {
|
|
LabeledContent("Sports Filter") {
|
|
Text(diagnostics.lastQuerySports.map(\.rawValue).joined(separator: ", "))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Data Loaded") {
|
|
LabeledContent("Teams", value: "\(diagnostics.teamsLoaded)")
|
|
LabeledContent("Stadiums", value: "\(diagnostics.stadiumsLoaded)")
|
|
LabeledContent("Total Games", value: "\(diagnostics.totalGamesReturned)")
|
|
}
|
|
|
|
if !diagnostics.gamesBySport.isEmpty {
|
|
Section("Games by Sport") {
|
|
ForEach(diagnostics.gamesBySport.sorted(by: { $0.key.rawValue < $1.key.rawValue }), id: \.key) { sport, count in
|
|
LabeledContent {
|
|
Text("\(count)")
|
|
.fontWeight(.semibold)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: sport.iconName)
|
|
.foregroundStyle(sport.themeColor)
|
|
Text(sport.rawValue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Troubleshooting") {
|
|
Text("If games are missing:")
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label("Check the date range above matches your expectations", systemImage: "1.circle")
|
|
Label("Verify the sport is enabled in the filter", systemImage: "2.circle")
|
|
Label("Pull down to refresh the schedule", systemImage: "3.circle")
|
|
Label("Check Settings > Debug > CloudKit Sync for sync status", systemImage: "4.circle")
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.navigationTitle("Schedule Diagnostics")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
ScheduleListView()
|
|
}
|
|
}
|