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

@@ -1,116 +1,9 @@
import Foundation
import Vision
import CoreGraphics
import ImageIO
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
}
}
class FeatureMatcher {
static let revision = VNGenerateImageFeaturePrintRequest.Revision.revision1
static func generateFingerprint(from image: CGImage, orientation: CGImagePropertyOrientation = .up) async throws -> VNFeaturePrintObservation {
let req = VNGenerateImageFeaturePrintRequest()
req.revision = revision
req.imageCropAndScaleOption = .scaleFill
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
try handler.perform([req])
guard let result = req.results?.first as? VNFeaturePrintObservation else {
throw NSError(domain: "FeatureMatcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "No features detected"])
}
return result
}
static func identify(scan: VNFeaturePrintObservation, database: [CardMetadata], cache: [UUID: VNFeaturePrintObservation]) -> MatchResult {
var candidates: [(CardMetadata, Float)] = []
for card in database {
guard let obs = cache[card.id] else { continue }
var dist: Float = 0
if (try? scan.computeDistance(&dist, to: obs)) != nil && dist < 18.0 {
candidates.append((card, dist))
}
}
let sorted = candidates.sorted { $0.1 < $1.1 }
guard let best = sorted.first else { return .unknown }
if best.1 < 6.0 { return .exact(best.0) }
let close = sorted.filter { $0.1 < (best.1 + 3.0) }
if close.count > 1 { return .ambiguous(name: best.0.name, candidates: close.map { $0.0 }) }
return .exact(best.0)
}
}
enum MatchResult {
case exact(CardMetadata)
case ambiguous(name: String, candidates: [CardMetadata])
case unknown
}
// ============================================================
// MIGRATION NOTICE This file has been refactored.
// All code has been moved to the new modular structure.
// Remove this file from your Xcode project and add:
//
// Data/Models/Card.swift CardFingerprint, CardMetadata,
// SavedCard, MatchResult
// Services/Vision/FeatureMatcher.swift FeatureMatcher
// ============================================================