776 lines
35 KiB
Swift
776 lines
35 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import Vision
|
|
import AVFoundation
|
|
import AudioToolbox
|
|
import Combine
|
|
import os
|
|
import ImageIO
|
|
import CoreMedia
|
|
import CoreImage
|
|
|
|
enum SortOption: String, CaseIterable {
|
|
case dateAdded = "Date Added"
|
|
case valueHighLow = "Value (High-Low)"
|
|
case nameAZ = "Name (A-Z)"
|
|
}
|
|
|
|
@MainActor
|
|
class ScannerViewModel: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
@Published var statusText = "Initializing Database..."
|
|
@Published var isDatabaseLoading = true
|
|
@Published var isCollectionLoading = true
|
|
@Published var detectedCard: CardMetadata?
|
|
@Published var scannedList: [SavedCard] = [] {
|
|
didSet { recalcStats() }
|
|
}
|
|
@Published var detectedDamages: [DamageObservation] = []
|
|
@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 currentFoilType: String = "None"
|
|
@Published var isProcessing = false
|
|
@Published var isFound = false
|
|
@Published var isPermissionDenied = false
|
|
@Published var isSaving = false
|
|
@Published var isTorchOn = false
|
|
@Published var isAutoScanEnabled: Bool = UserDefaults.standard.bool(forKey: "isAutoScanEnabled") {
|
|
didSet { UserDefaults.standard.set(isAutoScanEnabled, forKey: "isAutoScanEnabled") }
|
|
}
|
|
|
|
@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] = []
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
var currentFrameImage: CGImage?
|
|
private var recalcTask: Task<Void, Never>?
|
|
private var refreshTask: Task<Void, Never>?
|
|
private var processingTask: Task<Void, Never>?
|
|
private let analyzer = AnalysisActor()
|
|
public let session = AVCaptureSession()
|
|
private let sessionQueue = DispatchQueue(label: "com.iymtg.sessionQueue")
|
|
private let foilEngine = FoilEngine()
|
|
private var lastFrameTime = Date.distantPast
|
|
private var lastSaveTime = Date.distantPast
|
|
|
|
private let successHaptics = UINotificationFeedbackGenerator()
|
|
private let detectHaptics = UIImpactFeedbackGenerator(style: .light)
|
|
private var saveTask: Task<Void, Never>?
|
|
private let processingLock = OSAllocatedUnfairLock(initialState: false)
|
|
private var isScanningActive = false
|
|
|
|
private var isSessionConfigured = false
|
|
private var focusResetTask: Task<Void, Never>?
|
|
|
|
override init() {
|
|
super.init()
|
|
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
Task(priority: .background) { let _ = ConditionEngine.model; let _ = FoilEngine.model }
|
|
NetworkMonitor.shared.$isConnected.receive(on: RunLoop.main).sink { [weak self] status in self?.isConnected = status }.store(in: &cancellables)
|
|
|
|
Task { [weak self] in
|
|
let loaded = await PersistenceActor.shared.load()
|
|
guard let self = self else { return }
|
|
self.scannedList = loaded
|
|
self.isCollectionLoading = false
|
|
|
|
// Derive collections and boxes from loaded data
|
|
let loadedCollections = Set(loaded.map { $0.collectionName })
|
|
let loadedBoxes = Set(loaded.map { $0.storageLocation })
|
|
await MainActor.run { [weak self] in
|
|
guard let self = self else { return }
|
|
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)
|
|
}
|
|
CloudEngine.signInSilently()
|
|
DevEngine.activateIfCompiled()
|
|
ModelManager.shared.checkForUpdates()
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
guard let self = self else { return }
|
|
if let url = Bundle.main.url(forResource: "cards", withExtension: "json") {
|
|
do {
|
|
await self.analyzer.loadDatabase(from: url)
|
|
await MainActor.run { [weak self] in
|
|
guard let self = self else { return }
|
|
self.isDatabaseLoading = false
|
|
self.statusText = "Ready to Scan"
|
|
self.checkCameraPermissions()
|
|
}
|
|
} catch { await MainActor.run { [weak self] in self?.statusText = "Error: Database Corrupt" } }
|
|
} else { await MainActor.run { [weak self] in self?.statusText = "Database Missing" } }
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
|
recalcTask?.cancel()
|
|
refreshTask?.cancel()
|
|
processingTask?.cancel()
|
|
saveTask?.cancel()
|
|
focusResetTask?.cancel()
|
|
}
|
|
|
|
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) }
|
|
|
|
// Dashboard Calculation (Global)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func refreshPrices(force: Bool = false) {
|
|
refreshTask?.cancel()
|
|
let cardsSnapshot = self.scannedList
|
|
let currencySnapshot = self.selectedCurrency
|
|
|
|
refreshTask = Task.detached { [weak self] in
|
|
let (priceUpdates, metadataUpdates) = await InsuranceEngine.updateTrends(cards: cardsSnapshot, currency: currencySnapshot, force: force)
|
|
await MainActor.run { [weak self] in
|
|
guard let self = self else { return }
|
|
if priceUpdates.isEmpty && metadataUpdates.isEmpty { return }
|
|
|
|
var listCopy = self.scannedList
|
|
var changedIndices = Set<Int>()
|
|
var hasChanges = false
|
|
|
|
// OPTIMIZATION: Map IDs to indices for O(1) lookup
|
|
var indexMap = [UUID: Int]()
|
|
for (index, card) in listCopy.enumerated() { indexMap[card.id] = index }
|
|
|
|
// Apply Price Updates
|
|
for (id, newPrice) in priceUpdates {
|
|
if let index = indexMap[id] {
|
|
let oldCurrency = listCopy[index].currencyCode
|
|
let oldPrice = listCopy[index].currentValuation
|
|
|
|
if oldCurrency != currencySnapshot.rawValue {
|
|
// Currency Switch: Reset history to prevent invalid math (e.g. EUR - USD)
|
|
listCopy[index].previousValuation = newPrice
|
|
listCopy[index].currentValuation = newPrice
|
|
listCopy[index].currencyCode = currencySnapshot.rawValue
|
|
changedIndices.insert(index)
|
|
hasChanges = true
|
|
} else if oldPrice != newPrice {
|
|
// Price Change: Only shift history if value actually differs
|
|
listCopy[index].previousValuation = oldPrice
|
|
listCopy[index].currentValuation = newPrice
|
|
changedIndices.insert(index)
|
|
hasChanges = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply Metadata Updates
|
|
for (id, meta) in metadataUpdates {
|
|
if let index = indexMap[id] {
|
|
// Only update if values differ to avoid unnecessary writes
|
|
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()
|
|
|
|
// Reconstruct changedCards from indices to ensure latest data (price + metadata) is sent
|
|
let changedCards = changedIndices.map { listCopy[$0] }
|
|
// COST OPTIMIZATION: Only upload cards that actually changed to save Firestore writes
|
|
Task { await CloudEngine.batchUpdatePrices(cards: changedCards) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkCameraPermissions() {
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
self.isPermissionDenied = false
|
|
if !isSessionConfigured { self.setupCamera() } else { self.startSession() }
|
|
case .notDetermined:
|
|
Task {
|
|
[weak self] in
|
|
if await AVCaptureDevice.requestAccess(for: .video) {
|
|
self?.isPermissionDenied = false; self?.setupCamera()
|
|
} else {
|
|
self?.isPermissionDenied = true; self?.statusText = "Camera Access Denied"
|
|
}
|
|
}
|
|
case .denied, .restricted:
|
|
self.isPermissionDenied = true; self.statusText = "Camera Access Denied"
|
|
@unknown default: break
|
|
}
|
|
}
|
|
func setupCamera() {
|
|
if self.isPermissionDenied { return }
|
|
|
|
DispatchQueue.global(qos: .background).async {
|
|
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default, options: .mixWithOthers)
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
}
|
|
|
|
sessionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: dev) else { return }
|
|
|
|
self.session.beginConfiguration()
|
|
for i in self.session.inputs { self.session.removeInput(i) }
|
|
for o in self.session.outputs { self.session.removeOutput(o) }
|
|
|
|
if self.session.canSetSessionPreset(.hd1920x1080) { self.session.sessionPreset = .hd1920x1080 }
|
|
if self.session.canAddInput(input) { self.session.addInput(input) }
|
|
|
|
let out = AVCaptureVideoDataOutput()
|
|
out.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video"))
|
|
if self.session.canAddOutput(out) { self.session.addOutput(out) }
|
|
|
|
do {
|
|
try dev.lockForConfiguration()
|
|
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
|
if dev.isSmoothAutoFocusSupported { dev.isSmoothAutoFocusEnabled = true }
|
|
|
|
// OPTIMIZATION: Cap at 30 FPS to reduce thermal load and battery usage during scanning
|
|
dev.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30)
|
|
|
|
let zoomFactor = min(dev.maxAvailableVideoZoomFactor, max(1.5, dev.minAvailableVideoZoomFactor))
|
|
dev.videoZoomFactor = zoomFactor
|
|
dev.unlockForConfiguration()
|
|
} catch {}
|
|
|
|
self.session.commitConfiguration()
|
|
Task { @MainActor [weak self] in self?.isSessionConfigured = true }
|
|
self.session.startRunning()
|
|
|
|
if dev.hasTorch {
|
|
let actualState = dev.torchMode == .on
|
|
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
|
}
|
|
}
|
|
}
|
|
|
|
func toggleTorch() {
|
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch else { return }
|
|
sessionQueue.async { [weak self] in
|
|
try? dev.lockForConfiguration()
|
|
dev.torchMode = dev.torchMode == .on ? .off : .on
|
|
dev.unlockForConfiguration()
|
|
let actualState = dev.torchMode == .on
|
|
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
|
}
|
|
}
|
|
|
|
func focusCamera(at point: CGPoint) {
|
|
focusResetTask?.cancel()
|
|
|
|
sessionQueue.async {
|
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
|
do {
|
|
try dev.lockForConfiguration()
|
|
if dev.isFocusPointOfInterestSupported { dev.focusPointOfInterest = point; dev.focusMode = .autoFocus }
|
|
if dev.isExposurePointOfInterestSupported { dev.exposurePointOfInterest = point; dev.exposureMode = .autoExpose }
|
|
dev.unlockForConfiguration()
|
|
} catch {}
|
|
}
|
|
|
|
focusResetTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(nanoseconds: 4_000_000_000)
|
|
self?.sessionQueue.async {
|
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
|
do {
|
|
try dev.lockForConfiguration()
|
|
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
|
dev.unlockForConfiguration()
|
|
} catch {}
|
|
}
|
|
} catch {
|
|
// Task cancelled, do not reset focus
|
|
}
|
|
}
|
|
}
|
|
|
|
func startSession() {
|
|
self.isScanningActive = true
|
|
sessionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
if !self.session.isRunning { self.session.startRunning() }
|
|
}
|
|
}
|
|
|
|
func stopSession() {
|
|
// V1.1.0 FIX: Cancel pending focus reset
|
|
self.isScanningActive = false
|
|
focusResetTask?.cancel()
|
|
processingTask?.cancel()
|
|
self.forceSave()
|
|
|
|
sessionQueue.async { [weak self] in
|
|
if let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch {
|
|
try? dev.lockForConfiguration(); dev.torchMode = .off; dev.unlockForConfiguration()
|
|
}
|
|
guard let self = self else { return }
|
|
if self.session.isRunning { self.session.stopRunning() }
|
|
}
|
|
Task { @MainActor [weak self] in
|
|
guard let self = self else { return }
|
|
if !self.isFound { self.cancelScan() }
|
|
self.isProcessing = false; self.isTorchOn = false
|
|
}
|
|
}
|
|
|
|
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
|
guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
guard processingLock.withLock({ if $0 { return false } else { $0 = true; return true } }) else { return }
|
|
|
|
// FIX: Retain buffer via CIImage, but defer heavy rendering to background to avoid blocking camera
|
|
let ciImage = CIImage(cvPixelBuffer: cvBuffer)
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
guard let self = self else { return }
|
|
defer { self.processingLock.withLock { $0 = false } }
|
|
|
|
let (shouldProcess, orientation, isBusy) = await MainActor.run {
|
|
let now = Date()
|
|
// OPTIMIZATION: Throttle analysis pipeline to ~15 FPS. 30 FPS is unnecessary for card scanning and drains battery.
|
|
if now.timeIntervalSince(self.lastFrameTime) < 0.06 { return (false, .up, false) }
|
|
self.lastFrameTime = now
|
|
return (self.isScanningActive && !self.isFound && !self.isSaving && !self.isDatabaseLoading && !self.isCollectionLoading && now.timeIntervalSince(self.lastSaveTime) > 2.0, self.getCurrentOrientations().1, self.isProcessing)
|
|
}
|
|
|
|
guard shouldProcess else { return }
|
|
guard let cg = SharedEngineResources.context.createCGImage(ciImage, from: ciImage.extent) else { return }
|
|
|
|
let foilType = await self.foilEngine.addFrame(cg, orientation: orientation)
|
|
|
|
await MainActor.run {
|
|
if let foilType { self.currentFoilType = foilType }
|
|
}
|
|
|
|
// OPTIMIZATION: Skip rectangle detection if we are already busy processing a crop
|
|
guard !isBusy else { return }
|
|
|
|
// FIX: Run rectangle detection in background to prevent UI stutter
|
|
let handler = VNImageRequestHandler(cgImage: cg, orientation: .up)
|
|
let req = VNDetectRectanglesRequest()
|
|
try? handler.perform([req])
|
|
if let obs = req.results?.first as? VNRectangleObservation {
|
|
Task { @MainActor [weak self] in
|
|
guard let self = self, self.isScanningActive else { return }
|
|
self.processingTask = Task { [weak self] in await self?.processCrop(obs, from: cg, orientation: orientation) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func processCrop(_ obs: VNRectangleObservation, from fullImage: CGImage, orientation: CGImagePropertyOrientation) async {
|
|
if Task.isCancelled { return }
|
|
|
|
let shouldProceed = await MainActor.run {
|
|
// FIX: Guard against race conditions where another task started processing while this one was queued
|
|
if self.isFound || self.isProcessing { return false }
|
|
self.isProcessing = true
|
|
self.detectHaptics.prepare()
|
|
return true
|
|
}
|
|
guard shouldProceed else { return }
|
|
|
|
// FIX: Perform cropping in background to prevent Main Thread UI stutter
|
|
let croppedImage: CGImage? = await Task.detached {
|
|
let width = CGFloat(fullImage.width)
|
|
let height = CGFloat(fullImage.height)
|
|
|
|
// FIX: Vision uses Bottom-Left origin, CGImage uses Top-Left. We must flip Y.
|
|
let bbox = obs.boundingBox
|
|
let rect = CGRect(x: bbox.origin.x * width,
|
|
y: (1 - bbox.origin.y - bbox.height) * height,
|
|
width: bbox.width * width,
|
|
height: bbox.height * height)
|
|
return fullImage.cropping(to: rect)
|
|
}.value
|
|
|
|
guard let cropped = croppedImage else {
|
|
await MainActor.run { self.isProcessing = false }
|
|
return
|
|
}
|
|
|
|
// Dev Mode: Save Raw Crop
|
|
if DevEngine.isDevMode {
|
|
DevEngine.saveRaw(image: UIImage(cgImage: cropped), label: "AutoCrop")
|
|
}
|
|
|
|
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
|
|
|
// OPTIMIZATION: Run Identification (Actor) and Grading (Background Task) in parallel
|
|
async let analysis = self.analyzer.analyze(croppedImage: cropped, orientation: orientation)
|
|
async let damageCheck = Task.detached(priority: .userInitiated) { ConditionEngine.detectDamage(image: cropped, orientation: orientation) }.value
|
|
|
|
let ((finalCard, detectedSerialized), damages) = await (analysis, damageCheck)
|
|
|
|
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
|
|
|
await MainActor.run {
|
|
guard !self.isFound && self.isScanningActive else { self.isProcessing = false; return }
|
|
|
|
if var card = finalCard {
|
|
self.isFound = true
|
|
self.successHaptics.notificationOccurred(.success)
|
|
AudioServicesPlaySystemSound(1108)
|
|
|
|
card.isSerialized = detectedSerialized
|
|
self.detectedDamages = damages
|
|
self.detectedCard = card
|
|
self.currentFrameImage = cropped
|
|
|
|
if self.isAutoScanEnabled {
|
|
self.saveCurrentCard()
|
|
} else {
|
|
self.isProcessing = false
|
|
}
|
|
} else {
|
|
self.isProcessing = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelScan() {
|
|
self.isFound = false
|
|
self.detectedCard = nil
|
|
self.currentFrameImage = nil
|
|
self.isProcessing = false
|
|
self.currentFoilType = AppConfig.Defaults.defaultFoil
|
|
self.statusText = "Ready to Scan"
|
|
self.processingTask?.cancel()
|
|
}
|
|
|
|
func saveCurrentCard() {
|
|
guard let card = detectedCard, let cgImage = currentFrameImage else { return }
|
|
self.isSaving = true
|
|
self.lastSaveTime = Date()
|
|
|
|
let imageName = "\(UUID().uuidString).jpg"
|
|
let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: getCurrentOrientations().0)
|
|
let collection = self.currentCollection
|
|
let box = self.currentBox
|
|
|
|
// 1. Update UI Immediately (Optimistic)
|
|
var entry = SavedCard(from: card, imageName: imageName, collection: collection, location: box)
|
|
// FIX: Apply detected Foil and Condition to the saved record
|
|
entry.foilType = self.currentFoilType
|
|
entry.condition = ConditionEngine.overallGrade(damages: self.detectedDamages)
|
|
|
|
self.scannedList.insert(entry, at: 0)
|
|
self.saveCollectionAsync()
|
|
ReviewEngine.logScan()
|
|
|
|
if self.isAutoScanEnabled {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
self.cancelScan()
|
|
self.isSaving = false
|
|
}
|
|
} else {
|
|
self.cancelScan()
|
|
self.isSaving = false
|
|
}
|
|
|
|
// FIX: Robust Background Task Pattern to avoid Data Races
|
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveCard") {
|
|
// Expiration handler: End task immediately if time runs out
|
|
UIApplication.shared.endBackgroundTask(taskID)
|
|
taskID = .invalid
|
|
}
|
|
|
|
// 2. Offload Heavy I/O and Network to Background
|
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
|
do {
|
|
try ImageManager.save(uiImage, name: imageName)
|
|
} catch {
|
|
await MainActor.run { self?.statusText = "Warning: Image Save Failed" }
|
|
}
|
|
|
|
await CloudEngine.save(card: entry)
|
|
guard let self = self else { return }
|
|
|
|
if entry.currentValuation == nil {
|
|
let currency = await MainActor.run { self.selectedCurrency }
|
|
// FIX: Do not update global cache timestamp for single card scans
|
|
let (prices, metadata) = await InsuranceEngine.updateTrends(cards: [entry], currency: currency, force: true, updateTimestamp: false)
|
|
if let newPrice = prices[entry.id] {
|
|
let updatedCard: SavedCard? = await MainActor.run {
|
|
guard let self = self else { return nil }
|
|
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()
|
|
return self.scannedList[idx]
|
|
}
|
|
return nil
|
|
}
|
|
if let c = updatedCard {
|
|
await CloudEngine.save(card: c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateCardDetails(_ card: SavedCard) {
|
|
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
|
scannedList[idx] = card
|
|
|
|
// Learn new Collections/Boxes if manually entered
|
|
if !collections.contains(card.collectionName) { collections.append(card.collectionName); collections.sort() }
|
|
if !boxes.contains(card.storageLocation) { boxes.append(card.storageLocation); boxes.sort() }
|
|
|
|
self.saveCollectionAsync()
|
|
|
|
// FIX: Robust Background Task Pattern for Edits
|
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "UpdateCard") {
|
|
UIApplication.shared.endBackgroundTask(taskID)
|
|
taskID = .invalid
|
|
}
|
|
|
|
// Trigger price update if identity/foil changed
|
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
|
await CloudEngine.save(card: card)
|
|
|
|
guard let self = self else { return }
|
|
let currency = await MainActor.run { self.selectedCurrency }
|
|
// FIX: Do not update global cache timestamp for single card edits
|
|
let (prices, _) = await InsuranceEngine.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
|
if let newPrice = prices[card.id] {
|
|
let updatedCard: SavedCard? = await MainActor.run {
|
|
guard let self = self else { return nil }
|
|
if let i = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
|
self.scannedList[i].currentValuation = newPrice
|
|
self.saveCollectionAsync()
|
|
return self.scannedList[i]
|
|
}
|
|
return nil
|
|
}
|
|
if let c = updatedCard {
|
|
await CloudEngine.save(card: c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
let updatedCard = scannedList[idx]
|
|
Task { await CloudEngine.save(card: updatedCard) }
|
|
}
|
|
}
|
|
|
|
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))
|
|
// FIX: Adjusted layout to prevent text/image overlap
|
|
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
|
|
}
|
|
|
|
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 uploadTrainingImage(label: String) {
|
|
guard let cg = currentFrameImage else { return }
|
|
let orientation = getCurrentOrientations().0
|
|
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
|
Task.detached { TrainingUploader.upload(image: img, label: label, force: true) }
|
|
}
|
|
|
|
func uploadCorrection(image: UIImage?, card: SavedCard, original: SavedCard?) {
|
|
guard let img = image ?? ImageManager.load(name: card.imageFileName) else { return }
|
|
|
|
// Determine what changed to label the training data correctly
|
|
var labels: [String] = []
|
|
if card.name != original?.name || card.setCode != original?.setCode { labels.append("Identity_\(card.setCode)_\(card.collectorNumber)") }
|
|
if card.condition != original?.condition { labels.append("Condition_\(card.condition)") }
|
|
if card.foilType != original?.foilType { labels.append("Foil_\(card.foilType)") }
|
|
|
|
if labels.isEmpty { return }
|
|
|
|
Task.detached {
|
|
for label in labels {
|
|
TrainingUploader.upload(image: img, label: label, force: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func saveManualCard(_ card: SavedCard) {
|
|
self.scannedList.insert(card, at: 0)
|
|
self.saveCollectionAsync()
|
|
self.cancelScan()
|
|
ReviewEngine.logScan()
|
|
|
|
// FIX: Robust Background Task Pattern
|
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveManual") {
|
|
// Expiration handler: End task immediately if time runs out
|
|
UIApplication.shared.endBackgroundTask(taskID)
|
|
taskID = .invalid
|
|
}
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
|
await CloudEngine.save(card: card)
|
|
// Trigger price update for the manually entered card
|
|
guard let self = self else { return }
|
|
// FIX: Do not update global cache timestamp for manual entry
|
|
let (prices, metadata) = await InsuranceEngine.updateTrends(cards: [card], currency: self.selectedCurrency, force: true, updateTimestamp: false)
|
|
|
|
if let newPrice = prices[card.id] {
|
|
let updatedCard: SavedCard? = await MainActor.run { [weak self] in
|
|
guard let self = self else { return nil }
|
|
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()
|
|
return self.scannedList[idx]
|
|
}
|
|
return nil
|
|
}
|
|
if let c = updatedCard {
|
|
await CloudEngine.save(card: c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 forceSave() {
|
|
saveTask?.cancel()
|
|
let listSnapshot = self.scannedList
|
|
Task { await PersistenceActor.shared.save(listSnapshot) }
|
|
}
|
|
|
|
private func saveCollectionAsync() {
|
|
saveTask?.cancel()
|
|
let listSnapshot = self.scannedList
|
|
saveTask = Task {
|
|
do {
|
|
try await Task.sleep(nanoseconds: 500_000_000)
|
|
await PersistenceActor.shared.save(listSnapshot)
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
private func getCurrentOrientations() -> (UIImage.Orientation, CGImagePropertyOrientation) {
|
|
switch UIDevice.current.orientation {
|
|
case .portrait: return (.right, .right)
|
|
case .portraitUpsideDown: return (.left, .left)
|
|
case .landscapeLeft: return (.up, .up)
|
|
case .landscapeRight: return (.down, .down)
|
|
default: return (.right, .right)
|
|
}
|
|
}
|
|
} |