fix: 13 audit fixes — memory, concurrency, performance, accessibility
Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
with explicit recompute after mutations (3x recomputation per render)
Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures
Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
structured concurrency compliance
Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
2 let _ = print() from TripMapView.body (fires every render)
Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
completes so VoiceOver users know app is ready
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,36 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Cached Formatters
|
||||||
|
|
||||||
|
private enum GameFormatters {
|
||||||
|
static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
static let dateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
static let dayOfWeekFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "EEEE"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
static let localTimeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "h:mm a z"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
static let localTimeShortFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "h:mm a"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
struct Game: Identifiable, Codable, Hashable {
|
struct Game: Identifiable, Codable, Hashable {
|
||||||
let id: String // Canonical ID: "game_mlb_2026_bos_nyy_0401"
|
let id: String // Canonical ID: "game_mlb_2026_bos_nyy_0401"
|
||||||
let homeTeamId: String // FK: "team_mlb_bos"
|
let homeTeamId: String // FK: "team_mlb_bos"
|
||||||
@@ -58,21 +88,15 @@ struct Game: Identifiable, Codable, Hashable {
|
|||||||
var startTime: Date { dateTime }
|
var startTime: Date { dateTime }
|
||||||
|
|
||||||
var gameTime: String {
|
var gameTime: String {
|
||||||
let formatter = DateFormatter()
|
GameFormatters.timeFormatter.string(from: dateTime)
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter.string(from: dateTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedDate: String {
|
var formattedDate: String {
|
||||||
let formatter = DateFormatter()
|
GameFormatters.dateFormatter.string(from: dateTime)
|
||||||
formatter.dateStyle = .medium
|
|
||||||
return formatter.string(from: dateTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var dayOfWeek: String {
|
var dayOfWeek: String {
|
||||||
let formatter = DateFormatter()
|
GameFormatters.dayOfWeekFormatter.string(from: dateTime)
|
||||||
formatter.dateFormat = "EEEE"
|
|
||||||
return formatter.string(from: dateTime)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,16 +130,14 @@ struct RichGame: Identifiable, Hashable, Codable {
|
|||||||
|
|
||||||
/// Game time formatted in the stadium's local timezone with timezone indicator
|
/// Game time formatted in the stadium's local timezone with timezone indicator
|
||||||
var localGameTime: String {
|
var localGameTime: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = GameFormatters.localTimeFormatter
|
||||||
formatter.dateFormat = "h:mm a z"
|
|
||||||
formatter.timeZone = stadium.timeZone ?? .current
|
formatter.timeZone = stadium.timeZone ?? .current
|
||||||
return formatter.string(from: game.dateTime)
|
return formatter.string(from: game.dateTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Game time formatted in the stadium's local timezone without timezone indicator
|
/// Game time formatted in the stadium's local timezone without timezone indicator
|
||||||
var localGameTimeShort: String {
|
var localGameTimeShort: String {
|
||||||
let formatter = DateFormatter()
|
let formatter = GameFormatters.localTimeShortFormatter
|
||||||
formatter.dateFormat = "h:mm a"
|
|
||||||
formatter.timeZone = stadium.timeZone ?? .current
|
formatter.timeZone = stadium.timeZone ?? .current
|
||||||
return formatter.string(from: game.dateTime)
|
return formatter.string(from: game.dateTime)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ actor ItineraryItemService {
|
|||||||
debounceTask?.cancel()
|
debounceTask?.cancel()
|
||||||
|
|
||||||
// Start new debounce task with proper cancellation handling
|
// Start new debounce task with proper cancellation handling
|
||||||
debounceTask = Task { [weak self] in
|
debounceTask = Task {
|
||||||
// Check cancellation before sleeping
|
// Check cancellation before sleeping
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await Task.sleep(for: self?.debounceInterval ?? .seconds(1.5))
|
try await Task.sleep(for: debounceInterval)
|
||||||
} catch {
|
} catch {
|
||||||
// Task was cancelled during sleep
|
// Task was cancelled during sleep
|
||||||
return
|
return
|
||||||
@@ -70,7 +70,7 @@ actor ItineraryItemService {
|
|||||||
// Check cancellation after sleeping (belt and suspenders)
|
// Check cancellation after sleeping (belt and suspenders)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await self?.flushPendingUpdates()
|
await flushPendingUpdates()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ final class VisitPhotoService {
|
|||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
|
|
||||||
// Queue background upload
|
// Queue background upload
|
||||||
Task { [weak self] in
|
Task {
|
||||||
await self?.uploadPhoto(metadata: metadata, image: image)
|
await self.uploadPhoto(metadata: metadata, image: image)
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata
|
return metadata
|
||||||
@@ -211,12 +211,34 @@ final class VisitPhotoService {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Prepare image data and temp file off the main actor
|
||||||
|
nonisolated private static func prepareImageData(
|
||||||
|
_ image: UIImage,
|
||||||
|
quality: CGFloat
|
||||||
|
) throws -> (Data, URL) {
|
||||||
|
guard let data = image.jpegData(compressionQuality: quality) else {
|
||||||
|
throw PhotoServiceError.invalidImage
|
||||||
|
}
|
||||||
|
let tempURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("jpg")
|
||||||
|
try data.write(to: tempURL)
|
||||||
|
return (data, tempURL)
|
||||||
|
}
|
||||||
|
|
||||||
private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async {
|
private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async {
|
||||||
guard let imageData = image.jpegData(compressionQuality: Self.compressionQuality) else {
|
// Capture MainActor-isolated value before entering detached context
|
||||||
await MainActor.run {
|
let quality = Self.compressionQuality
|
||||||
metadata.uploadStatus = .failed
|
|
||||||
try? modelContext.save()
|
// Perform CPU-intensive JPEG encoding off MainActor
|
||||||
}
|
let tempURL: URL
|
||||||
|
do {
|
||||||
|
(_, tempURL) = try await Task.detached(priority: .utility) {
|
||||||
|
try Self.prepareImageData(image, quality: quality)
|
||||||
|
}.value
|
||||||
|
} catch {
|
||||||
|
metadata.uploadStatus = .failed
|
||||||
|
try? modelContext.save()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,17 +246,15 @@ final class VisitPhotoService {
|
|||||||
do {
|
do {
|
||||||
let status = try await container.accountStatus()
|
let status = try await container.accountStatus()
|
||||||
guard status == .available else {
|
guard status == .available else {
|
||||||
await MainActor.run {
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
metadata.uploadStatus = .failed
|
metadata.uploadStatus = .failed
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
metadata.uploadStatus = .failed
|
metadata.uploadStatus = .failed
|
||||||
try? modelContext.save()
|
try? modelContext.save()
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +262,7 @@ final class VisitPhotoService {
|
|||||||
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
|
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
|
||||||
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
|
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
|
||||||
|
|
||||||
// Write image to temporary file for CKAsset
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("jpg")
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try imageData.write(to: tempURL)
|
|
||||||
|
|
||||||
let asset = CKAsset(fileURL: tempURL)
|
let asset = CKAsset(fileURL: tempURL)
|
||||||
record["imageAsset"] = asset
|
record["imageAsset"] = asset
|
||||||
record["visitId"] = metadata.visitId.uuidString
|
record["visitId"] = metadata.visitId.uuidString
|
||||||
@@ -263,28 +276,16 @@ final class VisitPhotoService {
|
|||||||
try? FileManager.default.removeItem(at: tempURL)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
|
|
||||||
// Update metadata
|
// Update metadata
|
||||||
await MainActor.run {
|
metadata.cloudKitAssetId = savedRecord.recordID.recordName
|
||||||
metadata.cloudKitAssetId = savedRecord.recordID.recordName
|
metadata.uploadStatus = .uploaded
|
||||||
metadata.uploadStatus = .uploaded
|
try? modelContext.save()
|
||||||
try? modelContext.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch let error as CKError {
|
|
||||||
// Clean up temp file
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
metadata.uploadStatus = .failed
|
|
||||||
try? modelContext.save()
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Clean up temp file
|
// Clean up temp file
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
|
|
||||||
await MainActor.run {
|
metadata.uploadStatus = .failed
|
||||||
metadata.uploadStatus = .failed
|
try? modelContext.save()
|
||||||
try? modelContext.save()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,20 +373,20 @@ final class PhotoGalleryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isLoadingFullImage = true
|
isLoadingFullImage = true
|
||||||
Task { [weak self] in
|
Task {
|
||||||
do {
|
do {
|
||||||
let image = try await self?.photoService.fetchFullImage(for: metadata)
|
let image = try await photoService.fetchFullImage(for: metadata)
|
||||||
self?.fullResolutionImage = image
|
fullResolutionImage = image
|
||||||
} catch let error as PhotoServiceError {
|
} catch let error as PhotoServiceError {
|
||||||
self?.error = error
|
self.error = error
|
||||||
// Fall back to thumbnail
|
// Fall back to thumbnail
|
||||||
if let data = metadata.thumbnailData {
|
if let data = metadata.thumbnailData {
|
||||||
self?.fullResolutionImage = UIImage(data: data)
|
fullResolutionImage = UIImage(data: data)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self?.error = .downloadFailed(error.localizedDescription)
|
self.error = .downloadFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
self?.isLoadingFullImage = false
|
isLoadingFullImage = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -296,11 +296,7 @@ final class StoreManager {
|
|||||||
for await result in Transaction.updates {
|
for await result in Transaction.updates {
|
||||||
if case .verified(let transaction) = result {
|
if case .verified(let transaction) = result {
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
await MainActor.run {
|
await StoreManager.shared.updateEntitlements()
|
||||||
Task {
|
|
||||||
await StoreManager.shared.updateEntitlements()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ struct AnimatedSportsIcon: View {
|
|||||||
let index: Int
|
let index: Int
|
||||||
let animate: Bool
|
let animate: Bool
|
||||||
@State private var glowOpacity: Double = 0
|
@State private var glowOpacity: Double = 0
|
||||||
|
@State private var glowTask: Task<Void, Never>?
|
||||||
|
|
||||||
static let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
|
static let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
|
||||||
// Edge icons
|
// Edge icons
|
||||||
@@ -162,37 +163,28 @@ struct AnimatedSportsIcon: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
startRandomGlow()
|
glowTask = Task { @MainActor in
|
||||||
}
|
// Random initial delay
|
||||||
}
|
try? await Task.sleep(for: .seconds(Double.random(in: 2.0...8.0)))
|
||||||
|
while !Task.isCancelled {
|
||||||
private func startRandomGlow() {
|
guard !Theme.Animation.prefersReducedMotion else {
|
||||||
let initialDelay = Double.random(in: 2.0...8.0)
|
try? await Task.sleep(for: .seconds(6.0))
|
||||||
|
continue
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) {
|
}
|
||||||
triggerGlow()
|
// Slow fade in
|
||||||
}
|
withAnimation(.easeIn(duration: 0.8)) { glowOpacity = 1 }
|
||||||
}
|
// Hold briefly then slow fade out
|
||||||
|
try? await Task.sleep(for: .seconds(1.2))
|
||||||
private func triggerGlow() {
|
guard !Task.isCancelled else { break }
|
||||||
guard !Theme.Animation.prefersReducedMotion else { return }
|
withAnimation(.easeOut(duration: 1.0)) { glowOpacity = 0 }
|
||||||
|
// Wait before next glow
|
||||||
// Slow fade in
|
try? await Task.sleep(for: .seconds(Double.random(in: 6.0...12.0)))
|
||||||
withAnimation(.easeIn(duration: 0.8)) {
|
}
|
||||||
glowOpacity = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hold briefly then slow fade out
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
||||||
withAnimation(.easeOut(duration: 1.0)) {
|
|
||||||
glowOpacity = 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
// Longer interval between glows
|
glowTask?.cancel()
|
||||||
let nextGlow = Double.random(in: 6.0...12.0)
|
glowTask = nil
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) {
|
|
||||||
triggerGlow()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,83 +29,28 @@ final class ProgressViewModel {
|
|||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private var modelContainer: ModelContainer?
|
private var modelContainer: ModelContainer?
|
||||||
|
private var modelContext: ModelContext?
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Derived State (recomputed after mutations)
|
||||||
|
|
||||||
/// Overall progress for the selected sport
|
/// Overall progress for the selected sport
|
||||||
var leagueProgress: LeagueProgress {
|
private(set) var leagueProgress: LeagueProgress = LeagueProgress(sport: .mlb, totalStadiums: 0, visitedStadiums: 0, stadiumsVisited: [], stadiumsRemaining: [])
|
||||||
// Filter stadiums by sport directly (same as sportStadiums)
|
|
||||||
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
|
||||||
|
|
||||||
let visitedStadiumIds = Set(
|
|
||||||
visits
|
|
||||||
.filter { $0.sportEnum == selectedSport }
|
|
||||||
.compactMap { visit -> String? in
|
|
||||||
// O(1) dictionary lookup via DataProvider
|
|
||||||
dataProvider.stadium(for: visit.stadiumId)?.id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
|
||||||
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
|
||||||
|
|
||||||
return LeagueProgress(
|
|
||||||
sport: selectedSport,
|
|
||||||
totalStadiums: sportStadiums.count,
|
|
||||||
visitedStadiums: visited.count,
|
|
||||||
stadiumsVisited: visited,
|
|
||||||
stadiumsRemaining: remaining
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stadium visit status indexed by stadium ID
|
/// Stadium visit status indexed by stadium ID
|
||||||
var stadiumVisitStatus: [String: StadiumVisitStatus] {
|
private(set) var stadiumVisitStatus: [String: StadiumVisitStatus] = [:]
|
||||||
var statusMap: [String: StadiumVisitStatus] = [:]
|
|
||||||
|
|
||||||
// Group visits by stadium
|
/// Visited stadiums for the selected sport
|
||||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId }
|
private(set) var visitedStadiums: [Stadium] = []
|
||||||
|
|
||||||
for stadium in stadiums {
|
/// Unvisited stadiums for the selected sport
|
||||||
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
private(set) var unvisitedStadiums: [Stadium] = []
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stadiums for the selected sport
|
/// Stadiums for the selected sport
|
||||||
var sportStadiums: [Stadium] {
|
var sportStadiums: [Stadium] {
|
||||||
stadiums.filter { $0.sport == selectedSport }
|
stadiums.filter { $0.sport == selectedSport }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visited stadiums for the selected sport
|
|
||||||
var visitedStadiums: [Stadium] {
|
|
||||||
leagueProgress.stadiumsVisited
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unvisited stadiums for the selected sport
|
|
||||||
var unvisitedStadiums: [Stadium] {
|
|
||||||
leagueProgress.stadiumsRemaining
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count of trips for the selected sport (stub - can be enhanced)
|
/// Count of trips for the selected sport (stub - can be enhanced)
|
||||||
var tripCount: Int {
|
var tripCount: Int {
|
||||||
// TODO: Fetch saved trips count from SwiftData
|
// TODO: Fetch saved trips count from SwiftData
|
||||||
@@ -141,6 +86,7 @@ final class ProgressViewModel {
|
|||||||
|
|
||||||
func configure(with container: ModelContainer) {
|
func configure(with container: ModelContainer) {
|
||||||
self.modelContainer = container
|
self.modelContainer = container
|
||||||
|
self.modelContext = ModelContext(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
@@ -159,8 +105,7 @@ final class ProgressViewModel {
|
|||||||
teams = dataProvider.teams
|
teams = dataProvider.teams
|
||||||
|
|
||||||
// Load visits from SwiftData
|
// Load visits from SwiftData
|
||||||
if let container = modelContainer {
|
if let context = modelContext {
|
||||||
let context = ModelContext(container)
|
|
||||||
let descriptor = FetchDescriptor<StadiumVisit>(
|
let descriptor = FetchDescriptor<StadiumVisit>(
|
||||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||||
)
|
)
|
||||||
@@ -171,11 +116,13 @@ final class ProgressViewModel {
|
|||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recomputeDerivedState()
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectSport(_ sport: Sport) {
|
func selectSport(_ sport: Sport) {
|
||||||
selectedSport = sport
|
selectedSport = sport
|
||||||
|
recomputeDerivedState()
|
||||||
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
|
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,13 +134,12 @@ final class ProgressViewModel {
|
|||||||
// MARK: - Visit Management
|
// MARK: - Visit Management
|
||||||
|
|
||||||
func deleteVisit(_ visit: StadiumVisit) async throws {
|
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||||
guard let container = modelContainer else { return }
|
guard let context = modelContext else { return }
|
||||||
|
|
||||||
if let sport = visit.sportEnum {
|
if let sport = visit.sportEnum {
|
||||||
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
|
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
let context = ModelContext(container)
|
|
||||||
context.delete(visit)
|
context.delete(visit)
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
@@ -201,6 +147,61 @@ final class ProgressViewModel {
|
|||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived State Recomputation
|
||||||
|
|
||||||
|
private func recomputeDerivedState() {
|
||||||
|
// Compute league progress once
|
||||||
|
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||||
|
|
||||||
|
let visitedStadiumIds = Set(
|
||||||
|
visits
|
||||||
|
.filter { $0.sportEnum == selectedSport }
|
||||||
|
.compactMap { visit -> String? in
|
||||||
|
dataProvider.stadium(for: visit.stadiumId)?.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||||
|
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||||
|
|
||||||
|
leagueProgress = LeagueProgress(
|
||||||
|
sport: selectedSport,
|
||||||
|
totalStadiums: sportStadiums.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
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Progress Card Generation
|
// MARK: - Progress Card Generation
|
||||||
|
|
||||||
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
||||||
|
|||||||
@@ -240,14 +240,14 @@ struct SportsIconImageGeneratorView: View {
|
|||||||
isGenerating = true
|
isGenerating = true
|
||||||
|
|
||||||
// Generate on background thread to avoid UI freeze
|
// Generate on background thread to avoid UI freeze
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
Task {
|
||||||
let image = SportsIconImageGenerator.generateImage()
|
let image = await Task.detached(priority: .userInitiated) {
|
||||||
|
SportsIconImageGenerator.generateImage()
|
||||||
|
}.value
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
withAnimation {
|
||||||
withAnimation {
|
generatedImage = image
|
||||||
generatedImage = image
|
isGenerating = false
|
||||||
isGenerating = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,8 @@ struct TripDetailView: View {
|
|||||||
Text(sport.rawValue)
|
Text(sport.rawValue)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(sport.rawValue)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.background(sport.themeColor.opacity(0.2))
|
.background(sport.themeColor.opacity(0.2))
|
||||||
@@ -968,14 +970,7 @@ struct TripDetailView: View {
|
|||||||
private func fetchDrivingRoutes() async {
|
private func fetchDrivingRoutes() async {
|
||||||
// Use routeWaypoints which includes game stops + mappable custom items
|
// Use routeWaypoints which includes game stops + mappable custom items
|
||||||
let waypoints = routeWaypoints
|
let waypoints = routeWaypoints
|
||||||
print("🗺️ [FetchRoutes] Computing routes with \(waypoints.count) waypoints:")
|
guard waypoints.count >= 2 else { return }
|
||||||
for (index, wp) in waypoints.enumerated() {
|
|
||||||
print("🗺️ [FetchRoutes] \(index): \(wp.name) (custom: \(wp.isCustomItem))")
|
|
||||||
}
|
|
||||||
guard waypoints.count >= 2 else {
|
|
||||||
print("🗺️ [FetchRoutes] Not enough waypoints, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoadingRoutes = true
|
isLoadingRoutes = true
|
||||||
var allCoordinates: [[CLLocationCoordinate2D]] = []
|
var allCoordinates: [[CLLocationCoordinate2D]] = []
|
||||||
@@ -1013,7 +1008,6 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
print("🗺️ [FetchRoutes] Setting \(allCoordinates.count) route segments")
|
|
||||||
routeCoordinates = allCoordinates
|
routeCoordinates = allCoordinates
|
||||||
mapUpdateTrigger = UUID() // Force map to re-render with new routes
|
mapUpdateTrigger = UUID() // Force map to re-render with new routes
|
||||||
isLoadingRoutes = false
|
isLoadingRoutes = false
|
||||||
@@ -1051,6 +1045,7 @@ struct TripDetailView: View {
|
|||||||
// Items are ordered by (day, sortOrder) - visual order matches route order
|
// Items are ordered by (day, sortOrder) - visual order matches route order
|
||||||
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }
|
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print("🗺️ [Waypoints] Building waypoints. Mappable items by day:")
|
print("🗺️ [Waypoints] Building waypoints. Mappable items by day:")
|
||||||
for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) {
|
for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) {
|
||||||
for item in items.sorted(by: { $0.sortOrder < $1.sortOrder }) {
|
for item in items.sorted(by: { $0.sortOrder < $1.sortOrder }) {
|
||||||
@@ -1058,11 +1053,14 @@ struct TripDetailView: View {
|
|||||||
print("🗺️ [Waypoints] Day \(day): \(title), sortOrder: \(item.sortOrder)")
|
print("🗺️ [Waypoints] Day \(day): \(title), sortOrder: \(item.sortOrder)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
|
var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
|
||||||
let days = tripDays
|
let days = tripDays
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print("🗺️ [Waypoints] Trip has \(days.count) days")
|
print("🗺️ [Waypoints] Trip has \(days.count) days")
|
||||||
|
#endif
|
||||||
|
|
||||||
for (dayIndex, dayDate) in days.enumerated() {
|
for (dayIndex, dayDate) in days.enumerated() {
|
||||||
let dayNumber = dayIndex + 1
|
let dayNumber = dayIndex + 1
|
||||||
@@ -1077,7 +1075,9 @@ struct TripDetailView: View {
|
|||||||
return day >= arrival && day <= departure
|
return day >= arrival && day <= departure
|
||||||
})?.city
|
})?.city
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print("🗺️ [Waypoints] Day \(dayNumber): city=\(dayCity ?? "none"), games=\(gamesOnDay.count)")
|
print("🗺️ [Waypoints] Day \(dayNumber): city=\(dayCity ?? "none"), games=\(gamesOnDay.count)")
|
||||||
|
#endif
|
||||||
|
|
||||||
// Game stop for this day (only add once per city to avoid duplicates)
|
// Game stop for this day (only add once per city to avoid duplicates)
|
||||||
if let city = dayCity {
|
if let city = dayCity {
|
||||||
@@ -1098,16 +1098,15 @@ struct TripDetailView: View {
|
|||||||
if let stop = trip.stops.first(where: { $0.city == city }) {
|
if let stop = trip.stops.first(where: { $0.city == city }) {
|
||||||
if let stadiumId = stop.stadium,
|
if let stadiumId = stop.stadium,
|
||||||
let stadium = dataProvider.stadium(for: stadiumId) {
|
let stadium = dataProvider.stadium(for: stadiumId) {
|
||||||
print("🗺️ [Waypoints] Adding \(stadium.name) (stadium)")
|
|
||||||
waypoints.append((stadium.name, stadium.coordinate, false))
|
waypoints.append((stadium.name, stadium.coordinate, false))
|
||||||
} else if let coord = stop.coordinate {
|
} else if let coord = stop.coordinate {
|
||||||
// No stadium ID but stop has coordinate
|
|
||||||
print("🗺️ [Waypoints] Adding \(city) (city coord)")
|
|
||||||
waypoints.append((city, coord, false))
|
waypoints.append((city, coord, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
#if DEBUG
|
||||||
print("🗺️ [Waypoints] \(city) already in waypoints, skipping")
|
print("🗺️ [Waypoints] \(city) already in waypoints, skipping")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,7 +1115,6 @@ struct TripDetailView: View {
|
|||||||
let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
|
let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in sortedItems {
|
for item in sortedItems {
|
||||||
if let info = item.customInfo, let coord = info.coordinate {
|
if let info = item.customInfo, let coord = info.coordinate {
|
||||||
print("🗺️ [Waypoints] Adding \(info.title) (sortOrder: \(item.sortOrder))")
|
|
||||||
waypoints.append((info.title, coord, true))
|
waypoints.append((info.title, coord, true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1776,6 +1774,8 @@ struct GameRow: View {
|
|||||||
.padding(Theme.Spacing.sm)
|
.padding(Theme.Spacing.sm)
|
||||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("\(game.game.sport.rawValue): \(game.awayTeam.name) at \(game.homeTeam.name), \(game.stadium.name), \(game.localGameTimeShort)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1994,11 +1994,9 @@ struct TripMapView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let _ = print("🗺️ [TripMapView] Rendering with \(routeCoordinates.count) route segments, version: \(routeVersion)")
|
|
||||||
Map(position: $cameraPosition, interactionModes: []) {
|
Map(position: $cameraPosition, interactionModes: []) {
|
||||||
// Routes (driving directions)
|
// Routes (driving directions)
|
||||||
ForEach(Array(routeCoordinates.enumerated()), id: \.offset) { index, coords in
|
ForEach(Array(routeCoordinates.enumerated()), id: \.offset) { index, coords in
|
||||||
let _ = print("🗺️ [TripMapView] Drawing route \(index) with \(coords.count) points")
|
|
||||||
if !coords.isEmpty {
|
if !coords.isEmpty {
|
||||||
MapPolyline(MKPolyline(coordinates: coords, count: coords.count))
|
MapPolyline(MKPolyline(coordinates: coords, count: coords.count))
|
||||||
.stroke(Theme.routeGold, lineWidth: 4)
|
.stroke(Theme.routeGold, lineWidth: 4)
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ struct BootstrappedContentView: View {
|
|||||||
// 8. App is now usable
|
// 8. App is now usable
|
||||||
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
|
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
|
||||||
isBootstrapping = false
|
isBootstrapping = false
|
||||||
|
UIAccessibility.post(notification: .screenChanged, argument: nil)
|
||||||
|
|
||||||
// 9-10: Background sync (skip in UI test mode)
|
// 9-10: Background sync (skip in UI test mode)
|
||||||
if !ProcessInfo.isUITesting {
|
if !ProcessInfo.isUITesting {
|
||||||
|
|||||||
Reference in New Issue
Block a user