fix: 14 audit fixes — concurrency, memory, performance, accessibility
Second audit round addressing data races, task stacking, unbounded caches, and VoiceOver gaps across 7 files. Concurrency: - Move NSItemProvider @State access into MainActor block (3 drop handlers) - Cancel stale ScheduleViewModel tasks on rapid filter changes Memory: - Replace unbounded image dict with LRUCache(countLimit: 50) - Replace demo-mode asyncAfter with cancellable Task Performance: - Wrap debug NBA print() in #if DEBUG - Cache visitsById via @State + onChange instead of per-render computed - Pre-compute sportProgressFractions in ProgressViewModel - Replace allGames computed property with hasGames bool check - Cache sortedTrips via @State + onChange in SavedTripsListView Accessibility: - Add combined VoiceOver label to progress ring - Combine away/home team text into single readable phrase - Hide decorative StadiumDetailSheet icon from VoiceOver - Add explicit accessibilityLabel to SportFilterChip - Add combined accessibilityLabel to GameRowView Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,9 @@ final class ProgressViewModel {
|
||||
/// Unvisited stadiums for the selected sport
|
||||
private(set) var unvisitedStadiums: [Stadium] = []
|
||||
|
||||
/// Pre-computed progress fractions per sport (avoids filtering visits per sport per render)
|
||||
private(set) var sportProgressFractions: [Sport: Double] = [:]
|
||||
|
||||
/// Stadiums for the selected sport
|
||||
var sportStadiums: [Stadium] {
|
||||
stadiums.filter { $0.sport == selectedSport }
|
||||
@@ -200,6 +203,19 @@ final class ProgressViewModel {
|
||||
}
|
||||
}
|
||||
stadiumVisitStatus = statusMap
|
||||
|
||||
// Pre-compute sport progress fractions for SportSelectorGrid
|
||||
var sportCounts: [Sport: Int] = [:]
|
||||
for visit in visits {
|
||||
if let sport = visit.sportEnum {
|
||||
sportCounts[sport, default: 0] += 1
|
||||
}
|
||||
}
|
||||
sportProgressFractions = Dictionary(uniqueKeysWithValues: Sport.supported.map { sport in
|
||||
let total = LeagueStructure.stadiumCount(for: sport)
|
||||
let visited = min(sportCounts[sport] ?? 0, total)
|
||||
return (sport, total > 0 ? Double(visited) / Double(total) : 0)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Generation
|
||||
|
||||
@@ -19,11 +19,7 @@ struct ProgressTabView: View {
|
||||
@State private var selectedVisitId: UUID?
|
||||
|
||||
@Query private var visits: [StadiumVisit]
|
||||
|
||||
/// O(1) lookup for visits by ID (built from @Query results)
|
||||
private var visitsById: [UUID: StadiumVisit] {
|
||||
Dictionary(uniqueKeysWithValues: visits.map { ($0.id, $0) })
|
||||
}
|
||||
@State private var visitsById: [UUID: StadiumVisit] = [:]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -66,6 +62,9 @@ struct ProgressTabView: View {
|
||||
.accessibilityLabel("Add stadium visit")
|
||||
}
|
||||
}
|
||||
.onChange(of: visits, initial: true) { _, newVisits in
|
||||
visitsById = Dictionary(uniqueKeysWithValues: newVisits.map { ($0.id, $0) })
|
||||
}
|
||||
.task {
|
||||
viewModel.configure(with: modelContext.container)
|
||||
await viewModel.loadData()
|
||||
@@ -153,7 +152,7 @@ struct ProgressTabView: View {
|
||||
SportProgressButton(
|
||||
sport: sport,
|
||||
isSelected: viewModel.selectedSport == sport,
|
||||
progress: progressForSport(sport)
|
||||
progress: viewModel.sportProgressFractions[sport] ?? 0
|
||||
) {
|
||||
Theme.Animation.withMotion(Theme.Animation.spring) {
|
||||
viewModel.selectSport(sport)
|
||||
@@ -162,12 +161,6 @@ struct ProgressTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func progressForSport(_ sport: Sport) -> Double {
|
||||
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
|
||||
let total = LeagueStructure.stadiumCount(for: sport)
|
||||
guard total > 0 else { return 0 }
|
||||
return Double(min(visitedCount, total)) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Progress Summary Card
|
||||
|
||||
@@ -204,6 +197,8 @@ struct ProgressTabView: View {
|
||||
.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)
|
||||
@@ -540,13 +535,10 @@ struct RecentVisitRow: View {
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
// Date, Away @ Home on one line, left aligned
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(visit.shortDateDescription)
|
||||
if let away = visit.awayTeamName, let home = visit.homeTeamName {
|
||||
Text(away)
|
||||
Text("@")
|
||||
Text(home)
|
||||
Text("\(away) at \(home)")
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
@@ -596,6 +588,7 @@ struct StadiumDetailSheet: View {
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(stadium.name)
|
||||
.font(.headline)
|
||||
|
||||
Reference in New Issue
Block a user