Files
IYmtg/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift
Mike Wichers 13753359b3 fix: Resolve all audit issues from project readiness review
Blockers:
- IYmtgTests: replace ScannerViewModel() (no-arg init removed) with CollectionViewModel() in testViewModelFiltering and testPortfolioCalculation
- IYmtgTests: fix property access — scannedList, librarySearchText, filteredList, portfolioValue all live on CollectionViewModel, not ScannerViewModel; inject test data after async init settles

Major:
- ContentView: update .onChange(of:) to two-parameter closure syntax (iOS 17 deprecation)
- ModelManager: add missing import FirebaseCore so FirebaseApp.app() resolves explicitly
- CollectionViewModel.deleteCard: call CloudEngine.delete(card:) to remove Firebase entry when a card is deleted (prevents data accumulation)
- CloudEngine: remove never-called batchUpdatePrices() — full backup already handled by backupAllToFirebase()

Minor:
- CardDetailView: move to Features/CardDetail/CardDetailView.swift; remove from ContentView.swift
- Delete PersistenceActor.swift placeholder (superseded by PersistenceController.swift)
- AppConfig.validate(): broaden placeholder-email guard to catch empty strings and common fake domains
- ModelManager: document OTA restart requirement in code comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 20:22:45 -05:00

385 lines
16 KiB
Swift

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) }
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 {}
}
}
}