Files
Sportstime/SportsTime/Features/Schedule/Views/ScheduleListView.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

497 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 static let sectionDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEE, MMM d"
return f
}()
private func formatSectionDate(_ date: Date) -> String {
let calendar = Calendar.current
if calendar.isDateInToday(date) {
return "Today"
} else if calendar.isDateInTomorrow(date) {
return "Tomorrow"
} else {
return Self.sectionDateFormatter.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()
}
}