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
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ struct AnimatedSportsIcon: View {
|
||||
let index: Int
|
||||
let animate: Bool
|
||||
@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)] = [
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user