Files
Sportstime/SportsTime/Core/Services/VisitPhotoService.swift
Trey t 9b0cb96638 fix: 10 audit fixes — memory safety, performance, accessibility, architecture
- Add a11y label to ProgressMapView reset button and progress bar values
- Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit
- Add [weak self] to PhotoGalleryViewModel Task closure
- Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop
- Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated()
- Cache itinerarySections via ItinerarySectionBuilder static extraction + @State
- Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class
- Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:00:35 -06:00

411 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.detached { [weak self] in
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
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()
}
return
}
// Check CloudKit availability
do {
let status = try await container.accountStatus()
guard status == .available else {
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
return
}
} catch {
await MainActor.run {
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)
// 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
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
await MainActor.run {
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()
}
}
}
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 { [weak self] in
do {
let image = try await self?.photoService.fetchFullImage(for: metadata)
self?.fullResolutionImage = image
} catch let error as PhotoServiceError {
self?.error = error
// Fall back to thumbnail
if let data = metadata.thumbnailData {
self?.fullResolutionImage = UIImage(data: data)
}
} catch {
self?.error = .downloadFailed(error.localizedDescription)
}
self?.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
}
}