From 5511e07538684801e182a2d35a9717decadaa29b Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 18 Feb 2026 22:09:06 -0600 Subject: [PATCH] =?UTF-8?q?fix:=2013=20audit=20fixes=20=E2=80=94=20memory,?= =?UTF-8?q?=20concurrency,=20performance,=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SportsTime/Core/Models/Domain/Game.swift | 48 +++++-- .../Core/Services/ItineraryItemService.swift | 6 +- .../Core/Services/VisitPhotoService.swift | 93 ++++++------ SportsTime/Core/Store/StoreManager.swift | 6 +- .../Core/Theme/AnimatedBackground.swift | 50 +++---- .../ViewModels/ProgressViewModel.swift | 135 +++++++++--------- .../Settings/SportsIconImageGenerator.swift | 14 +- .../Features/Trip/Views/TripDetailView.swift | 28 ++-- SportsTime/SportsTimeApp.swift | 1 + 9 files changed, 196 insertions(+), 185 deletions(-) diff --git a/SportsTime/Core/Models/Domain/Game.swift b/SportsTime/Core/Models/Domain/Game.swift index 32303d6..2aad0aa 100644 --- a/SportsTime/Core/Models/Domain/Game.swift +++ b/SportsTime/Core/Models/Domain/Game.swift @@ -17,6 +17,36 @@ 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 { let id: String // Canonical ID: "game_mlb_2026_bos_nyy_0401" let homeTeamId: String // FK: "team_mlb_bos" @@ -58,21 +88,15 @@ struct Game: Identifiable, Codable, Hashable { var startTime: Date { dateTime } var gameTime: String { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter.string(from: dateTime) + GameFormatters.timeFormatter.string(from: dateTime) } var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter.string(from: dateTime) + GameFormatters.dateFormatter.string(from: dateTime) } var dayOfWeek: String { - let formatter = DateFormatter() - formatter.dateFormat = "EEEE" - return formatter.string(from: dateTime) + GameFormatters.dayOfWeekFormatter.string(from: dateTime) } } @@ -106,16 +130,14 @@ struct RichGame: Identifiable, Hashable, Codable { /// Game time formatted in the stadium's local timezone with timezone indicator var localGameTime: String { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a z" + let formatter = GameFormatters.localTimeFormatter formatter.timeZone = stadium.timeZone ?? .current return formatter.string(from: game.dateTime) } /// Game time formatted in the stadium's local timezone without timezone indicator var localGameTimeShort: String { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" + let formatter = GameFormatters.localTimeShortFormatter formatter.timeZone = stadium.timeZone ?? .current return formatter.string(from: game.dateTime) } diff --git a/SportsTime/Core/Services/ItineraryItemService.swift b/SportsTime/Core/Services/ItineraryItemService.swift index 1a5f37c..2fe091b 100644 --- a/SportsTime/Core/Services/ItineraryItemService.swift +++ b/SportsTime/Core/Services/ItineraryItemService.swift @@ -56,12 +56,12 @@ actor ItineraryItemService { debounceTask?.cancel() // Start new debounce task with proper cancellation handling - debounceTask = Task { [weak self] in + debounceTask = Task { // Check cancellation before sleeping guard !Task.isCancelled else { return } do { - try await Task.sleep(for: self?.debounceInterval ?? .seconds(1.5)) + try await Task.sleep(for: debounceInterval) } catch { // Task was cancelled during sleep return @@ -70,7 +70,7 @@ actor ItineraryItemService { // Check cancellation after sleeping (belt and suspenders) guard !Task.isCancelled else { return } - await self?.flushPendingUpdates() + await flushPendingUpdates() } } diff --git a/SportsTime/Core/Services/VisitPhotoService.swift b/SportsTime/Core/Services/VisitPhotoService.swift index 7a7f548..8d097b7 100644 --- a/SportsTime/Core/Services/VisitPhotoService.swift +++ b/SportsTime/Core/Services/VisitPhotoService.swift @@ -108,8 +108,8 @@ final class VisitPhotoService { try modelContext.save() // Queue background upload - Task { [weak self] in - await self?.uploadPhoto(metadata: metadata, image: image) + Task { + await self.uploadPhoto(metadata: metadata, image: image) } return metadata @@ -211,12 +211,34 @@ final class VisitPhotoService { // 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 { - guard let imageData = image.jpegData(compressionQuality: Self.compressionQuality) else { - await MainActor.run { - metadata.uploadStatus = .failed - try? modelContext.save() - } + // Capture MainActor-isolated value before entering detached context + let quality = Self.compressionQuality + + // 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 } @@ -224,17 +246,15 @@ final class VisitPhotoService { do { let status = try await container.accountStatus() guard status == .available else { - await MainActor.run { - metadata.uploadStatus = .failed - try? modelContext.save() - } + try? FileManager.default.removeItem(at: tempURL) + metadata.uploadStatus = .failed + try? modelContext.save() return } } catch { - await MainActor.run { - metadata.uploadStatus = .failed - try? modelContext.save() - } + try? FileManager.default.removeItem(at: tempURL) + metadata.uploadStatus = .failed + try? modelContext.save() return } @@ -242,14 +262,7 @@ final class VisitPhotoService { let recordID = CKRecord.ID(recordName: metadata.id.uuidString) 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 { - try imageData.write(to: tempURL) - let asset = CKAsset(fileURL: tempURL) record["imageAsset"] = asset record["visitId"] = metadata.visitId.uuidString @@ -263,28 +276,16 @@ final class VisitPhotoService { try? FileManager.default.removeItem(at: tempURL) // Update metadata - await MainActor.run { - metadata.cloudKitAssetId = savedRecord.recordID.recordName - metadata.uploadStatus = .uploaded - try? modelContext.save() - } + metadata.cloudKitAssetId = savedRecord.recordID.recordName + metadata.uploadStatus = .uploaded + 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 { // Clean up temp file try? FileManager.default.removeItem(at: tempURL) - await MainActor.run { - metadata.uploadStatus = .failed - try? modelContext.save() - } + metadata.uploadStatus = .failed + try? modelContext.save() } } @@ -372,20 +373,20 @@ final class PhotoGalleryViewModel { } isLoadingFullImage = true - Task { [weak self] in + Task { do { - let image = try await self?.photoService.fetchFullImage(for: metadata) - self?.fullResolutionImage = image + let image = try await photoService.fetchFullImage(for: metadata) + fullResolutionImage = image } catch let error as PhotoServiceError { - self?.error = error + self.error = error // Fall back to thumbnail if let data = metadata.thumbnailData { - self?.fullResolutionImage = UIImage(data: data) + fullResolutionImage = UIImage(data: data) } } catch { - self?.error = .downloadFailed(error.localizedDescription) + self.error = .downloadFailed(error.localizedDescription) } - self?.isLoadingFullImage = false + isLoadingFullImage = false } } diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift index 258aaa8..4da84f1 100644 --- a/SportsTime/Core/Store/StoreManager.swift +++ b/SportsTime/Core/Store/StoreManager.swift @@ -296,11 +296,7 @@ final class StoreManager { for await result in Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() - await MainActor.run { - Task { - await StoreManager.shared.updateEntitlements() - } - } + await StoreManager.shared.updateEntitlements() } } } diff --git a/SportsTime/Core/Theme/AnimatedBackground.swift b/SportsTime/Core/Theme/AnimatedBackground.swift index 175c7be..67d6f54 100644 --- a/SportsTime/Core/Theme/AnimatedBackground.swift +++ b/SportsTime/Core/Theme/AnimatedBackground.swift @@ -109,6 +109,7 @@ struct AnimatedSportsIcon: View { let index: Int let animate: Bool @State private var glowOpacity: Double = 0 + @State private var glowTask: Task? static let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [ // Edge icons @@ -162,37 +163,28 @@ struct AnimatedSportsIcon: View { ) } .onAppear { - startRandomGlow() - } - } - - private func startRandomGlow() { - let initialDelay = Double.random(in: 2.0...8.0) - - DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { - triggerGlow() - } - } - - private func triggerGlow() { - guard !Theme.Animation.prefersReducedMotion else { return } - - // Slow fade in - 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 + glowTask = Task { @MainActor in + // Random initial delay + try? await Task.sleep(for: .seconds(Double.random(in: 2.0...8.0))) + while !Task.isCancelled { + guard !Theme.Animation.prefersReducedMotion else { + try? await Task.sleep(for: .seconds(6.0)) + continue + } + // Slow fade in + withAnimation(.easeIn(duration: 0.8)) { glowOpacity = 1 } + // Hold briefly then slow fade out + try? await Task.sleep(for: .seconds(1.2)) + guard !Task.isCancelled else { break } + withAnimation(.easeOut(duration: 1.0)) { glowOpacity = 0 } + // Wait before next glow + try? await Task.sleep(for: .seconds(Double.random(in: 6.0...12.0))) + } } } - - // Longer interval between glows - let nextGlow = Double.random(in: 6.0...12.0) - DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) { - triggerGlow() + .onDisappear { + glowTask?.cancel() + glowTask = nil } } } diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index 61db598..d554c22 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -29,83 +29,28 @@ final class ProgressViewModel { // MARK: - Dependencies private var modelContainer: ModelContainer? + private var modelContext: ModelContext? private let dataProvider = AppDataProvider.shared - // MARK: - Computed Properties + // MARK: - Derived State (recomputed after mutations) /// Overall progress for the selected sport - var leagueProgress: LeagueProgress { - // 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 - ) - } + private(set) var leagueProgress: LeagueProgress = LeagueProgress(sport: .mlb, totalStadiums: 0, visitedStadiums: 0, stadiumsVisited: [], stadiumsRemaining: []) /// Stadium visit status indexed by stadium ID - var stadiumVisitStatus: [String: StadiumVisitStatus] { - var statusMap: [String: StadiumVisitStatus] = [:] + private(set) var stadiumVisitStatus: [String: StadiumVisitStatus] = [:] - // Group visits by stadium - let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId } + /// Visited stadiums for the selected sport + private(set) var visitedStadiums: [Stadium] = [] - 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 - } - } - - return statusMap - } + /// Unvisited stadiums for the selected sport + private(set) var unvisitedStadiums: [Stadium] = [] /// Stadiums for the selected sport var sportStadiums: [Stadium] { 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) var tripCount: Int { // TODO: Fetch saved trips count from SwiftData @@ -141,6 +86,7 @@ final class ProgressViewModel { func configure(with container: ModelContainer) { self.modelContainer = container + self.modelContext = ModelContext(container) } // MARK: - Actions @@ -159,8 +105,7 @@ final class ProgressViewModel { teams = dataProvider.teams // Load visits from SwiftData - if let container = modelContainer { - let context = ModelContext(container) + if let context = modelContext { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.visitDate, order: .reverse)] ) @@ -171,11 +116,13 @@ final class ProgressViewModel { self.errorMessage = error.localizedDescription } + recomputeDerivedState() isLoading = false } func selectSport(_ sport: Sport) { selectedSport = sport + recomputeDerivedState() AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue)) } @@ -187,13 +134,12 @@ final class ProgressViewModel { // MARK: - Visit Management func deleteVisit(_ visit: StadiumVisit) async throws { - guard let container = modelContainer else { return } + guard let context = modelContext else { return } if let sport = visit.sportEnum { AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue)) } - let context = ModelContext(container) context.delete(visit) try context.save() @@ -201,6 +147,61 @@ final class ProgressViewModel { 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 func progressCardData(includeUsername: Bool = false) -> ProgressCardData { diff --git a/SportsTime/Features/Settings/SportsIconImageGenerator.swift b/SportsTime/Features/Settings/SportsIconImageGenerator.swift index db51904..650a8e4 100644 --- a/SportsTime/Features/Settings/SportsIconImageGenerator.swift +++ b/SportsTime/Features/Settings/SportsIconImageGenerator.swift @@ -240,14 +240,14 @@ struct SportsIconImageGeneratorView: View { isGenerating = true // Generate on background thread to avoid UI freeze - DispatchQueue.global(qos: .userInitiated).async { - let image = SportsIconImageGenerator.generateImage() + Task { + let image = await Task.detached(priority: .userInitiated) { + SportsIconImageGenerator.generateImage() + }.value - DispatchQueue.main.async { - withAnimation { - generatedImage = image - isGenerating = false - } + withAnimation { + generatedImage = image + isGenerating = false } } } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index fe23bee..bfbffe0 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -457,6 +457,8 @@ struct TripDetailView: View { Text(sport.rawValue) .font(.caption2) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(sport.rawValue) .padding(.horizontal, 10) .padding(.vertical, 5) .background(sport.themeColor.opacity(0.2)) @@ -968,14 +970,7 @@ struct TripDetailView: View { private func fetchDrivingRoutes() async { // Use routeWaypoints which includes game stops + mappable custom items let waypoints = routeWaypoints - print("🗺️ [FetchRoutes] Computing routes with \(waypoints.count) waypoints:") - 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 - } + guard waypoints.count >= 2 else { return } isLoadingRoutes = true var allCoordinates: [[CLLocationCoordinate2D]] = [] @@ -1013,7 +1008,6 @@ struct TripDetailView: View { } await MainActor.run { - print("🗺️ [FetchRoutes] Setting \(allCoordinates.count) route segments") routeCoordinates = allCoordinates mapUpdateTrigger = UUID() // Force map to re-render with new routes isLoadingRoutes = false @@ -1051,6 +1045,7 @@ struct TripDetailView: View { // Items are ordered by (day, sortOrder) - visual order matches route order let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day } + #if DEBUG print("🗺️ [Waypoints] Building waypoints. Mappable items by day:") for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) { 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)") } } + #endif var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = [] let days = tripDays + #if DEBUG print("🗺️ [Waypoints] Trip has \(days.count) days") + #endif for (dayIndex, dayDate) in days.enumerated() { let dayNumber = dayIndex + 1 @@ -1077,7 +1075,9 @@ struct TripDetailView: View { return day >= arrival && day <= departure })?.city + #if DEBUG print("🗺️ [Waypoints] Day \(dayNumber): city=\(dayCity ?? "none"), games=\(gamesOnDay.count)") + #endif // Game stop for this day (only add once per city to avoid duplicates) if let city = dayCity { @@ -1098,16 +1098,15 @@ struct TripDetailView: View { if let stop = trip.stops.first(where: { $0.city == city }) { if let stadiumId = stop.stadium, let stadium = dataProvider.stadium(for: stadiumId) { - print("🗺️ [Waypoints] Adding \(stadium.name) (stadium)") waypoints.append((stadium.name, stadium.coordinate, false)) } else if let coord = stop.coordinate { - // No stadium ID but stop has coordinate - print("🗺️ [Waypoints] Adding \(city) (city coord)") waypoints.append((city, coord, false)) } } } else { + #if DEBUG print("🗺️ [Waypoints] \(city) already in waypoints, skipping") + #endif } } @@ -1116,7 +1115,6 @@ struct TripDetailView: View { let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder } for item in sortedItems { if let info = item.customInfo, let coord = info.coordinate { - print("🗺️ [Waypoints] Adding \(info.title) (sortOrder: \(item.sortOrder))") waypoints.append((info.title, coord, true)) } } @@ -1776,6 +1774,8 @@ struct GameRow: View { .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) .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 { - let _ = print("🗺️ [TripMapView] Rendering with \(routeCoordinates.count) route segments, version: \(routeVersion)") Map(position: $cameraPosition, interactionModes: []) { // Routes (driving directions) ForEach(Array(routeCoordinates.enumerated()), id: \.offset) { index, coords in - let _ = print("🗺️ [TripMapView] Drawing route \(index) with \(coords.count) points") if !coords.isEmpty { MapPolyline(MKPolyline(coordinates: coords, count: coords.count)) .stroke(Theme.routeGold, lineWidth: 4) diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index b464682..9e33f37 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -250,6 +250,7 @@ struct BootstrappedContentView: View { // 8. App is now usable print("🚀 [BOOT] Step 8: Bootstrap complete - app ready") isBootstrapping = false + UIAccessibility.post(notification: .screenChanged, argument: nil) // 9-10: Background sync (skip in UI test mode) if !ProcessInfo.isUITesting {