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() private var recalcTask: Task? private var refreshTask: Task? private var saveTask: Task? 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() 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) } Task { await CloudEngine.delete(card: card) } 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 {} } } }