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() var currentFrameImage: CGImage? private var recalcTask: Task? private var refreshTask: Task? private var processingTask: Task? 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? private let processingLock = OSAllocatedUnfairLock(initialState: false) private var isScanningActive = false private var isSessionConfigured = false private var focusResetTask: Task? 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() 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) } } }