import SwiftData import Foundation // MARK: - BACKGROUND PERSISTENCE ACTOR // Performs all SwiftData I/O on a dedicated background context. // CloudKit synchronization is handled automatically by the ModelContainer configuration. // @ModelActor provides a ModelContext bound to this actor's executor (background thread). @ModelActor actor BackgroundPersistenceActor { // MARK: - Load func load() -> [SavedCard] { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.dateAdded, order: .reverse)] ) let models = (try? modelContext.fetch(descriptor)) ?? [] return models.map { $0.toSavedCard() } } // MARK: - Save (diff-based to minimise CloudKit writes) func save(_ cards: [SavedCard]) { do { let existing = try modelContext.fetch(FetchDescriptor()) let existingMap = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) }) let incomingIDs = Set(cards.map { $0.id }) // Delete cards no longer in collection for model in existing where !incomingIDs.contains(model.id) { modelContext.delete(model) } // Update existing or insert new for card in cards { if let model = existingMap[card.id] { model.update(from: card) } else { modelContext.insert(SavedCardModel(from: card)) } } try modelContext.save() } catch { print("BackgroundPersistenceActor save error: \(error)") } } // MARK: - One-time JSON Migration /// Imports cards from the legacy user_collection.json file into SwiftData. /// Runs once on first launch after the SwiftData upgrade; subsequent calls are no-ops. /// Also triggers image migration from the flat Documents/ layout to Documents/UserContent/. func migrateFromJSONIfNeeded() { let migrationKey = "swiftdata_migration_complete_v1" guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } let iCloudDocuments = FileManager.default .url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") let localDocuments = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask)[0] let candidates: [URL] = [ iCloudDocuments?.appendingPathComponent("user_collection.json"), localDocuments.appendingPathComponent("user_collection.json") ].compactMap { $0 } for jsonURL in candidates { guard FileManager.default.fileExists(atPath: jsonURL.path), let data = try? Data(contentsOf: jsonURL), let legacyCards = try? JSONDecoder().decode([SavedCard].self, from: data), !legacyCards.isEmpty else { continue } let existingIDs: Set do { let existing = try modelContext.fetch(FetchDescriptor()) existingIDs = Set(existing.map { $0.id }) } catch { existingIDs = [] } var imported = 0 for card in legacyCards where !existingIDs.contains(card.id) { modelContext.insert(SavedCardModel(from: card)) imported += 1 } try? modelContext.save() // Rename old JSON — keeps it as a fallback without re-triggering import let migratedURL = jsonURL.deletingLastPathComponent() .appendingPathComponent("user_collection.migrated.json") try? FileManager.default.moveItem(at: jsonURL, to: migratedURL) print("MIGRATION: Imported \(imported) cards from JSON into SwiftData.") ImageManager.migrateImagesToUserContent() break } UserDefaults.standard.set(true, forKey: migrationKey) } } // MARK: - PERSISTENCE CONTROLLER // Owns and provides access to the SwiftData ModelContainer. // The container is configured for automatic iCloud CloudKit synchronization. // // XCODE SETUP REQUIRED (one-time): // 1. Signing & Capabilities → iCloud → enable CloudKit // 2. Add a CloudKit container: "iCloud." // 3. Signing & Capabilities → Background Modes → enable "Remote notifications" // Without this setup the app runs in local-only mode (no cross-device sync). @MainActor final class PersistenceController { static let shared = PersistenceController() let container: ModelContainer let backgroundActor: BackgroundPersistenceActor private init() { let schema = Schema([SavedCardModel.self]) let resolvedContainer: ModelContainer do { // .automatic derives the CloudKit container from the app bundle ID. // Degrades gracefully to local storage if iCloud is unavailable. let config = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .automatic ) resolvedContainer = try ModelContainer(for: schema, configurations: [config]) } catch { print("⚠️ PersistenceController: CloudKit init failed (\(error)). Using local storage.") do { let fallback = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) resolvedContainer = try ModelContainer(for: schema, configurations: [fallback]) } catch let fatal { fatalError("PersistenceController: Cannot create ModelContainer. Error: \(fatal)") } } self.container = resolvedContainer self.backgroundActor = BackgroundPersistenceActor(modelContainer: resolvedContainer) } }