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:
383
IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift
Normal file
383
IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
enum SortOption: String, CaseIterable {
|
||||
case dateAdded = "Date Added"
|
||||
case valueHighLow = "Value (High-Low)"
|
||||
case nameAZ = "Name (A-Z)"
|
||||
}
|
||||
|
||||
// MARK: - COLLECTION VIEW MODEL
|
||||
// Owns all collection state: list management, filtering, sorting, pricing, persistence.
|
||||
// Primary sync: SwiftData + CloudKit (via PersistenceController).
|
||||
// Secondary backup: Firebase — user-triggered, metadata only, no images.
|
||||
@MainActor
|
||||
class CollectionViewModel: ObservableObject {
|
||||
@Published var scannedList: [SavedCard] = [] {
|
||||
didSet { recalcStats() }
|
||||
}
|
||||
@Published var collections: [String] = [AppConfig.Defaults.masterCollectionName]
|
||||
@Published var boxes: [String] = [AppConfig.Defaults.unsortedBoxName]
|
||||
|
||||
@Published var currentCollection: String = AppConfig.Defaults.masterCollectionName {
|
||||
didSet { librarySearchText = ""; recalcStats() }
|
||||
}
|
||||
@Published var currentBox: String = AppConfig.Defaults.unsortedBoxName
|
||||
@Published var sortOption: SortOption = .dateAdded
|
||||
@Published var librarySearchText = "" {
|
||||
didSet { recalcStats() }
|
||||
}
|
||||
@Published var isConnected = true
|
||||
@Published var selectedCurrency: CurrencyCode = AppConfig.defaultCurrency
|
||||
|
||||
@Published var filteredList: [SavedCard] = []
|
||||
@Published var totalValue: Double = 0.0
|
||||
|
||||
// Dashboard Stats
|
||||
@Published var portfolioValue: Double = 0.0
|
||||
@Published var portfolioDailyChange: Double = 0.0
|
||||
@Published var topMovers: [SavedCard] = []
|
||||
|
||||
@Published var isCollectionLoading = true
|
||||
@Published var isRefreshingPrices = false
|
||||
@Published var isBackingUpToFirebase = false
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var recalcTask: Task<Void, Never>?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
NetworkMonitor.shared.$isConnected
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] status in self?.isConnected = status }
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task { [weak self] in
|
||||
// One-time JSON → SwiftData migration, then load
|
||||
await PersistenceController.shared.backgroundActor.migrateFromJSONIfNeeded()
|
||||
let loaded = await PersistenceController.shared.backgroundActor.load()
|
||||
guard let self = self else { return }
|
||||
self.scannedList = loaded
|
||||
self.isCollectionLoading = false
|
||||
|
||||
let loadedCollections = Set(loaded.map { $0.collectionName })
|
||||
let loadedBoxes = Set(loaded.map { $0.storageLocation })
|
||||
self.collections = Array(loadedCollections.union([AppConfig.Defaults.masterCollectionName])).sorted()
|
||||
self.boxes = Array(loadedBoxes.union([AppConfig.Defaults.unsortedBoxName])).sorted()
|
||||
|
||||
Task.detached {
|
||||
let activeImages = Set(loaded.map { $0.imageFileName })
|
||||
ImageManager.cleanupOrphans(activeImages: activeImages)
|
||||
}
|
||||
self.refreshPrices(force: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats & Filtering
|
||||
|
||||
func recalcStats() {
|
||||
let currentColl = self.currentCollection
|
||||
let searchText = self.librarySearchText.lowercased()
|
||||
let sort = self.sortOption
|
||||
let sourceList = self.scannedList
|
||||
|
||||
recalcTask?.cancel()
|
||||
recalcTask = Task.detached { [weak self] in
|
||||
let filtered = sourceList.filter { card in
|
||||
(searchText.isEmpty ||
|
||||
card.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
card.setCode.localizedCaseInsensitiveContains(searchText) ||
|
||||
card.collectorNumber.localizedCaseInsensitiveContains(searchText)) &&
|
||||
(currentColl == AppConfig.Defaults.masterCollectionName || card.collectionName == currentColl)
|
||||
}
|
||||
|
||||
let sorted: [SavedCard]
|
||||
switch sort {
|
||||
case .dateAdded: sorted = filtered.sorted { $0.dateAdded > $1.dateAdded }
|
||||
case .valueHighLow: sorted = filtered.sorted { ($0.currentValuation ?? 0) > ($1.currentValuation ?? 0) }
|
||||
case .nameAZ: sorted = filtered.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
let total = filtered.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||
|
||||
let globalTotal = sourceList.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||
let globalPrev = sourceList.reduce(0) { $0 + ($1.previousValuation ?? $1.currentValuation ?? 0) }
|
||||
let dailyChange = globalTotal - globalPrev
|
||||
|
||||
let movers = sourceList.filter { ($0.currentValuation ?? 0) != ($0.previousValuation ?? 0) }
|
||||
.sorted { abs(($0.currentValuation ?? 0) - ($0.previousValuation ?? 0)) > abs(($1.currentValuation ?? 0) - ($1.previousValuation ?? 0)) }
|
||||
.prefix(5)
|
||||
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.filteredList = sorted
|
||||
self.totalValue = total
|
||||
self.portfolioValue = globalTotal
|
||||
self.portfolioDailyChange = dailyChange
|
||||
self.topMovers = Array(movers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pricing
|
||||
|
||||
func refreshPrices(force: Bool = false) {
|
||||
refreshTask?.cancel()
|
||||
self.isRefreshingPrices = true
|
||||
let cardsSnapshot = self.scannedList
|
||||
let currencySnapshot = self.selectedCurrency
|
||||
|
||||
refreshTask = Task.detached { [weak self] in
|
||||
let (priceUpdates, metadataUpdates) = await ScryfallAPI.updateTrends(cards: cardsSnapshot, currency: currencySnapshot, force: force)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.isRefreshingPrices = false
|
||||
if priceUpdates.isEmpty && metadataUpdates.isEmpty { return }
|
||||
|
||||
var listCopy = self.scannedList
|
||||
var changedIndices = Set<Int>()
|
||||
var hasChanges = false
|
||||
|
||||
var indexMap = [UUID: Int]()
|
||||
for (index, card) in listCopy.enumerated() { indexMap[card.id] = index }
|
||||
|
||||
for (id, newPrice) in priceUpdates {
|
||||
if let index = indexMap[id] {
|
||||
let oldCurrency = listCopy[index].currencyCode
|
||||
let oldPrice = listCopy[index].currentValuation
|
||||
|
||||
if oldCurrency != currencySnapshot.rawValue {
|
||||
listCopy[index].previousValuation = newPrice
|
||||
listCopy[index].currentValuation = newPrice
|
||||
listCopy[index].currencyCode = currencySnapshot.rawValue
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
} else if oldPrice != newPrice {
|
||||
listCopy[index].previousValuation = oldPrice
|
||||
listCopy[index].currentValuation = newPrice
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (id, meta) in metadataUpdates {
|
||||
if let index = indexMap[id] {
|
||||
if listCopy[index].rarity != meta.0 || listCopy[index].colorIdentity != meta.1 || (meta.2 && listCopy[index].isSerialized != true) {
|
||||
listCopy[index].rarity = meta.0
|
||||
listCopy[index].colorIdentity = meta.1
|
||||
if meta.2 { listCopy[index].isSerialized = true }
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
self.scannedList = listCopy
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collection Mutations
|
||||
|
||||
func addCard(_ entry: SavedCard, cgImage: CGImage, uiImage: UIImage, autoScan: Bool) {
|
||||
self.scannedList.insert(entry, at: 0)
|
||||
self.saveCollectionAsync()
|
||||
ReviewEngine.logScan()
|
||||
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveCard") {
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
do {
|
||||
try ImageManager.save(uiImage, name: entry.imageFileName)
|
||||
} catch {
|
||||
print("Warning: Image Save Failed")
|
||||
}
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
if entry.currentValuation == nil {
|
||||
let currency = await MainActor.run { self.selectedCurrency }
|
||||
let (prices, metadata) = await ScryfallAPI.updateTrends(cards: [entry], currency: currency, force: true, updateTimestamp: false)
|
||||
if let newPrice = prices[entry.id] {
|
||||
await MainActor.run {
|
||||
guard let self = self else { return }
|
||||
if let idx = self.scannedList.firstIndex(where: { $0.id == entry.id }) {
|
||||
self.scannedList[idx].currentValuation = newPrice
|
||||
if let meta = metadata[entry.id] {
|
||||
self.scannedList[idx].rarity = meta.0
|
||||
self.scannedList[idx].colorIdentity = meta.1
|
||||
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||
}
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateCardDetails(_ card: SavedCard) {
|
||||
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
scannedList[idx] = card
|
||||
|
||||
if !collections.contains(card.collectionName) { collections.append(card.collectionName); collections.sort() }
|
||||
if !boxes.contains(card.storageLocation) { boxes.append(card.storageLocation); boxes.sort() }
|
||||
|
||||
self.saveCollectionAsync()
|
||||
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "UpdateCard") {
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
guard let self = self else { return }
|
||||
let currency = await MainActor.run { self.selectedCurrency }
|
||||
let (prices, _) = await ScryfallAPI.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
||||
if let newPrice = prices[card.id] {
|
||||
await MainActor.run {
|
||||
guard let self = self else { return }
|
||||
if let i = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
self.scannedList[i].currentValuation = newPrice
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveCard(_ card: SavedCard, toCollection: String, toBox: String) {
|
||||
if let idx = scannedList.firstIndex(of: card) {
|
||||
scannedList[idx].collectionName = toCollection
|
||||
scannedList[idx].storageLocation = toBox
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCard(_ card: SavedCard) {
|
||||
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
let fileName = scannedList[idx].imageFileName
|
||||
Task { await ImageManager.delete(name: fileName) }
|
||||
scannedList.remove(at: idx)
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
|
||||
func saveManualCard(_ card: SavedCard) {
|
||||
self.scannedList.insert(card, at: 0)
|
||||
self.saveCollectionAsync()
|
||||
ReviewEngine.logScan()
|
||||
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveManual") {
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
guard let self = self else { return }
|
||||
let currency = await MainActor.run { self.selectedCurrency }
|
||||
let (prices, metadata) = await ScryfallAPI.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
||||
|
||||
if let newPrice = prices[card.id] {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let idx = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
self.scannedList[idx].currentValuation = newPrice
|
||||
if let meta = metadata[card.id] {
|
||||
self.scannedList[idx].rarity = meta.0
|
||||
self.scannedList[idx].colorIdentity = meta.1
|
||||
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||
}
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export
|
||||
|
||||
func exportCollection(format: ExportFormat) async -> URL? {
|
||||
let list = self.scannedList
|
||||
return await Task.detached { ExportEngine.generate(cards: list, format: format) }.value
|
||||
}
|
||||
|
||||
func copyToClipboard(format: ExportFormat) {
|
||||
let text = ExportEngine.generateString(cards: self.scannedList, format: format)
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
|
||||
func generateShareImage(for card: SavedCard) async -> UIImage? {
|
||||
let currencySymbol = self.selectedCurrency.symbol
|
||||
return await Task.detached {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1080, height: 1080))
|
||||
return renderer.image { ctx in
|
||||
UIColor.black.setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: 1080, height: 1080))
|
||||
if let img = ImageManager.load(name: card.imageFileName) { img.draw(in: CGRect(x: 140, y: 180, width: 800, height: 800)) }
|
||||
if let watermark = UIImage(named: "share_watermark") { watermark.draw(in: CGRect(x: 340, y: 990, width: 400, height: 80)) }
|
||||
else { "Verified by IYmtg".draw(at: CGPoint(x: 100, y: 1000), withAttributes: [.font: UIFont.systemFont(ofSize: 40), .foregroundColor: UIColor.gray]) }
|
||||
let price = "\(currencySymbol)\(card.currentValuation ?? 0.0)" as NSString
|
||||
price.draw(at: CGPoint(x: 100, y: 40), withAttributes: [.font: UIFont.systemFont(ofSize: 120, weight: .bold), .foregroundColor: UIColor.green])
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - Firebase Backup (manual, metadata only)
|
||||
|
||||
/// Pushes all card metadata to Firebase Firestore as a secondary disaster-recovery backup.
|
||||
/// Does NOT include card images (those remain in iCloud Drive).
|
||||
/// Primary sync is handled automatically by SwiftData + CloudKit.
|
||||
func backupAllToFirebase() {
|
||||
guard !isBackingUpToFirebase else { return }
|
||||
isBackingUpToFirebase = true
|
||||
let snapshot = self.scannedList
|
||||
|
||||
Task { [weak self] in
|
||||
CloudEngine.signInSilently()
|
||||
for card in snapshot {
|
||||
await CloudEngine.save(card: card)
|
||||
}
|
||||
await MainActor.run { [weak self] in
|
||||
self?.isBackingUpToFirebase = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
func forceSave() {
|
||||
saveTask?.cancel()
|
||||
let listSnapshot = self.scannedList
|
||||
Task { await PersistenceController.shared.backgroundActor.save(listSnapshot) }
|
||||
}
|
||||
|
||||
func saveCollectionAsync() {
|
||||
saveTask?.cancel()
|
||||
let listSnapshot = self.scannedList
|
||||
saveTask = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
if !Task.isCancelled {
|
||||
await PersistenceController.shared.backgroundActor.save(listSnapshot)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user