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,24 @@
import Foundation
import UIKit
// MARK: - SECURE DEV MODE
class DevEngine {
static var isDevMode = false
static func activateIfCompiled() {
#if ENABLE_DEV_MODE
isDevMode = true
print("⚠️ DEV MODE COMPILED")
#endif
}
static func saveRaw(image: UIImage, label: String) {
#if ENABLE_DEV_MODE
if isDevMode, let data = image.jpegData(compressionQuality: 1.0) {
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("RawTrainingData")
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
try? data.write(to: path.appendingPathComponent("TRAIN_\(label)_\(UUID().uuidString).jpg"))
}
#endif
}
}

View File

@@ -0,0 +1,135 @@
import UIKit
import PDFKit
enum ExportFormat: String, CaseIterable { case insurance = "PDF"; case arena = "Arena"; case mtgo = "MTGO"; case csv = "CSV" }
// MARK: - EXPORT ENGINE
class ExportEngine {
static func generateString(cards: [SavedCard], format: ExportFormat) -> String {
if format == .csv {
func cleanCSV(_ text: String) -> String { return text.replacingOccurrences(of: "\"", with: "\"\"") }
var csv = "Count,Name,Set,Number,Condition,Foil,Price,Serialized\n"
for card in cards {
let ser = (card.isSerialized ?? false) ? "Yes" : "No"
let line = "1,\"\(cleanCSV(card.name))\",\(card.setCode),\(card.collectorNumber),\(card.condition),\(card.foilType),\(card.currentValuation ?? 0.0),\(ser)\n"
csv.append(line)
}
return csv
} else if format == .mtgo {
return cards.map { "1 \($0.name)" }.joined(separator: "\n")
} else {
// Arena
return cards.map { "1 \($0.name) (\($0.setCode)) \($0.collectorNumber)" }.joined(separator: "\n")
}
}
static func generate(cards: [SavedCard], format: ExportFormat) -> URL? {
if format == .insurance { return generatePDF(cards: cards) }
let text = generateString(cards: cards, format: format)
let filename = format == .csv ? "Collection.csv" : (format == .mtgo ? "MTGO_List.txt" : "Arena_Decklist.txt")
let path = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
try? text.write(to: path, atomically: true, encoding: .utf8)
return path
}
private static func generatePDF(cards: [SavedCard]) -> URL? {
let fmt = UIGraphicsPDFRendererFormat()
fmt.documentInfo = [kCGPDFContextCreator: "IYmtg"] as [String: Any]
let url = FileManager.default.temporaryDirectory.appendingPathComponent("Insurance.pdf")
let totalVal = cards.reduce(0) { $0 + ($1.currentValuation ?? 0) }
let count = cards.count
UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792), format: fmt).writePDF(to: url) { ctx in
var pageY: CGFloat = 50
let headerAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 24)]
let subHeaderAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14)]
let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)]
func drawHeader() {
"Insurance Schedule - \(Date().formatted(date: .abbreviated, time: .shortened))".draw(at: CGPoint(x: 50, y: 50), withAttributes: headerAttrs)
}
func drawColumnHeaders(y: CGFloat) {
let hAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: UIColor.darkGray]
"CARD DETAILS".draw(at: CGPoint(x: 100, y: y), withAttributes: hAttrs)
"SET / #".draw(at: CGPoint(x: 310, y: y), withAttributes: hAttrs)
"COND".draw(at: CGPoint(x: 420, y: y), withAttributes: hAttrs)
"VALUE".draw(at: CGPoint(x: 520, y: y), withAttributes: hAttrs)
}
ctx.beginPage()
drawHeader()
// Summary Section
"Policy Holder: __________________________".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
"Total Items: \(count)".draw(at: CGPoint(x: 50, y: 115), withAttributes: subHeaderAttrs)
"Total Value: \(cards.first?.currencyCode == "EUR" ? "" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 200, y: 115), withAttributes: subHeaderAttrs)
drawColumnHeaders(y: 135)
pageY = 150
for card in cards {
if pageY + 65 > 740 {
ctx.beginPage()
drawHeader()
drawColumnHeaders(y: 75)
pageY = 90
}
// Column 0: Image
if let img = ImageManager.load(name: card.imageFileName) {
img.draw(in: CGRect(x: 50, y: pageY, width: 40, height: 56))
}
let textY = pageY + 20
// Column 1: Name (Truncate if long)
let name = "\(card.name)\((card.isSerialized ?? false) ? " [S]" : "")"
name.draw(in: CGRect(x: 100, y: textY, width: 200, height: 18), withAttributes: textAttrs)
// Column 2: Set
let setInfo = "\(card.setCode.uppercased()) #\(card.collectorNumber)"
setInfo.draw(at: CGPoint(x: 310, y: textY), withAttributes: textAttrs)
// Column 3: Condition (Short code)
let cond = card.condition.components(separatedBy: "(").last?.replacingOccurrences(of: ")", with: "") ?? card.condition
cond.draw(at: CGPoint(x: 420, y: textY), withAttributes: textAttrs)
// Column 4: Value
let val = "\(card.currencyCode == "EUR" ? "" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))"
val.draw(at: CGPoint(x: 520, y: textY), withAttributes: textAttrs)
pageY += 65
}
// Condensed Summary Section
ctx.beginPage()
drawHeader()
"Condensed Manifest".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
pageY = 120
for card in cards {
if pageY > 740 {
ctx.beginPage()
drawHeader()
"Condensed Manifest (Cont.)".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
pageY = 120
}
let line = "\(card.name) (\(card.setCode) #\(card.collectorNumber))"
line.draw(in: CGRect(x: 50, y: pageY, width: 400, height: 18), withAttributes: textAttrs)
let val = "\(card.currencyCode == "EUR" ? "" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))"
val.draw(at: CGPoint(x: 500, y: pageY), withAttributes: textAttrs)
pageY += 20
}
// FIX: Ensure Grand Total doesn't fall off the page
if pageY + 40 > 792 {
ctx.beginPage()
drawHeader()
pageY = 90
}
"GRAND TOTAL: \(cards.first?.currencyCode == "EUR" ? "" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 400, y: pageY + 20), withAttributes: subHeaderAttrs)
}
return url
}
}

View File

@@ -0,0 +1,17 @@
import StoreKit
import UIKit
// MARK: - REVIEW ENGINE
class ReviewEngine {
static func logScan() {
let count = UserDefaults.standard.integer(forKey: "scanCount") + 1
UserDefaults.standard.set(count, forKey: "scanCount")
if count == 20 || count == 100 {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
SKStoreReviewController.requestReview(in: scene)
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
import StoreKit
@MainActor
class StoreEngine: ObservableObject {
@Published var products: [Product] = []
@Published var showThankYou = false
var transactionListener: Task<Void, Never>? = nil
init() {
// FIX: Added [weak self] to prevent retain cycle
transactionListener = Task.detached { [weak self] in
for await result in Transaction.updates {
do {
guard let self = self else { return }
let transaction = try self.checkVerified(result)
await transaction.finish()
await MainActor.run { self.showThankYou = true }
} catch {}
}
}
}
deinit { transactionListener?.cancel() }
func loadProducts() async {
do {
let p = try await Product.products(for: AppConfig.tipJarProductIDs)
self.products = p
#if DEBUG
if p.isEmpty { print("⚠️ StoreEngine: No products found. Verify IAP ID in AppConfig.") }
#endif
} catch { print("Store Error: \(error)") }
}
func purchase(_ product: Product) async {
guard let result = try? await product.purchase() else { return }
switch result {
case .success(let verification):
if let transaction = try? checkVerified(verification) {
await transaction.finish()
self.showThankYou = true
}
case .pending, .userCancelled: break
@unknown default: break
}
}
nonisolated func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified: throw StoreError.failedVerification
case .verified(let safe): return safe
}
}
enum StoreError: Error { case failedVerification }
}