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,109 @@
import Foundation
struct CardFingerprint: Codable, Identifiable, Sendable {
let id: UUID
let name: String
let setCode: String
let collectorNumber: String
let hasFoilPrinting: Bool
let hasSerializedPrinting: Bool?
let featureData: Data
var priceScanned: Double? = nil
}
struct CardMetadata: Identifiable, Sendable {
let id: UUID
let name: String
let setCode: String
let collectorNumber: String
let hasFoilPrinting: Bool
let hasSerializedPrinting: Bool
var priceScanned: Double? = nil
var rarity: String? = nil
var colorIdentity: [String]? = nil
var isSerialized: Bool = false
}
struct SavedCard: Codable, Identifiable, Hashable, Sendable {
let id: UUID
let scryfallID: String
var name: String
var setCode: String
var collectorNumber: String
let 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]?
// Grading Fields
var gradingService: String? // PSA, BGS
var grade: String? // 10, 9.5
var certNumber: String? // 123456
var isCustomValuation: Bool = false
var isSerialized: Bool? = false
var currencyCode: String?
init(from scan: CardMetadata, imageName: String, collection: String, location: String) {
self.id = UUID()
self.scryfallID = "\(scan.setCode)-\(scan.collectorNumber)"
self.name = scan.name
self.setCode = scan.setCode
self.collectorNumber = scan.collectorNumber
self.imageFileName = imageName
self.condition = AppConfig.Defaults.defaultCondition
self.foilType = AppConfig.Defaults.defaultFoil
self.currentValuation = scan.priceScanned
self.previousValuation = scan.priceScanned
self.dateAdded = Date()
self.classification = "Unknown"
self.collectionName = collection
self.storageLocation = location
self.rarity = scan.rarity
self.colorIdentity = scan.colorIdentity
self.isSerialized = scan.isSerialized
}
/// Full memberwise init used by SavedCardModel (SwiftData) SavedCard conversion.
init(id: UUID, scryfallID: String, name: String, setCode: String, collectorNumber: String,
imageFileName: String, condition: String, foilType: String, currentValuation: Double?,
previousValuation: Double?, dateAdded: Date, classification: String, collectionName: String,
storageLocation: String, rarity: String?, colorIdentity: [String]?,
gradingService: String?, grade: String?, certNumber: String?,
isCustomValuation: Bool, isSerialized: Bool?, currencyCode: String?) {
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
}
}
enum MatchResult {
case exact(CardMetadata)
case ambiguous(name: String, candidates: [CardMetadata])
case unknown
}

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
)
}
}