Implement storage architecture from ai_blueprint.md

Primary sync: replace PersistenceActor JSON file with SwiftData + CloudKit
- Add SavedCardModel (@Model class) and PersistenceController (ModelContainer
  with .automatic CloudKit, fallback to local). BackgroundPersistenceActor
  (@ModelActor) handles all DB I/O off the main thread.
- One-time migration imports user_collection.json into SwiftData and renames
  the original file to prevent re-import.
- Inject modelContainer into SwiftUI environment in IYmtgApp.

Image storage: Documents/UserContent/ subfolder (blueprint requirement)
- ImageManager.dir now targets iCloud Documents/UserContent/ (or local equiv).
- migrateImagesToUserContent() moves existing JPGs to the new subfolder on
  first launch; called during the SwiftData migration.

Firebase: demoted to optional manual backup (metadata only, no images)
- Remove all automatic CloudEngine.save/delete/batchUpdatePrices calls from
  CollectionViewModel mutations.
- Add backupAllToFirebase() for user-triggered metadata sync.
- Add isFirebaseBackupEnabled to AppConfig (default false).
- Add Cloud Backup section in Library settings with iCloud vs Firebase
  explanation and "Backup Metadata to Firebase Now" button.

Also: full modular refactor (Data/, Features/, Services/ directories) and
README updated with CloudKit setup steps and revised release checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:13:17 -05:00
parent b993ef4020
commit 24dcb44af4
38 changed files with 2786 additions and 2105 deletions

View File

@@ -0,0 +1,70 @@
import FirebaseFirestore
import FirebaseAuth
// MARK: - CLOUD ENGINE
class CloudEngine {
static var db: Firestore? {
return FirebaseApp.app() != nil ? Firestore.firestore() : nil
}
static func signInSilently() {
guard FirebaseApp.app() != nil else { return }
if Auth.auth().currentUser == nil { Auth.auth().signInAnonymously() }
}
static func save(card: SavedCard) async {
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
let data: [String: Any] = [
"name": card.name,
"set": card.setCode,
"num": card.collectorNumber,
"val": card.currentValuation ?? 0.0,
"cond": card.condition,
"foil": card.foilType,
"coll": card.collectionName,
"loc": card.storageLocation,
"grade": card.grade ?? "",
"svc": card.gradingService ?? "",
"cert": card.certNumber ?? "",
"date": card.dateAdded,
"curr": card.currencyCode ?? "USD",
"rarity": card.rarity ?? "",
"colors": card.colorIdentity ?? [],
"ser": card.isSerialized ?? false,
"img": card.imageFileName,
"custom": card.isCustomValuation
]
do {
try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).setData(data, merge: true)
} catch { print("Cloud Save Error: \(error)") }
}
static func delete(card: SavedCard) async {
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
do {
try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).delete()
} catch { print("Cloud Delete Error: \(error)") }
}
static func batchUpdatePrices(cards: [SavedCard]) async {
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
let ref = db.collection("users").document(uid).collection("inventory")
let chunks = stride(from: 0, to: cards.count, by: 400).map { Array(cards[$0..<min($0 + 400, cards.count)]) }
for chunk in chunks {
let batch = db.batch()
for card in chunk {
let data: [String: Any] = [
"val": card.currentValuation ?? 0.0,
"curr": card.currencyCode ?? "USD",
"rarity": card.rarity ?? "",
"colors": card.colorIdentity ?? []
]
batch.setData(data, forDocument: ref.document(card.id.uuidString), merge: true)
}
do {
try await batch.commit()
} catch { print("Batch Update Error: \(error)") }
}
}
}