Files
Sportstime/SportsTime/Features/Progress/Views/ProgressTabView.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

690 lines
25 KiB
Swift

//
// ProgressTabView.swift
// SportsTime
//
// Main view for stadium progress tracking with league selector and map.
//
import SwiftUI
import SwiftData
struct ProgressTabView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = ProgressViewModel()
@State private var showVisitSheet = false
@State private var showPhotoImport = false
@State private var selectedStadium: Stadium?
@State private var selectedVisitId: UUID?
@Query private var visits: [StadiumVisit]
@State private var visitsById: [UUID: StadiumVisit] = [:]
var body: some View {
Group {
if viewModel.stadiums.isEmpty {
ProgressView("Loading...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
scrollContent
}
}
.themedBackground()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ShareButton(
progress: viewModel.leagueProgress,
tripCount: viewModel.tripCount,
style: .icon
)
.foregroundStyle(Theme.warmOrange)
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showVisitSheet = true
} label: {
Label("Manual Entry", systemImage: "pencil")
}
Button {
showPhotoImport = true
} label: {
Label("Import from Photos", systemImage: "photo.on.rectangle.angled")
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
}
.accessibilityLabel("Add stadium visit")
}
}
.onChange(of: visits, initial: true) { _, newVisits in
visitsById = Dictionary(newVisits.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
}
.task {
viewModel.configure(with: modelContext.container)
await viewModel.loadData()
}
.sheet(isPresented: $showVisitSheet) {
StadiumVisitSheet(initialSport: viewModel.selectedSport) { _ in
Task {
await viewModel.loadData()
}
}
}
.sheet(isPresented: $showPhotoImport) {
PhotoImportView()
.onDisappear {
Task {
await viewModel.loadData()
}
}
}
.sheet(item: $selectedStadium) { stadium in
StadiumDetailSheet(
stadium: stadium,
visitStatus: viewModel.stadiumVisitStatus[stadium.id] ?? .notVisited,
sport: viewModel.selectedSport,
onVisitLogged: {
Task {
await viewModel.loadData()
}
}
)
.presentationDetents([.medium])
}
}
// MARK: - Scroll Content
private var scrollContent: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// League Selector
leagueSelector
.accessibilityIdentifier("progress.sportSelector")
.staggeredAnimation(index: 0)
// Progress Summary Card
progressSummaryCard
.staggeredAnimation(index: 1)
// Map View
ProgressMapView(
stadiums: viewModel.sportStadiums,
visitStatus: viewModel.stadiumVisitStatus,
selectedStadium: $selectedStadium
)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.staggeredAnimation(index: 2)
// Stadium Lists
stadiumListsSection
.staggeredAnimation(index: 3)
// Achievements Teaser
achievementsSection
.staggeredAnimation(index: 4)
// Recent Visits
if !viewModel.recentVisits.isEmpty {
recentVisitsSection
.staggeredAnimation(index: 5)
}
}
.padding(Theme.Spacing.md)
}
}
// MARK: - League Selector
private var leagueSelector: some View {
SportSelectorGrid { sport in
SportProgressButton(
sport: sport,
isSelected: viewModel.selectedSport == sport,
progress: viewModel.sportProgressFractions[sport] ?? 0
) {
Theme.Animation.withMotion(Theme.Animation.spring) {
viewModel.selectSport(sport)
}
}
}
}
// MARK: - Progress Summary Card
private var progressSummaryCard: some View {
let progress = viewModel.leagueProgress
return VStack(spacing: Theme.Spacing.lg) {
// Title and progress ring
HStack(alignment: .center, spacing: Theme.Spacing.lg) {
// Progress Ring
ZStack {
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
.frame(width: 80, height: 80)
.accessibilityHidden(true)
Circle()
.trim(from: 0, to: progress.progressFraction)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80)
.rotationEffect(.degrees(-90))
.animation(
Theme.Animation.prefersReducedMotion ? nil : .easeInOut(duration: 0.5),
value: progress.progressFraction
)
.accessibilityHidden(true)
VStack(spacing: 0) {
Text("\(progress.visitedStadiums)")
.font(.title3)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("/\(progress.totalStadiums)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(progress.visitedStadiums) of \(progress.totalStadiums) stadiums visited, \(Int(progress.completionPercentage)) percent complete")
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(viewModel.selectedSport.displayName)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Stadium Quest")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
.accessibilityIdentifier("progress.stadiumQuest")
if progress.isComplete {
HStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
Text("Complete!")
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
} else {
Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
}
// Stats row
HStack(spacing: Theme.Spacing.lg) {
ProgressStatPill(
icon: "mappin.circle.fill",
value: "\(progress.visitedStadiums)",
label: "Visited"
)
ProgressStatPill(
icon: "circle.dotted",
value: "\(progress.totalStadiums - progress.visitedStadiums)",
label: "Remaining"
)
ProgressStatPill(
icon: "percent",
value: String(format: "%.0f%%", progress.completionPercentage),
label: "Complete"
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Statistics: \(progress.visitedStadiums) visited, \(progress.totalStadiums - progress.visitedStadiums) remaining, \(String(format: "%.0f", progress.completionPercentage)) percent complete")
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
}
// MARK: - Stadium Lists Section
private var stadiumListsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
// Visited Stadiums
if !viewModel.visitedStadiums.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
Text("Visited (\(viewModel.visitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(viewModel.visitedStadiums) { stadium in
StadiumChip(
stadium: stadium,
isVisited: true,
visitCount: viewModel.stadiumVisitStatus[stadium.id]?.visitCount ?? 1
) {
selectedStadium = stadium
}
}
}
}
}
}
// Unvisited Stadiums
if !viewModel.unvisitedStadiums.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "circle.dotted")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(viewModel.unvisitedStadiums) { stadium in
StadiumChip(
stadium: stadium,
isVisited: false
) {
selectedStadium = stadium
}
}
}
}
}
}
}
}
// MARK: - Achievements Section
private var achievementsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Achievements")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
.accessibilityIdentifier("progress.achievementsTitle")
Spacer()
NavigationLink {
AchievementsListView()
} label: {
HStack(spacing: 4) {
Text("View All")
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
NavigationLink {
AchievementsListView()
} label: {
HStack(spacing: Theme.Spacing.md) {
// Trophy icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 50, height: 50)
Image(systemName: "trophy.fill")
.font(.title3)
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
Text("Track Your Progress")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Earn badges for stadium visits")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Recent Visits Section
private var recentVisitsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Recent Visits")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
.accessibilityIdentifier("progress.recentVisitsTitle")
Spacer()
NavigationLink {
GamesHistoryView()
} label: {
HStack(spacing: 4) {
Text("See All")
Image(systemName: "chevron.right")
.accessibilityHidden(true)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
.accessibilityIdentifier("progress.seeAllGamesHistory")
}
ForEach(viewModel.recentVisits) { visitSummary in
if let stadiumVisit = visitsById[visitSummary.id] {
NavigationLink {
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
} label: {
RecentVisitRow(visit: visitSummary)
}
.buttonStyle(.plain)
} else {
RecentVisitRow(visit: visitSummary)
}
}
}
}
}
// MARK: - Supporting Views
struct ProgressStatPill: View {
let icon: String
let value: String
let label: String
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 4) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.accessibilityHidden(true)
Text(value)
.font(.body)
}
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(label)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity)
}
}
struct StadiumChip: View {
let stadium: Stadium
let isVisited: Bool
var visitCount: Int = 1
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: Theme.Spacing.xs) {
if isVisited {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(stadium.name)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
// Visit count badge (if more than 1)
if visitCount > 1 {
Text("\(visitCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Theme.warmOrange, in: Capsule())
}
}
Text(stadium.city)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Theme.cardBackground(colorScheme))
.clipShape(Capsule())
.overlay {
Capsule()
.stroke(isVisited ? Color.green.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
}
}
struct RecentVisitRow: View {
let visit: VisitSummary
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.md) {
// Sport icon
ZStack {
Circle()
.fill(visit.sport.themeColor.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: visit.sport.iconName)
.foregroundStyle(visit.sport.themeColor)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
Text(visit.stadium.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(alignment: .leading, spacing: 4) {
Text(visit.shortDateDescription)
if let away = visit.awayTeamName, let home = visit.homeTeamName {
Text("\(away) at \(home)")
}
}
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.accessibilityElement(children: .combine)
}
}
struct StadiumDetailSheet: View {
let stadium: Stadium
let visitStatus: StadiumVisitStatus
let sport: Sport
var onVisitLogged: (() -> Void)?
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var showLogVisit = false
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.lg) {
// Stadium header
VStack(spacing: Theme.Spacing.sm) {
ZStack {
Circle()
.fill(sport.themeColor.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: visitStatus.isVisited ? "checkmark.seal.fill" : sport.iconName)
.font(.largeTitle)
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
}
.accessibilityHidden(true)
Text(stadium.name)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
Text(stadium.fullAddress)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
if visitStatus.isVisited {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Visited \(visitStatus.visitCount) time\(visitStatus.visitCount == 1 ? "" : "s")")
}
.font(.subheadline)
.foregroundStyle(.green)
}
}
// Visit history if visited
if case .visited(let visits) = visitStatus {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Visit History")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(visit.shortDateDescription)
.font(.subheadline)
if let matchup = visit.matchup {
Text(matchup)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
Spacer()
Text(visit.visitType.displayName)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
}
}
Spacer()
// Action button
Button {
showLogVisit = true
} label: {
HStack {
Image(systemName: visitStatus.isVisited ? "plus" : "checkmark.circle")
Text(visitStatus.isVisited ? "Log Another Visit" : "Log Visit")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.pressableStyle()
}
.padding(Theme.Spacing.lg)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
.sheet(isPresented: $showLogVisit) {
StadiumVisitSheet(
initialStadium: stadium,
initialSport: sport
) { _ in
onVisitLogged?()
dismiss()
}
}
}
}
}
#Preview {
NavigationStack {
ProgressTabView()
}
.modelContainer(for: StadiumVisit.self, inMemory: true)
}