// // 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 { [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( 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() 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 } }