Files
Sportstime/SportsTime/Features/Schedule/Views/ScheduleListView.swift
Trey t dc142bd14b feat: expand XCUITest coverage to 54 QA scenarios with accessibility IDs and fix test failures
Add 22 new UI tests across 8 test files covering Home, Schedule, Progress,
Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility
identifiers to 11 view files for test element discovery. Fix sport chip
assertion logic (all sports start selected, tap deselects), scroll container
issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow.
Update QA coverage map from 32 to 54 automated test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:44:22 -06:00

486 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 allGames: [RichGame] {
viewModel.gamesBySport.flatMap(\.games)
}
var body: some View {
Group {
if viewModel.isLoading && allGames.isEmpty {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if allGames.isEmpty {
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.gamesBySport, id: \.sport) { sportGroup in
Section {
ForEach(sportGroup.games) { richGame in
GameRowView(game: richGame, showDate: true, showLocation: true)
}
} header: {
HStack(spacing: 8) {
Image(systemName: sportGroup.sport.iconName)
.accessibilityHidden(true)
Text(sportGroup.sport.rawValue)
}
.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())
}
.buttonStyle(.plain)
.accessibilityIdentifier("schedule.sport.\(sport.rawValue.lowercased())")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - Game Row View
struct GameRowView: View {
let game: RichGame
var showDate: Bool = false
var showLocation: Bool = false
// Static formatter to avoid allocation per row (significant performance improvement)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter
}()
// Cache isToday check to avoid repeated Calendar calls
private var isToday: Bool {
Calendar.current.isDateInToday(game.game.dateTime)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Date (when grouped by sport)
if showDate {
HStack(spacing: 6) {
Text(Self.dateFormatter.string(from: game.game.dateTime))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
if isToday {
Text("TODAY")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.clipShape(Capsule())
}
}
}
// Teams
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TeamBadge(team: game.awayTeam, isHome: false)
Text("@")
.font(.caption)
.foregroundStyle(.secondary)
TeamBadge(team: game.homeTeam, isHome: true)
}
}
Spacer()
}
// Game info
HStack(spacing: 12) {
Label(game.localGameTimeShort, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {
Label(broadcast, systemImage: "tv")
}
}
.font(.caption)
.foregroundStyle(.secondary)
// Location info (when grouped by game)
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)
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
}
}
// 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") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
}
Button("Next 14 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
}
Button("Next 30 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
}
}
}
.navigationTitle("Select Dates")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
onApply(startDate, endDate)
dismiss()
}
}
}
}
}
}
// 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()
}
}