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>
486 lines
16 KiB
Swift
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()
|
|
}
|
|
}
|