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,156 @@
import SwiftData
import Foundation
// MARK: - SAVED CARD MODEL
// SwiftData @Model class for structured persistence with automatic CloudKit sync.
// Mirrors the SavedCard value-type struct so the rest of the app stays unchanged.
// Requires iOS 17+. Set minimum deployment target to iOS 17 in Xcode.
@Model
final class SavedCardModel {
@Attribute(.unique) var id: UUID
var scryfallID: String
var name: String
var setCode: String
var collectorNumber: String
var imageFileName: String
var condition: String
var foilType: String
var currentValuation: Double?
var previousValuation: Double?
var dateAdded: Date
var classification: String
var collectionName: String
var storageLocation: String
var rarity: String?
var colorIdentity: [String]?
var gradingService: String?
var grade: String?
var certNumber: String?
var isCustomValuation: Bool
var isSerialized: Bool?
var currencyCode: String?
init(
id: UUID = UUID(),
scryfallID: String,
name: String,
setCode: String,
collectorNumber: String,
imageFileName: String,
condition: String,
foilType: String,
currentValuation: Double? = nil,
previousValuation: Double? = nil,
dateAdded: Date = Date(),
classification: String = "Unknown",
collectionName: String,
storageLocation: String,
rarity: String? = nil,
colorIdentity: [String]? = nil,
gradingService: String? = nil,
grade: String? = nil,
certNumber: String? = nil,
isCustomValuation: Bool = false,
isSerialized: Bool? = false,
currencyCode: String? = nil
) {
self.id = id
self.scryfallID = scryfallID
self.name = name
self.setCode = setCode
self.collectorNumber = collectorNumber
self.imageFileName = imageFileName
self.condition = condition
self.foilType = foilType
self.currentValuation = currentValuation
self.previousValuation = previousValuation
self.dateAdded = dateAdded
self.classification = classification
self.collectionName = collectionName
self.storageLocation = storageLocation
self.rarity = rarity
self.colorIdentity = colorIdentity
self.gradingService = gradingService
self.grade = grade
self.certNumber = certNumber
self.isCustomValuation = isCustomValuation
self.isSerialized = isSerialized
self.currencyCode = currencyCode
}
convenience init(from card: SavedCard) {
self.init(
id: card.id,
scryfallID: card.scryfallID,
name: card.name,
setCode: card.setCode,
collectorNumber: card.collectorNumber,
imageFileName: card.imageFileName,
condition: card.condition,
foilType: card.foilType,
currentValuation: card.currentValuation,
previousValuation: card.previousValuation,
dateAdded: card.dateAdded,
classification: card.classification,
collectionName: card.collectionName,
storageLocation: card.storageLocation,
rarity: card.rarity,
colorIdentity: card.colorIdentity,
gradingService: card.gradingService,
grade: card.grade,
certNumber: card.certNumber,
isCustomValuation: card.isCustomValuation,
isSerialized: card.isSerialized,
currencyCode: card.currencyCode
)
}
func update(from card: SavedCard) {
name = card.name
setCode = card.setCode
collectorNumber = card.collectorNumber
condition = card.condition
foilType = card.foilType
currentValuation = card.currentValuation
previousValuation = card.previousValuation
classification = card.classification
collectionName = card.collectionName
storageLocation = card.storageLocation
rarity = card.rarity
colorIdentity = card.colorIdentity
gradingService = card.gradingService
grade = card.grade
certNumber = card.certNumber
isCustomValuation = card.isCustomValuation
isSerialized = card.isSerialized
currencyCode = card.currencyCode
}
func toSavedCard() -> SavedCard {
SavedCard(
id: id,
scryfallID: scryfallID,
name: name,
setCode: setCode,
collectorNumber: collectorNumber,
imageFileName: imageFileName,
condition: condition,
foilType: foilType,
currentValuation: currentValuation,
previousValuation: previousValuation,
dateAdded: dateAdded,
classification: classification,
collectionName: collectionName,
storageLocation: storageLocation,
rarity: rarity,
colorIdentity: colorIdentity,
gradingService: gradingService,
grade: grade,
certNumber: certNumber,
isCustomValuation: isCustomValuation,
isSerialized: isSerialized,
currencyCode: currencyCode
)
}
}