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,15 @@
import Network
import Combine
import Foundation
// MARK: - NETWORK MONITOR
class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
@Published var isConnected = true
init() {
monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.isConnected = path.status == .satisfied } }
monitor.start(queue: DispatchQueue.global(qos: .background))
}
}

View File

@@ -0,0 +1,121 @@
import Foundation
// MARK: - SCRYFALL THROTTLER
actor ScryfallThrottler {
static let shared = ScryfallThrottler()
private var nextAllowedTime = Date.distantPast
func wait() async {
let now = Date()
let targetTime = max(now, nextAllowedTime)
nextAllowedTime = targetTime.addingTimeInterval(0.1)
let waitTime = targetTime.timeIntervalSince(now)
if waitTime > 0 {
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
}
}
// MARK: - SCRYFALL API (formerly InsuranceEngine)
class ScryfallAPI {
struct PricingIdentifier: Codable { let set: String; let collector_number: String }
struct ScryfallData {
let price: Double?
let typeLine: String
let rarity: String?
let colors: [String]?
let isSerialized: Bool
}
static func getPriceKey(foilType: String, currency: CurrencyCode) -> String {
if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" }
let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil
let base = currency.scryfallKey
return isFoil ? "\(base)_foil" : base
}
static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) {
if !NetworkMonitor.shared.isConnected { return ([:], [:]) }
let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast
let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400
// Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh)
let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil }
if cardsToFetch.isEmpty { return ([:], [:]) }
var updates: [UUID: Double] = [:]
var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:]
let marketCards = cardsToFetch.filter { !$0.isCustomValuation }
let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0..<min($0 + 75, marketCards.count)]) }
for chunk in chunks {
await ScryfallThrottler.shared.wait()
let identifiers = chunk.map { PricingIdentifier(set: $0.setCode, collector_number: $0.collectorNumber) }
guard let body = try? JSONEncoder().encode(["identifiers": identifiers]) else { continue }
var request = URLRequest(url: URL(string: "https://api.scryfall.com/cards/collection")!)
request.httpMethod = "POST"
request.httpBody = body
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent")
request.addValue("application/json", forHTTPHeaderField: "Accept")
if let (data, response) = try? await URLSession.shared.data(for: request),
(response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = json["data"] as? [[String: Any]] {
for cardData in dataArray {
if let set = cardData["set"] as? String, let num = cardData["collector_number"] as? String,
let prices = cardData["prices"] as? [String: Any] {
let matchingCards = chunk.filter { $0.setCode.caseInsensitiveCompare(set) == .orderedSame && $0.collectorNumber == num }
for card in matchingCards {
let priceKey = getPriceKey(foilType: card.foilType, currency: currency)
// Capture Metadata (Backfill)
let rarity = cardData["rarity"] as? String
let colors = cardData["color_identity"] as? [String]
let promoTypes = cardData["promo_types"] as? [String]
let isSer = promoTypes?.contains("serialized") ?? false
metadataUpdates[card.id] = (rarity, colors, isSer)
if let newPriceStr = prices[priceKey] as? String,
let newPrice = Double(newPriceStr),
!newPrice.isNaN, !newPrice.isInfinite {
updates[card.id] = newPrice
}
}
}
}
}
}
if (force || isCacheExpired) && updateTimestamp { UserDefaults.standard.set(Date(), forKey: "LastPriceUpdate") }
return (updates, metadataUpdates)
}
static func fetchPrice(setCode: String, number: String, foilType: String, currency: CurrencyCode) async -> ScryfallData {
if !NetworkMonitor.shared.isConnected { return ScryfallData(price: nil, typeLine: "Offline", rarity: nil, colors: nil, isSerialized: false) }
await ScryfallThrottler.shared.wait()
let encodedSet = setCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? setCode
let encodedNum = number.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? number
guard let url = URL(string: "https://api.scryfall.com/cards/\(encodedSet)/\(encodedNum)") else { return ScryfallData(price: nil, typeLine: "Error", rarity: nil, colors: nil, isSerialized: false) }
var request = URLRequest(url: url)
request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent")
guard let (data, _) = try? await URLSession.shared.data(for: request),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScryfallData(price: nil, typeLine: "Unknown", rarity: nil, colors: nil, isSerialized: false) }
let prices = json["prices"] as? [String: Any]
let priceKey = getPriceKey(foilType: foilType, currency: currency)
let price = Double(prices?[priceKey] as? String ?? "")
let rarity = json["rarity"] as? String
let colors = json["color_identity"] as? [String]
let promoTypes = json["promo_types"] as? [String]
let isSer = promoTypes?.contains("serialized") ?? false
return ScryfallData(price: price, typeLine: (json["type_line"] as? String) ?? "", rarity: rarity, colors: colors, isSerialized: isSer)
}
}