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>
238 lines
7.7 KiB
Swift
238 lines
7.7 KiB
Swift
//
|
|
// ProgressViewModel.swift
|
|
// SportsTime
|
|
//
|
|
// ViewModel for stadium progress tracking and visualization.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class ProgressViewModel {
|
|
|
|
// MARK: - State
|
|
|
|
var selectedSport: Sport = .mlb
|
|
var isLoading = true
|
|
var error: Error?
|
|
var errorMessage: String?
|
|
|
|
// MARK: - Data
|
|
|
|
private(set) var visits: [StadiumVisit] = []
|
|
private(set) var stadiums: [Stadium] = []
|
|
private(set) var teams: [Team] = []
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private var modelContainer: ModelContainer?
|
|
private var modelContext: ModelContext?
|
|
private let dataProvider = AppDataProvider.shared
|
|
|
|
// MARK: - Derived State (recomputed after mutations)
|
|
|
|
/// Overall progress for the selected sport
|
|
private(set) var leagueProgress: LeagueProgress = LeagueProgress(sport: .mlb, totalStadiums: 0, visitedStadiums: 0, stadiumsVisited: [], stadiumsRemaining: [])
|
|
|
|
/// Stadium visit status indexed by stadium ID
|
|
private(set) var stadiumVisitStatus: [String: StadiumVisitStatus] = [:]
|
|
|
|
/// Visited stadiums for the selected sport
|
|
private(set) var visitedStadiums: [Stadium] = []
|
|
|
|
/// 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 (cached, recomputed on data change)
|
|
private(set) var sportStadiums: [Stadium] = []
|
|
|
|
/// Count of trips for the selected sport (stub - can be enhanced)
|
|
var tripCount: Int {
|
|
savedTripCount
|
|
}
|
|
|
|
/// Recent visits sorted by date (cached, recomputed on data change)
|
|
private(set) var recentVisits: [VisitSummary] = []
|
|
private(set) var savedTripCount: Int = 0
|
|
|
|
// MARK: - Configuration
|
|
|
|
func configure(with container: ModelContainer) {
|
|
self.modelContainer = container
|
|
self.modelContext = ModelContext(container)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
func loadData() async {
|
|
isLoading = true
|
|
error = nil
|
|
errorMessage = nil
|
|
|
|
do {
|
|
// Load stadiums and teams from data provider
|
|
if dataProvider.stadiums.isEmpty {
|
|
await dataProvider.loadInitialData()
|
|
}
|
|
stadiums = dataProvider.stadiums
|
|
teams = dataProvider.teams
|
|
|
|
// Load visits from SwiftData
|
|
if let context = modelContext {
|
|
let descriptor = FetchDescriptor<StadiumVisit>(
|
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
|
)
|
|
visits = try context.fetch(descriptor)
|
|
savedTripCount = try context.fetchCount(FetchDescriptor<SavedTrip>())
|
|
}
|
|
} catch {
|
|
self.error = error
|
|
self.errorMessage = error.localizedDescription
|
|
}
|
|
|
|
recomputeDerivedState()
|
|
isLoading = false
|
|
}
|
|
|
|
func selectSport(_ sport: Sport) {
|
|
selectedSport = sport
|
|
recomputeDerivedState()
|
|
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
|
|
}
|
|
|
|
func clearError() {
|
|
error = nil
|
|
errorMessage = nil
|
|
}
|
|
|
|
// MARK: - Visit Management
|
|
|
|
func deleteVisit(_ visit: StadiumVisit) async throws {
|
|
guard let context = modelContext else { return }
|
|
|
|
if let sport = visit.sportEnum {
|
|
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
|
|
}
|
|
|
|
context.delete(visit)
|
|
try context.save()
|
|
|
|
// Reload data
|
|
await loadData()
|
|
}
|
|
|
|
// MARK: - Derived State Recomputation
|
|
|
|
private func recomputeDerivedState() {
|
|
// Compute league progress once
|
|
let filteredStadiums = stadiums.filter { $0.sport == selectedSport }
|
|
self.sportStadiums = filteredStadiums
|
|
|
|
let visitedStadiumIds = Set(
|
|
visits
|
|
.filter { $0.sportEnum == selectedSport }
|
|
.compactMap { visit -> String? in
|
|
dataProvider.stadium(for: visit.stadiumId)?.id
|
|
}
|
|
)
|
|
|
|
let visited = filteredStadiums.filter { visitedStadiumIds.contains($0.id) }
|
|
let remaining = filteredStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
|
|
|
leagueProgress = LeagueProgress(
|
|
sport: selectedSport,
|
|
totalStadiums: filteredStadiums.count,
|
|
visitedStadiums: visited.count,
|
|
stadiumsVisited: visited,
|
|
stadiumsRemaining: remaining
|
|
)
|
|
visitedStadiums = visited
|
|
unvisitedStadiums = remaining
|
|
|
|
// Compute stadium visit status once
|
|
var statusMap: [String: StadiumVisitStatus] = [:]
|
|
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId }
|
|
|
|
for stadium in stadiums {
|
|
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
|
let summaries = stadiumVisits.map { visit in
|
|
VisitSummary(
|
|
id: visit.id,
|
|
stadium: stadium,
|
|
visitDate: visit.visitDate,
|
|
visitType: visit.visitType,
|
|
sport: selectedSport,
|
|
homeTeamName: visit.homeTeamName,
|
|
awayTeamName: visit.awayTeamName,
|
|
score: visit.finalScore,
|
|
photoCount: visit.photoMetadata?.count ?? 0,
|
|
notes: visit.notes
|
|
)
|
|
}
|
|
statusMap[stadium.id] = .visited(visits: summaries)
|
|
} else {
|
|
statusMap[stadium.id] = .notVisited
|
|
}
|
|
}
|
|
stadiumVisitStatus = statusMap
|
|
|
|
// Pre-compute sport progress fractions for SportSelectorGrid
|
|
var uniqueStadiumIdsBySport: [Sport: Set<String>] = [:]
|
|
for visit in visits {
|
|
if let sport = visit.sportEnum {
|
|
uniqueStadiumIdsBySport[sport, default: []].insert(visit.stadiumId)
|
|
}
|
|
}
|
|
sportProgressFractions = Dictionary(uniqueKeysWithValues: Sport.supported.map { sport in
|
|
let total = LeagueStructure.stadiumCount(for: sport)
|
|
let visited = min(uniqueStadiumIdsBySport[sport]?.count ?? 0, total)
|
|
return (sport, total > 0 ? Double(visited) / Double(total) : 0)
|
|
})
|
|
|
|
// Recompute recent visits cache
|
|
recomputeRecentVisits()
|
|
}
|
|
|
|
private func recomputeRecentVisits() {
|
|
recentVisits = visits
|
|
.sorted { $0.visitDate > $1.visitDate }
|
|
.prefix(10)
|
|
.compactMap { visit -> VisitSummary? in
|
|
guard let stadium = dataProvider.stadium(for: visit.stadiumId),
|
|
let sport = visit.sportEnum else {
|
|
return nil
|
|
}
|
|
return VisitSummary(
|
|
id: visit.id,
|
|
stadium: stadium,
|
|
visitDate: visit.visitDate,
|
|
visitType: visit.visitType,
|
|
sport: sport,
|
|
homeTeamName: visit.homeTeamName,
|
|
awayTeamName: visit.awayTeamName,
|
|
score: visit.finalScore,
|
|
photoCount: visit.photoMetadata?.count ?? 0,
|
|
notes: visit.notes
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress Card Generation
|
|
|
|
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
|
ProgressCardData(
|
|
sport: selectedSport,
|
|
progress: leagueProgress,
|
|
username: nil,
|
|
includeMap: true,
|
|
showDetailedStats: false
|
|
)
|
|
}
|
|
}
|