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>
385 lines
16 KiB
Swift
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 {}
|
|
}
|
|
}
|
|
}
|