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>
412 lines
13 KiB
Swift
412 lines
13 KiB
Swift
//
|
|
// VisitPhotoService.swift
|
|
// SportsTime
|
|
//
|
|
// Manages visit photos with CloudKit sync for backup.
|
|
// Thumbnails stored locally in SwiftData for fast loading.
|
|
// Full images stored in CloudKit private database.
|
|
//
|
|
|
|
import Foundation
|
|
import CloudKit
|
|
import SwiftData
|
|
import UIKit
|
|
|
|
// MARK: - Photo Service Errors
|
|
|
|
enum PhotoServiceError: Error, LocalizedError {
|
|
case notSignedIn
|
|
case uploadFailed(String)
|
|
case downloadFailed(String)
|
|
case thumbnailGenerationFailed
|
|
case invalidImage
|
|
case assetNotFound
|
|
case quotaExceeded
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notSignedIn:
|
|
return "Please sign in to iCloud to sync photos"
|
|
case .uploadFailed(let message):
|
|
return "Upload failed: \(message)"
|
|
case .downloadFailed(let message):
|
|
return "Download failed: \(message)"
|
|
case .thumbnailGenerationFailed:
|
|
return "Could not generate thumbnail"
|
|
case .invalidImage:
|
|
return "Invalid image data"
|
|
case .assetNotFound:
|
|
return "Photo not found in cloud storage"
|
|
case .quotaExceeded:
|
|
return "iCloud storage quota exceeded"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Visit Photo Service
|
|
|
|
@MainActor
|
|
final class VisitPhotoService {
|
|
|
|
// MARK: - Properties
|
|
|
|
private let modelContext: ModelContext
|
|
private let container: CKContainer
|
|
private let privateDatabase: CKDatabase
|
|
|
|
// Configuration
|
|
private static let thumbnailSize = CGSize(width: 200, height: 200)
|
|
private static let compressionQuality: CGFloat = 0.7
|
|
private static let recordType = "VisitPhoto"
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(modelContext: ModelContext) {
|
|
self.modelContext = modelContext
|
|
self.container = CloudKitContainerConfig.makeContainer()
|
|
self.privateDatabase = container.privateCloudDatabase
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Add a photo to a visit
|
|
/// - Parameters:
|
|
/// - visit: The visit to add the photo to
|
|
/// - image: The UIImage to add
|
|
/// - caption: Optional caption for the photo
|
|
/// - Returns: The created photo metadata
|
|
func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String? = nil) async throws -> VisitPhotoMetadata {
|
|
// Generate thumbnail
|
|
guard let thumbnail = generateThumbnail(from: image) else {
|
|
throw PhotoServiceError.thumbnailGenerationFailed
|
|
}
|
|
|
|
guard let thumbnailData = thumbnail.jpegData(compressionQuality: Self.compressionQuality) else {
|
|
throw PhotoServiceError.thumbnailGenerationFailed
|
|
}
|
|
|
|
// Get current photo count for order index
|
|
let orderIndex = visit.photoMetadata?.count ?? 0
|
|
|
|
// Create metadata record
|
|
let metadata = VisitPhotoMetadata(
|
|
visitId: visit.id,
|
|
cloudKitAssetId: nil,
|
|
thumbnailData: thumbnailData,
|
|
caption: caption,
|
|
orderIndex: orderIndex,
|
|
uploadStatus: .pending
|
|
)
|
|
|
|
// Add to visit
|
|
if visit.photoMetadata == nil {
|
|
visit.photoMetadata = []
|
|
}
|
|
visit.photoMetadata?.append(metadata)
|
|
|
|
modelContext.insert(metadata)
|
|
try modelContext.save()
|
|
|
|
// Queue background upload
|
|
Task {
|
|
await self.uploadPhoto(metadata: metadata, image: image)
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
/// Fetch full-resolution image for a photo
|
|
/// - Parameter metadata: The photo metadata
|
|
/// - Returns: The full-resolution UIImage
|
|
func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage {
|
|
guard let assetId = metadata.cloudKitAssetId else {
|
|
throw PhotoServiceError.assetNotFound
|
|
}
|
|
|
|
let recordID = CKRecord.ID(recordName: assetId)
|
|
|
|
do {
|
|
let record = try await privateDatabase.record(for: recordID)
|
|
|
|
guard let asset = record["imageAsset"] as? CKAsset,
|
|
let fileURL = asset.fileURL,
|
|
let data = try? Data(contentsOf: fileURL),
|
|
let image = UIImage(data: data) else {
|
|
throw PhotoServiceError.downloadFailed("Could not read image data")
|
|
}
|
|
|
|
return image
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
}
|
|
}
|
|
|
|
/// Delete a photo from visit and CloudKit
|
|
/// - Parameter metadata: The photo metadata to delete
|
|
func deletePhoto(_ metadata: VisitPhotoMetadata) async throws {
|
|
// Delete from CloudKit if uploaded
|
|
if let assetId = metadata.cloudKitAssetId {
|
|
let recordID = CKRecord.ID(recordName: assetId)
|
|
do {
|
|
try await privateDatabase.deleteRecord(withID: recordID)
|
|
} catch {
|
|
// Continue with local deletion even if CloudKit fails
|
|
}
|
|
}
|
|
|
|
// Delete from SwiftData
|
|
modelContext.delete(metadata)
|
|
try modelContext.save()
|
|
}
|
|
|
|
/// Retry uploading failed photos
|
|
func retryFailedUploads() async {
|
|
let descriptor = FetchDescriptor<VisitPhotoMetadata>(
|
|
predicate: #Predicate { $0.uploadStatusRaw == "failed" || $0.uploadStatusRaw == "pending" }
|
|
)
|
|
|
|
do {
|
|
let pendingPhotos = try modelContext.fetch(descriptor)
|
|
|
|
for metadata in pendingPhotos {
|
|
// We can't upload without the original image
|
|
// Mark as failed permanently if no thumbnail
|
|
if metadata.thumbnailData == nil {
|
|
metadata.uploadStatus = .failed
|
|
}
|
|
}
|
|
|
|
try modelContext.save()
|
|
} catch {
|
|
// Silently fail - will retry on next launch
|
|
}
|
|
}
|
|
|
|
/// Get upload status summary
|
|
func getUploadStatus() -> (pending: Int, uploaded: Int, failed: Int) {
|
|
let descriptor = FetchDescriptor<VisitPhotoMetadata>()
|
|
|
|
do {
|
|
let all = try modelContext.fetch(descriptor)
|
|
|
|
let pending = all.filter { $0.uploadStatus == .pending }.count
|
|
let uploaded = all.filter { $0.uploadStatus == .uploaded }.count
|
|
let failed = all.filter { $0.uploadStatus == .failed }.count
|
|
|
|
return (pending, uploaded, failed)
|
|
} catch {
|
|
return (0, 0, 0)
|
|
}
|
|
}
|
|
|
|
/// Check if CloudKit is available for photo sync
|
|
func isCloudKitAvailable() async -> Bool {
|
|
do {
|
|
let status = try await container.accountStatus()
|
|
return status == .available
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// 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
|
|
}
|
|
|
|
// Check CloudKit availability
|
|
do {
|
|
let status = try await container.accountStatus()
|
|
guard status == .available else {
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
metadata.uploadStatus = .failed
|
|
try? modelContext.save()
|
|
return
|
|
}
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
metadata.uploadStatus = .failed
|
|
try? modelContext.save()
|
|
return
|
|
}
|
|
|
|
// Create CloudKit record
|
|
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
|
|
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
|
|
|
|
do {
|
|
let asset = CKAsset(fileURL: tempURL)
|
|
record["imageAsset"] = asset
|
|
record["visitId"] = metadata.visitId.uuidString
|
|
record["caption"] = metadata.caption
|
|
record["orderIndex"] = metadata.orderIndex as CKRecordValue
|
|
|
|
// Upload to CloudKit
|
|
let savedRecord = try await privateDatabase.save(record)
|
|
|
|
// Clean up temp file
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
|
|
// Update metadata
|
|
metadata.cloudKitAssetId = savedRecord.recordID.recordName
|
|
metadata.uploadStatus = .uploaded
|
|
try? modelContext.save()
|
|
|
|
} catch {
|
|
// Clean up temp file
|
|
try? FileManager.default.removeItem(at: tempURL)
|
|
|
|
metadata.uploadStatus = .failed
|
|
try? modelContext.save()
|
|
}
|
|
}
|
|
|
|
private func generateThumbnail(from image: UIImage) -> UIImage? {
|
|
let size = Self.thumbnailSize
|
|
let aspectRatio = image.size.width / image.size.height
|
|
|
|
let targetSize: CGSize
|
|
if aspectRatio > 1 {
|
|
// Landscape
|
|
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
|
|
} else {
|
|
// Portrait or square
|
|
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
|
|
}
|
|
|
|
let renderer = UIGraphicsImageRenderer(size: targetSize)
|
|
return renderer.image { context in
|
|
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
|
}
|
|
}
|
|
|
|
private func mapCloudKitError(_ error: CKError) -> PhotoServiceError {
|
|
switch error.code {
|
|
case .notAuthenticated:
|
|
return .notSignedIn
|
|
case .quotaExceeded:
|
|
return .quotaExceeded
|
|
case .unknownItem:
|
|
return .assetNotFound
|
|
default:
|
|
return .downloadFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Photo Gallery View Model
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class PhotoGalleryViewModel {
|
|
var photos: [VisitPhotoMetadata] = []
|
|
var selectedPhoto: VisitPhotoMetadata?
|
|
var fullResolutionImage: UIImage?
|
|
var isLoadingFullImage = false
|
|
var error: PhotoServiceError?
|
|
|
|
private let photoService: VisitPhotoService
|
|
private let visit: StadiumVisit
|
|
|
|
init(visit: StadiumVisit, modelContext: ModelContext) {
|
|
self.visit = visit
|
|
self.photoService = VisitPhotoService(modelContext: modelContext)
|
|
loadPhotos()
|
|
}
|
|
|
|
func loadPhotos() {
|
|
photos = (visit.photoMetadata ?? []).sorted { $0.orderIndex < $1.orderIndex }
|
|
}
|
|
|
|
func addPhoto(_ image: UIImage, caption: String? = nil) async {
|
|
do {
|
|
let metadata = try await photoService.addPhoto(to: visit, image: image, caption: caption)
|
|
photos.append(metadata)
|
|
photos.sort { $0.orderIndex < $1.orderIndex }
|
|
} catch let error as PhotoServiceError {
|
|
self.error = error
|
|
} catch {
|
|
self.error = .uploadFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func selectPhoto(_ metadata: VisitPhotoMetadata) {
|
|
selectedPhoto = metadata
|
|
loadFullResolution(for: metadata)
|
|
}
|
|
|
|
func loadFullResolution(for metadata: VisitPhotoMetadata) {
|
|
guard metadata.cloudKitAssetId != nil else {
|
|
// Photo not uploaded yet, use thumbnail
|
|
if let data = metadata.thumbnailData {
|
|
fullResolutionImage = UIImage(data: data)
|
|
}
|
|
return
|
|
}
|
|
|
|
isLoadingFullImage = true
|
|
Task {
|
|
do {
|
|
let image = try await photoService.fetchFullImage(for: metadata)
|
|
fullResolutionImage = image
|
|
} catch let error as PhotoServiceError {
|
|
self.error = error
|
|
// Fall back to thumbnail
|
|
if let data = metadata.thumbnailData {
|
|
fullResolutionImage = UIImage(data: data)
|
|
}
|
|
} catch {
|
|
self.error = .downloadFailed(error.localizedDescription)
|
|
}
|
|
isLoadingFullImage = false
|
|
}
|
|
}
|
|
|
|
func deletePhoto(_ metadata: VisitPhotoMetadata) async {
|
|
do {
|
|
try await photoService.deletePhoto(metadata)
|
|
photos.removeAll { $0.id == metadata.id }
|
|
if selectedPhoto?.id == metadata.id {
|
|
selectedPhoto = nil
|
|
fullResolutionImage = nil
|
|
}
|
|
} catch let error as PhotoServiceError {
|
|
self.error = error
|
|
} catch {
|
|
self.error = .uploadFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func clearError() {
|
|
error = nil
|
|
}
|
|
}
|