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:
Trey t
2026-02-18 22:09:06 -06:00
parent 20ac1a7e59
commit 5511e07538
9 changed files with 196 additions and 185 deletions

View File

@@ -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
}
}