diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f761e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# AI/ML Model Files +*.pt +*.pth +*.onnx +*.pb +*.h5 +*.model +*.mlmodel + +# AI/ML Data & Cache +*.csv +*.json +*.parquet +/data/ +/datasets/ +__pycache__/ +.ipynb_checkpoints + +# Environment & Tooling Specific +.env +.venv/ +venv/ +env/ +.idea/ +.vscode/ +.claude/ + +# Mac-specific +.DS_Store diff --git a/IYmtg_App_iOS/AppConfig.swift b/IYmtg_App_iOS/AppConfig.swift index bcee63b..5c3a8a7 100644 --- a/IYmtg_App_iOS/AppConfig.swift +++ b/IYmtg_App_iOS/AppConfig.swift @@ -44,6 +44,13 @@ struct AppConfig { get { UserDefaults.standard.bool(forKey: "TrainingOptIn") } set { UserDefaults.standard.set(newValue, forKey: "TrainingOptIn") } } + + // Firebase secondary backup — metadata only (no card images). + // Default: false. Users opt-in via the Cloud Backup section in Library settings. + static var isFirebaseBackupEnabled: Bool { + get { UserDefaults.standard.bool(forKey: "FirebaseBackupEnabled") } + set { UserDefaults.standard.set(newValue, forKey: "FirebaseBackupEnabled") } + } static var scryfallUserAgent: String { return "IYmtg/1.0 (\(contactEmail))" @@ -54,7 +61,9 @@ struct AppConfig { if contactEmail.contains("yourdomain.com") { fatalError("🛑 SETUP ERROR: Change 'contactEmail' in AppConfig.swift to your real email.") } - if let first = tipJarProductIDs.first, (first.contains("yourname") || first == "com.iymtg.app.tip") { + if tipJarProductIDs.isEmpty { + print("⚠️ CONFIG WARNING: 'tipJarProductIDs' is empty. Tip Jar will not be available.") + } else if let first = tipJarProductIDs.first, (first.contains("yourname") || first == "com.iymtg.app.tip") { print("⚠️ CONFIG WARNING: 'tipJarProductIDs' contains placeholder. IAP will not load.") } #endif diff --git a/IYmtg_App_iOS/CardLogic.swift b/IYmtg_App_iOS/CardLogic.swift index d092dea..9f75879 100644 --- a/IYmtg_App_iOS/CardLogic.swift +++ b/IYmtg_App_iOS/CardLogic.swift @@ -1,116 +1,9 @@ -import Foundation -import Vision -import CoreGraphics -import ImageIO - -struct CardFingerprint: Codable, Identifiable, Sendable { - let id: UUID - let name: String - let setCode: String - let collectorNumber: String - let hasFoilPrinting: Bool - let hasSerializedPrinting: Bool? - let featureData: Data - var priceScanned: Double? = nil -} - -struct CardMetadata: Identifiable, Sendable { - let id: UUID - let name: String - let setCode: String - let collectorNumber: String - let hasFoilPrinting: Bool - let hasSerializedPrinting: Bool - var priceScanned: Double? = nil - var rarity: String? = nil - var colorIdentity: [String]? = nil - var isSerialized: Bool = false -} - -struct SavedCard: Codable, Identifiable, Hashable, Sendable { - let id: UUID - let scryfallID: String - var name: String - var setCode: String - var collectorNumber: String - let imageFileName: String - var condition: String - var foilType: String - var currentValuation: Double? - var previousValuation: Double? - var dateAdded: Date - var classification: String - var collectionName: String - var storageLocation: String - var rarity: String? - var colorIdentity: [String]? - - // Grading Fields - var gradingService: String? // PSA, BGS - var grade: String? // 10, 9.5 - var certNumber: String? // 123456 - var isCustomValuation: Bool = false - var isSerialized: Bool? = false - var currencyCode: String? - - init(from scan: CardMetadata, imageName: String, collection: String, location: String) { - self.id = UUID() - self.scryfallID = "\(scan.setCode)-\(scan.collectorNumber)" - self.name = scan.name - self.setCode = scan.setCode - self.collectorNumber = scan.collectorNumber - self.imageFileName = imageName - self.condition = AppConfig.Defaults.defaultCondition - self.foilType = AppConfig.Defaults.defaultFoil - self.currentValuation = scan.priceScanned - self.previousValuation = scan.priceScanned - self.dateAdded = Date() - self.classification = "Unknown" - self.collectionName = collection - self.storageLocation = location - self.rarity = scan.rarity - self.colorIdentity = scan.colorIdentity - self.isSerialized = scan.isSerialized - } -} - -class FeatureMatcher { - static let revision = VNGenerateImageFeaturePrintRequest.Revision.revision1 - - static func generateFingerprint(from image: CGImage, orientation: CGImagePropertyOrientation = .up) async throws -> VNFeaturePrintObservation { - let req = VNGenerateImageFeaturePrintRequest() - req.revision = revision - req.imageCropAndScaleOption = .scaleFill - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) - try handler.perform([req]) - - guard let result = req.results?.first as? VNFeaturePrintObservation else { - throw NSError(domain: "FeatureMatcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "No features detected"]) - } - return result - } - - static func identify(scan: VNFeaturePrintObservation, database: [CardMetadata], cache: [UUID: VNFeaturePrintObservation]) -> MatchResult { - var candidates: [(CardMetadata, Float)] = [] - for card in database { - guard let obs = cache[card.id] else { continue } - var dist: Float = 0 - if (try? scan.computeDistance(&dist, to: obs)) != nil && dist < 18.0 { - candidates.append((card, dist)) - } - } - let sorted = candidates.sorted { $0.1 < $1.1 } - guard let best = sorted.first else { return .unknown } - - if best.1 < 6.0 { return .exact(best.0) } - let close = sorted.filter { $0.1 < (best.1 + 3.0) } - if close.count > 1 { return .ambiguous(name: best.0.name, candidates: close.map { $0.0 }) } - return .exact(best.0) - } -} - -enum MatchResult { - case exact(CardMetadata) - case ambiguous(name: String, candidates: [CardMetadata]) - case unknown -} \ No newline at end of file +// ============================================================ +// MIGRATION NOTICE — This file has been refactored. +// All code has been moved to the new modular structure. +// Remove this file from your Xcode project and add: +// +// Data/Models/Card.swift — CardFingerprint, CardMetadata, +// SavedCard, MatchResult +// Services/Vision/FeatureMatcher.swift — FeatureMatcher +// ============================================================ diff --git a/IYmtg_App_iOS/ContentView.swift b/IYmtg_App_iOS/ContentView.swift index fe13269..bb847d3 100644 --- a/IYmtg_App_iOS/ContentView.swift +++ b/IYmtg_App_iOS/ContentView.swift @@ -4,15 +4,25 @@ import AVFoundation import StoreKit struct ContentView: View { - @StateObject var vm = ScannerViewModel() + @StateObject private var collectionVM: CollectionViewModel + @StateObject private var scannerVM: ScannerViewModel @StateObject var store = StoreEngine() @AppStorage("hasLaunchedBefore") var hasLaunchedBefore = false - + + init() { + let colVM = CollectionViewModel() + _collectionVM = StateObject(wrappedValue: colVM) + _scannerVM = StateObject(wrappedValue: ScannerViewModel(collectionVM: colVM)) + } + var body: some View { TabView { - DashboardView(vm: vm).tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") } - ScannerView(vm: vm).tabItem { Label("Scan", systemImage: "viewfinder") } - CollectionView(vm: vm, store: store).tabItem { Label("Library", systemImage: "tray.full.fill") } + DashboardView(vm: collectionVM) + .tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") } + ScannerView(vm: scannerVM) + .tabItem { Label("Scan", systemImage: "viewfinder") } + CollectionView(vm: collectionVM, scannerVM: scannerVM, store: store) + .tabItem { Label("Library", systemImage: "tray.full.fill") } } .preferredColorScheme(.dark).accentColor(Color(red: 0.6, green: 0.3, blue: 0.9)) .task { await store.loadProducts() } @@ -25,10 +35,10 @@ struct ContentView: View { } } -// DASHBOARD VIEW +// MARK: - DASHBOARD VIEW struct DashboardView: View { - @ObservedObject var vm: ScannerViewModel - + @ObservedObject var vm: CollectionViewModel + var body: some View { NavigationStack { List { @@ -43,7 +53,7 @@ struct DashboardView: View { } } } - + Section(header: Text("Top Movers")) { if vm.topMovers.isEmpty { Text("No price changes detected today.").foregroundColor(.gray) } ForEach(vm.topMovers) { card in @@ -61,7 +71,7 @@ struct DashboardView: View { } } -// SCANNER VIEW +// MARK: - SCANNER VIEW struct ScannerView: View { @ObservedObject var vm: ScannerViewModel @Environment(\.scenePhase) var scenePhase @@ -70,7 +80,7 @@ struct ScannerView: View { @State private var focusTask: Task? = nil @AppStorage("TrainingOptIn") var isTrainingOptIn = false @State private var showManualEdit = false - + var body: some View { ZStack(alignment: .top) { Group { @@ -101,10 +111,10 @@ struct ScannerView: View { } .edgesIgnoringSafeArea(.all).opacity(0.8) if let point = focusPoint { Circle().stroke(Color.yellow, lineWidth: 2).frame(width: 60, height: 60).position(point).transition(.opacity) } - Image("scanner_frame").resizable().scaledToFit().frame(width: geo.size.width * 0.75) + Image("scanner_frame").resizable().scaledToFit().frame(width: 300) .colorMultiply(vm.isFound ? .green : (vm.isProcessing ? .yellow : .white)) .opacity(0.8).allowsHitTesting(false).animation(.easeInOut(duration: 0.2), value: vm.isFound) - + VStack { Spacer() if !vm.isProcessing && !vm.isFound { Text(vm.statusText).padding().background(.ultraThinMaterial).cornerRadius(8).padding(.bottom, 20) } @@ -121,9 +131,7 @@ struct ScannerView: View { } Menu { Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") } - Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { - isTrainingOptIn.toggle() - } + Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { isTrainingOptIn.toggle() } } label: { Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue) } @@ -133,7 +141,7 @@ struct ScannerView: View { } } }.frame(maxWidth: .infinity, maxHeight: .infinity) - + VStack { HStack { Image("logo_header").resizable().scaledToFit().frame(height: 32) @@ -149,11 +157,11 @@ struct ScannerView: View { if !vm.isConnected { Image(systemName: "cloud.slash.fill").foregroundColor(.red).padding(8).background(.ultraThinMaterial).clipShape(Circle()) } Menu { ForEach(vm.collections, id: \.self) { name in Button(name) { vm.currentCollection = name } } - Button("New Collection...") { vm.collections.append("New Binder"); vm.currentCollection = "New Binder" } + Button("New Collection...") { vm.collectionVM.collections.append("New Binder"); vm.currentCollection = "New Binder" } } label: { HStack { Image(systemName: "folder.fill").font(.caption); Text(vm.currentCollection).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) } Menu { ForEach(vm.boxes, id: \.self) { name in Button(name) { vm.currentBox = name } } - Button("New Box/Deck...") { vm.boxes.append("New Box"); vm.currentBox = "New Box" } + Button("New Box/Deck...") { vm.collectionVM.boxes.append("New Box"); vm.currentBox = "New Box" } } label: { HStack { Image(systemName: "archivebox.fill").font(.caption); Text(vm.currentBox).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) } }.padding(.top, 60).padding(.horizontal) Spacer() @@ -167,14 +175,19 @@ struct ScannerView: View { } .sheet(isPresented: $showManualEdit) { if let detected = vm.detectedCard, let cg = vm.currentFrameImage { - let orientation = UIImage.Orientation.right // Default for simplicity in edit mode + let orientation = UIImage.Orientation.right let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation) let tempFileName = "\(UUID().uuidString).jpg" let _ = try? ImageManager.save(img, name: tempFileName) let tempCard = SavedCard(from: detected, imageName: tempFileName, collection: vm.currentCollection, location: vm.currentBox) - CardDetailView(card: tempCard, vm: vm, isNewEntry: true) + CardDetailView(card: tempCard, vm: vm.collectionVM, scannerVM: vm, isNewEntry: true) } } + .alert(vm.databaseAlertTitle, isPresented: $vm.showDatabaseAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(vm.databaseAlertMessage) + } } } @@ -194,22 +207,23 @@ struct CameraPreview: UIViewRepresentable { } } -// COLLECTION VIEW +// MARK: - COLLECTION VIEW struct CollectionView: View { - @ObservedObject var vm: ScannerViewModel + @ObservedObject var vm: CollectionViewModel + var scannerVM: ScannerViewModel // Passed through to CardDetailView for uploadCorrection @ObservedObject var store: StoreEngine @State private var showShare = false @State private var shareItem: Any? @State private var editingCard: SavedCard? @State private var isSharing = false - + func getPriceColor(_ card: SavedCard) -> Color { if card.isCustomValuation { return .yellow } guard let curr = card.currentValuation, let prev = card.previousValuation else { return .white } if curr > prev { return .green }; if curr < prev { return .red } return .white } - + var body: some View { NavigationStack { if vm.isCollectionLoading { ProgressView("Loading Collection...") } @@ -221,14 +235,35 @@ struct CollectionView: View { Section { HStack { Text("Total Value").font(.headline); Spacer(); Text("\(vm.selectedCurrency.symbol)\(vm.totalValue, specifier: "%.2f")").bold().foregroundColor(.green) } } Section(header: Text("Settings")) { Picker("Market Region", selection: $vm.selectedCurrency) { ForEach(CurrencyCode.allCases, id: \.self) { curr in Text(curr.rawValue).tag(curr) } }.onChange(of: vm.selectedCurrency) { _ in vm.refreshPrices(force: true) } - // V1.1.0 FIX: Haptic on Press - Button("Refresh Prices") { + Button(action: { let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred() vm.refreshPrices(force: true) + }) { + HStack { + Text("Refresh Prices") + if vm.isRefreshingPrices { ProgressView().tint(.accentColor) } + } } + .disabled(vm.isRefreshingPrices) Toggle("Share Data for Training", isOn: Binding(get: { AppConfig.isTrainingOptIn }, set: { AppConfig.isTrainingOptIn = $0 })) Button("Contact Support") { if let url = URL(string: "mailto:\(AppConfig.contactEmail)") { UIApplication.shared.open(url) } } } + Section(header: Text("Cloud Backup")) { + VStack(alignment: .leading, spacing: 4) { + Label("Primary: iCloud (automatic, includes images)", systemImage: "icloud.fill") + .font(.caption).foregroundColor(.green) + Label("Secondary: Firebase (metadata only — no images)", systemImage: "cylinder.split.1x2") + .font(.caption).foregroundColor(.secondary) + } + .padding(.vertical, 2) + Button(action: { vm.backupAllToFirebase() }) { + HStack { + Text("Backup Metadata to Firebase Now") + if vm.isBackingUpToFirebase { ProgressView().tint(.accentColor) } + } + } + .disabled(vm.isBackingUpToFirebase) + } Section(header: Text("Support the App")) { ForEach(store.products) { p in Button("Tip \(p.displayPrice)") { Task { await store.purchase(p) } } } } ForEach(vm.filteredList) { card in HStack { @@ -242,12 +277,15 @@ struct CollectionView: View { if let val = card.currentValuation, card.currencyCode == vm.selectedCurrency.rawValue { Text("\(vm.selectedCurrency.symbol)\(val, specifier: "%.2f")").foregroundColor(getPriceColor(card)) } else { Text("Updating...").foregroundColor(.gray).font(.caption) } - } - else { Text("Offline").foregroundColor(.red).font(.caption2) } + } else { Text("Offline").foregroundColor(.red).font(.caption2) } Text(card.storageLocation).font(.caption2) } } - .contextMenu { Button("Edit Details / Graded") { editingCard = card }; Button("Move to Binder 1") { vm.moveCard(card, toCollection: "Binder 1", toBox: "Page 1") }; Button("Share") { Task { isSharing = true; shareItem = await vm.generateShareImage(for: card); isSharing = false; if shareItem != nil { showShare = true } } } } + .contextMenu { + Button("Edit Details / Graded") { editingCard = card } + Button("Move to Binder 1") { vm.moveCard(card, toCollection: "Binder 1", toBox: "Page 1") } + Button("Share") { Task { isSharing = true; shareItem = await vm.generateShareImage(for: card); isSharing = false; if shareItem != nil { showShare = true } } } + } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { vm.deleteCard(card) } label: { Label("Delete", systemImage: "trash") } } } } @@ -273,16 +311,17 @@ struct CollectionView: View { } .overlay { if isSharing { ProgressView().padding().background(.ultraThinMaterial).cornerRadius(10) } } .sheet(isPresented: $showShare) { if let i = shareItem { ShareSheet(items: [i]) } } - .sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm) } + .sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm, scannerVM: scannerVM) } } } } } -// CARD DETAIL VIEW +// MARK: - CARD DETAIL VIEW struct CardDetailView: View { @State var card: SavedCard - var vm: ScannerViewModel + @ObservedObject var vm: CollectionViewModel + var scannerVM: ScannerViewModel var isNewEntry: Bool = false @Environment(\.dismiss) var dismiss @FocusState private var isInputActive: Bool @@ -291,7 +330,7 @@ struct CardDetailView: View { @State private var showContributionAlert = false let conditions = ["Near Mint (NM)", "Excellent (EX)", "Played (PL)", "Damaged"] let foilTypes = ["None", "Traditional", "Etched", "Galaxy", "Surge", "Textured", "Oil Slick", "Halo", "Confetti", "Neon Ink", "Other"] - + var body: some View { NavigationStack { Form { @@ -310,17 +349,21 @@ struct CardDetailView: View { } Section(header: Text("Grading (Slab Mode)")) { Toggle("Is Graded?", isOn: Binding(get: { card.gradingService != nil }, set: { if !$0 { card.gradingService = nil; card.grade = nil; card.certNumber = nil } else { card.gradingService = "PSA"; card.grade = "10"; card.isCustomValuation = true } })) - if card.gradingService != nil { TextField("Service (e.g. PSA)", text: Binding(get: { card.gradingService ?? "" }, set: { card.gradingService = $0 })); TextField("Grade (e.g. 10)", text: Binding(get: { card.grade ?? "" }, set: { card.grade = $0 })); TextField("Cert #", text: Binding(get: { card.certNumber ?? "" }, set: { card.certNumber = $0 })) } + if card.gradingService != nil { + TextField("Service (e.g. PSA)", text: Binding(get: { card.gradingService ?? "" }, set: { card.gradingService = $0 })) + TextField("Grade (e.g. 10)", text: Binding(get: { card.grade ?? "" }, set: { card.grade = $0 })) + TextField("Cert #", text: Binding(get: { card.certNumber ?? "" }, set: { card.certNumber = $0 })) + } } Section(header: Text("Valuation")) { Toggle("Custom Price", isOn: $card.isCustomValuation) - if card.isCustomValuation { TextField("Value (\(vm.selectedCurrency.symbol))", value: Binding(get: { card.currentValuation ?? 0.0 }, set: { card.currentValuation = $0 }), format: .number).keyboardType(.decimalPad).focused($isInputActive) } - else { + if card.isCustomValuation { + TextField("Value (\(vm.selectedCurrency.symbol))", value: Binding(get: { card.currentValuation ?? 0.0 }, set: { card.currentValuation = $0 }), format: .number).keyboardType(.decimalPad).focused($isInputActive) + } else { if vm.isConnected { if let val = card.currentValuation, card.currencyCode == vm.selectedCurrency.rawValue { Text("Market Price: \(vm.selectedCurrency.symbol)\(val, specifier: "%.2f")").foregroundColor(.gray) } else { Text("Market Price: Updating...").foregroundColor(.gray) } - } - else { Text("Market Price: Unavailable (Offline)").foregroundColor(.red) } + } else { Text("Market Price: Unavailable (Offline)").foregroundColor(.red) } } } } @@ -335,56 +378,58 @@ struct CardDetailView: View { if originalCard == nil { originalCard = card } } .alert("Contribute Correction?", isPresented: $showContributionAlert) { - Button("Send Image", role: .none) { vm.uploadCorrection(image: displayImage, card: card, original: originalCard); finishSave() } + Button("Send Image", role: .none) { scannerVM.uploadCorrection(image: displayImage, card: card, original: originalCard); finishSave() } Button("No Thanks", role: .cancel) { finishSave() } } message: { Text("You changed the card details. Would you like to send the image to help train the AI?") } } } - + func saveChanges() { let hasChanges = card.name != originalCard?.name || card.setCode != originalCard?.setCode || card.condition != originalCard?.condition || card.foilType != originalCard?.foilType if hasChanges && !AppConfig.isTrainingOptIn && !isNewEntry { showContributionAlert = true } else { - if hasChanges && AppConfig.isTrainingOptIn && !isNewEntry { vm.uploadCorrection(image: displayImage, card: card, original: originalCard) } + if hasChanges && AppConfig.isTrainingOptIn && !isNewEntry { scannerVM.uploadCorrection(image: displayImage, card: card, original: originalCard) } finishSave() } } - + func finishSave() { if isNewEntry { vm.saveManualCard(card) } else { vm.updateCardDetails(card) } dismiss() } } +// MARK: - SHARE SHEET struct ShareSheet: UIViewControllerRepresentable { var items: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) } func updateUIViewController(_ ui: UIActivityViewController, context: Context) {} } +// MARK: - WELCOME VIEW struct WelcomeView: View { @Binding var hasLaunchedBefore: Bool - + var body: some View { VStack(spacing: 40) { Image(systemName: "viewfinder.circle.fill") .font(.system(size: 80)) .foregroundColor(Color(red: 0.6, green: 0.3, blue: 0.9)) - + Text("Welcome to IYmtg") .font(.largeTitle) .bold() - + VStack(alignment: .leading, spacing: 25) { FeatureRow(icon: "camera.viewfinder", title: "Smart Scanning", text: "Point at any card. We detect Set, Condition, and Foil automatically.") FeatureRow(icon: "bolt.badge.a.fill", title: "Auto-Scan Mode", text: "Tap the lightning icon in the scanner to enable rapid-fire bulk entry.") FeatureRow(icon: "tray.full.fill", title: "Organize", text: "Select your Binder and Box from the top menu to sort as you scan.") - FeatureRow(icon: "lock.shield.fill", title: "Private & Secure", text: "Your collection data is backed up, but your photos stay private in iCloud.") + FeatureRow(icon: "lock.shield.fill", title: "Private & Secure", text: "Your collection data is backed up securely to the cloud. Your card photos stay on your device.") } .padding(.horizontal) - + Spacer() - + Button(action: { hasLaunchedBefore = true }) { Text("Get Started") .font(.headline) @@ -405,7 +450,7 @@ struct FeatureRow: View { let icon: String let title: String let text: String - + var body: some View { HStack(spacing: 15) { Image(systemName: icon).font(.title).foregroundColor(Color(red: 0.6, green: 0.3, blue: 0.9)).frame(width: 40) @@ -415,4 +460,4 @@ struct FeatureRow: View { } } } -} \ No newline at end of file +} diff --git a/IYmtg_App_iOS/Data/Models/Card.swift b/IYmtg_App_iOS/Data/Models/Card.swift new file mode 100644 index 0000000..c470e44 --- /dev/null +++ b/IYmtg_App_iOS/Data/Models/Card.swift @@ -0,0 +1,109 @@ +import Foundation + +struct CardFingerprint: Codable, Identifiable, Sendable { + let id: UUID + let name: String + let setCode: String + let collectorNumber: String + let hasFoilPrinting: Bool + let hasSerializedPrinting: Bool? + let featureData: Data + var priceScanned: Double? = nil +} + +struct CardMetadata: Identifiable, Sendable { + let id: UUID + let name: String + let setCode: String + let collectorNumber: String + let hasFoilPrinting: Bool + let hasSerializedPrinting: Bool + var priceScanned: Double? = nil + var rarity: String? = nil + var colorIdentity: [String]? = nil + var isSerialized: Bool = false +} + +struct SavedCard: Codable, Identifiable, Hashable, Sendable { + let id: UUID + let scryfallID: String + var name: String + var setCode: String + var collectorNumber: String + let imageFileName: String + var condition: String + var foilType: String + var currentValuation: Double? + var previousValuation: Double? + var dateAdded: Date + var classification: String + var collectionName: String + var storageLocation: String + var rarity: String? + var colorIdentity: [String]? + + // Grading Fields + var gradingService: String? // PSA, BGS + var grade: String? // 10, 9.5 + var certNumber: String? // 123456 + var isCustomValuation: Bool = false + var isSerialized: Bool? = false + var currencyCode: String? + + init(from scan: CardMetadata, imageName: String, collection: String, location: String) { + self.id = UUID() + self.scryfallID = "\(scan.setCode)-\(scan.collectorNumber)" + self.name = scan.name + self.setCode = scan.setCode + self.collectorNumber = scan.collectorNumber + self.imageFileName = imageName + self.condition = AppConfig.Defaults.defaultCondition + self.foilType = AppConfig.Defaults.defaultFoil + self.currentValuation = scan.priceScanned + self.previousValuation = scan.priceScanned + self.dateAdded = Date() + self.classification = "Unknown" + self.collectionName = collection + self.storageLocation = location + self.rarity = scan.rarity + self.colorIdentity = scan.colorIdentity + self.isSerialized = scan.isSerialized + } + + /// Full memberwise init used by SavedCardModel (SwiftData) ↔ SavedCard conversion. + init(id: UUID, scryfallID: String, name: String, setCode: String, collectorNumber: String, + imageFileName: String, condition: String, foilType: String, currentValuation: Double?, + previousValuation: Double?, dateAdded: Date, classification: String, collectionName: String, + storageLocation: String, rarity: String?, colorIdentity: [String]?, + gradingService: String?, grade: String?, certNumber: String?, + isCustomValuation: Bool, isSerialized: Bool?, currencyCode: String?) { + self.id = id + self.scryfallID = scryfallID + self.name = name + self.setCode = setCode + self.collectorNumber = collectorNumber + self.imageFileName = imageFileName + self.condition = condition + self.foilType = foilType + self.currentValuation = currentValuation + self.previousValuation = previousValuation + self.dateAdded = dateAdded + self.classification = classification + self.collectionName = collectionName + self.storageLocation = storageLocation + self.rarity = rarity + self.colorIdentity = colorIdentity + self.gradingService = gradingService + self.grade = grade + self.certNumber = certNumber + self.isCustomValuation = isCustomValuation + self.isSerialized = isSerialized + self.currencyCode = currencyCode + } +} + +enum MatchResult { + case exact(CardMetadata) + case ambiguous(name: String, candidates: [CardMetadata]) + case unknown +} diff --git a/IYmtg_App_iOS/Data/Models/SavedCardModel.swift b/IYmtg_App_iOS/Data/Models/SavedCardModel.swift new file mode 100644 index 0000000..8eb310c --- /dev/null +++ b/IYmtg_App_iOS/Data/Models/SavedCardModel.swift @@ -0,0 +1,156 @@ +import SwiftData +import Foundation + +// MARK: - SAVED CARD MODEL +// SwiftData @Model class for structured persistence with automatic CloudKit sync. +// Mirrors the SavedCard value-type struct so the rest of the app stays unchanged. +// Requires iOS 17+. Set minimum deployment target to iOS 17 in Xcode. + +@Model +final class SavedCardModel { + @Attribute(.unique) var id: UUID + var scryfallID: String + var name: String + var setCode: String + var collectorNumber: String + var imageFileName: String + var condition: String + var foilType: String + var currentValuation: Double? + var previousValuation: Double? + var dateAdded: Date + var classification: String + var collectionName: String + var storageLocation: String + var rarity: String? + var colorIdentity: [String]? + var gradingService: String? + var grade: String? + var certNumber: String? + var isCustomValuation: Bool + var isSerialized: Bool? + var currencyCode: String? + + init( + id: UUID = UUID(), + scryfallID: String, + name: String, + setCode: String, + collectorNumber: String, + imageFileName: String, + condition: String, + foilType: String, + currentValuation: Double? = nil, + previousValuation: Double? = nil, + dateAdded: Date = Date(), + classification: String = "Unknown", + collectionName: String, + storageLocation: String, + rarity: String? = nil, + colorIdentity: [String]? = nil, + gradingService: String? = nil, + grade: String? = nil, + certNumber: String? = nil, + isCustomValuation: Bool = false, + isSerialized: Bool? = false, + currencyCode: String? = nil + ) { + self.id = id + self.scryfallID = scryfallID + self.name = name + self.setCode = setCode + self.collectorNumber = collectorNumber + self.imageFileName = imageFileName + self.condition = condition + self.foilType = foilType + self.currentValuation = currentValuation + self.previousValuation = previousValuation + self.dateAdded = dateAdded + self.classification = classification + self.collectionName = collectionName + self.storageLocation = storageLocation + self.rarity = rarity + self.colorIdentity = colorIdentity + self.gradingService = gradingService + self.grade = grade + self.certNumber = certNumber + self.isCustomValuation = isCustomValuation + self.isSerialized = isSerialized + self.currencyCode = currencyCode + } + + convenience init(from card: SavedCard) { + self.init( + id: card.id, + scryfallID: card.scryfallID, + name: card.name, + setCode: card.setCode, + collectorNumber: card.collectorNumber, + imageFileName: card.imageFileName, + condition: card.condition, + foilType: card.foilType, + currentValuation: card.currentValuation, + previousValuation: card.previousValuation, + dateAdded: card.dateAdded, + classification: card.classification, + collectionName: card.collectionName, + storageLocation: card.storageLocation, + rarity: card.rarity, + colorIdentity: card.colorIdentity, + gradingService: card.gradingService, + grade: card.grade, + certNumber: card.certNumber, + isCustomValuation: card.isCustomValuation, + isSerialized: card.isSerialized, + currencyCode: card.currencyCode + ) + } + + func update(from card: SavedCard) { + name = card.name + setCode = card.setCode + collectorNumber = card.collectorNumber + condition = card.condition + foilType = card.foilType + currentValuation = card.currentValuation + previousValuation = card.previousValuation + classification = card.classification + collectionName = card.collectionName + storageLocation = card.storageLocation + rarity = card.rarity + colorIdentity = card.colorIdentity + gradingService = card.gradingService + grade = card.grade + certNumber = card.certNumber + isCustomValuation = card.isCustomValuation + isSerialized = card.isSerialized + currencyCode = card.currencyCode + } + + func toSavedCard() -> SavedCard { + SavedCard( + id: id, + scryfallID: scryfallID, + name: name, + setCode: setCode, + collectorNumber: collectorNumber, + imageFileName: imageFileName, + condition: condition, + foilType: foilType, + currentValuation: currentValuation, + previousValuation: previousValuation, + dateAdded: dateAdded, + classification: classification, + collectionName: collectionName, + storageLocation: storageLocation, + rarity: rarity, + colorIdentity: colorIdentity, + gradingService: gradingService, + grade: grade, + certNumber: certNumber, + isCustomValuation: isCustomValuation, + isSerialized: isSerialized, + currencyCode: currencyCode + ) + } +} diff --git a/IYmtg_App_iOS/Data/Network/NetworkMonitor.swift b/IYmtg_App_iOS/Data/Network/NetworkMonitor.swift new file mode 100644 index 0000000..5287cd6 --- /dev/null +++ b/IYmtg_App_iOS/Data/Network/NetworkMonitor.swift @@ -0,0 +1,15 @@ +import Network +import Combine +import Foundation + +// MARK: - NETWORK MONITOR +class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + private let monitor = NWPathMonitor() + @Published var isConnected = true + + init() { + monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.isConnected = path.status == .satisfied } } + monitor.start(queue: DispatchQueue.global(qos: .background)) + } +} diff --git a/IYmtg_App_iOS/Data/Network/ScryfallAPI.swift b/IYmtg_App_iOS/Data/Network/ScryfallAPI.swift new file mode 100644 index 0000000..47ead31 --- /dev/null +++ b/IYmtg_App_iOS/Data/Network/ScryfallAPI.swift @@ -0,0 +1,121 @@ +import Foundation + +// MARK: - SCRYFALL THROTTLER +actor ScryfallThrottler { + static let shared = ScryfallThrottler() + private var nextAllowedTime = Date.distantPast + + func wait() async { + let now = Date() + let targetTime = max(now, nextAllowedTime) + nextAllowedTime = targetTime.addingTimeInterval(0.1) + + let waitTime = targetTime.timeIntervalSince(now) + if waitTime > 0 { + try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) + } + } +} + +// MARK: - SCRYFALL API (formerly InsuranceEngine) +class ScryfallAPI { + struct PricingIdentifier: Codable { let set: String; let collector_number: String } + struct ScryfallData { + let price: Double? + let typeLine: String + let rarity: String? + let colors: [String]? + let isSerialized: Bool + } + + static func getPriceKey(foilType: String, currency: CurrencyCode) -> String { + if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" } + let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil + let base = currency.scryfallKey + return isFoil ? "\(base)_foil" : base + } + + static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) { + if !NetworkMonitor.shared.isConnected { return ([:], [:]) } + let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast + let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400 + + // Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh) + let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil } + if cardsToFetch.isEmpty { return ([:], [:]) } + + var updates: [UUID: Double] = [:] + var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:] + let marketCards = cardsToFetch.filter { !$0.isCustomValuation } + let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0.. ScryfallData { + if !NetworkMonitor.shared.isConnected { return ScryfallData(price: nil, typeLine: "Offline", rarity: nil, colors: nil, isSerialized: false) } + await ScryfallThrottler.shared.wait() + + let encodedSet = setCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? setCode + let encodedNum = number.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? number + + guard let url = URL(string: "https://api.scryfall.com/cards/\(encodedSet)/\(encodedNum)") else { return ScryfallData(price: nil, typeLine: "Error", rarity: nil, colors: nil, isSerialized: false) } + var request = URLRequest(url: url) + request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent") + + guard let (data, _) = try? await URLSession.shared.data(for: request), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScryfallData(price: nil, typeLine: "Unknown", rarity: nil, colors: nil, isSerialized: false) } + + let prices = json["prices"] as? [String: Any] + let priceKey = getPriceKey(foilType: foilType, currency: currency) + let price = Double(prices?[priceKey] as? String ?? "") + let rarity = json["rarity"] as? String + let colors = json["color_identity"] as? [String] + let promoTypes = json["promo_types"] as? [String] + let isSer = promoTypes?.contains("serialized") ?? false + return ScryfallData(price: price, typeLine: (json["type_line"] as? String) ?? "", rarity: rarity, colors: colors, isSerialized: isSer) + } +} diff --git a/IYmtg_App_iOS/Data/Persistence/ImageManager.swift b/IYmtg_App_iOS/Data/Persistence/ImageManager.swift new file mode 100644 index 0000000..6fdae90 --- /dev/null +++ b/IYmtg_App_iOS/Data/Persistence/ImageManager.swift @@ -0,0 +1,137 @@ +import UIKit +import Foundation + +// MARK: - IMAGE MANAGER +// All card images and user content are stored in a dedicated UserContent/ subfolder +// inside either iCloud Drive/Documents/ or the local Documents directory. +// This keeps binary files separate from the SwiftData store and aligns with the +// blueprint's requirement for a named UserContent container in iCloud Drive. + +class ImageManager { + + // MARK: - Directory Helpers + + /// Active working directory: iCloud Documents/UserContent/ or local Documents/UserContent/. + static var dir: URL { + let base: URL + if FileManager.default.ubiquityIdentityToken != nil, + let cloud = FileManager.default.url(forUbiquityContainerIdentifier: nil) { + base = cloud.appendingPathComponent("Documents") + } else { + base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + let userContent = base.appendingPathComponent("UserContent") + if !FileManager.default.fileExists(atPath: userContent.path) { + try? FileManager.default.createDirectory(at: userContent, withIntermediateDirectories: true) + } + return userContent + } + + /// The pre-UserContent base directory. Used only for one-time migration; not for new I/O. + private static var legacyDir: URL { + if FileManager.default.ubiquityIdentityToken != nil, + let cloud = FileManager.default.url(forUbiquityContainerIdentifier: nil) { + return cloud.appendingPathComponent("Documents") + } + return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + // MARK: - Save / Load / Delete + + static func save(_ img: UIImage, name: String) throws { + let url = dir.appendingPathComponent(name) + + let maxSize: CGFloat = 1024 + var actualImg = img + if img.size.width > maxSize || img.size.height > maxSize { + let scale = maxSize / max(img.size.width, img.size.height) + let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: newSize, format: format) + actualImg = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) } + } + + guard let data = actualImg.jpegData(compressionQuality: 0.7) else { return } + try data.write(to: url, options: .atomic) + } + + static func load(name: String) -> UIImage? { + let p = dir.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: p.path) { return UIImage(contentsOfFile: p.path) } + try? FileManager.default.startDownloadingUbiquitousItem(at: p) + return UIImage(contentsOfFile: p.path) + } + + static func delete(name: String) async { + let url = dir.appendingPathComponent(name) + try? FileManager.default.removeItem(at: url) + } + + // MARK: - Migration + + /// Moves JPG images from the legacy flat directory into the UserContent/ subfolder. + /// Called once during the SwiftData migration in BackgroundPersistenceActor. + static func migrateImagesToUserContent() { + let oldDir = legacyDir + let newDir = dir + + guard let files = try? FileManager.default.contentsOfDirectory( + at: oldDir, includingPropertiesForKeys: nil + ) else { return } + + var moved = 0 + for file in files where file.pathExtension == "jpg" { + let dest = newDir.appendingPathComponent(file.lastPathComponent) + if !FileManager.default.fileExists(atPath: dest.path) { + try? FileManager.default.moveItem(at: file, to: dest) + moved += 1 + } + } + if moved > 0 { print("MIGRATION: Moved \(moved) images → UserContent/") } + } + + /// Legacy: moves images from local Documents to iCloud Documents/UserContent/. + /// Retained for compatibility but superseded by migrateImagesToUserContent(). + static func migrateToCloud() { + guard let cloudBase = FileManager.default.url(forUbiquityContainerIdentifier: nil)? + .appendingPathComponent("Documents") else { return } + let cloudURL = cloudBase.appendingPathComponent("UserContent") + let localURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + + if !FileManager.default.fileExists(atPath: cloudURL.path) { + try? FileManager.default.createDirectory(at: cloudURL, withIntermediateDirectories: true) + } + + guard let files = try? FileManager.default.contentsOfDirectory( + at: localURL, includingPropertiesForKeys: nil + ) else { return } + for file in files where file.pathExtension == "jpg" { + let dest = cloudURL.appendingPathComponent(file.lastPathComponent) + if !FileManager.default.fileExists(atPath: dest.path) { + try? FileManager.default.moveItem(at: file, to: dest) + } + } + } + + // MARK: - Cleanup + + static func cleanupOrphans(activeImages: Set) { + Task.detached { + guard !activeImages.isEmpty else { return } + let fileManager = FileManager.default + guard let files = try? fileManager.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey] + ) else { return } + + for fileURL in files where fileURL.pathExtension == "jpg" { + if !activeImages.contains(fileURL.lastPathComponent), + let attrs = try? fileManager.attributesOfItem(atPath: fileURL.path), + let date = attrs[.modificationDate] as? Date, + Date().timeIntervalSince(date) > 3600 { + try? fileManager.removeItem(at: fileURL) + } + } + } + } +} diff --git a/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift b/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift new file mode 100644 index 0000000..7ce18cf --- /dev/null +++ b/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift @@ -0,0 +1,12 @@ +import Foundation + +// MARK: - PERSISTENCE ACTOR (Superseded) +// This file is kept as a placeholder. The functionality has been replaced by: +// - BackgroundPersistenceActor (in Data/Persistence/PersistenceController.swift) +// - PersistenceController (in Data/Persistence/PersistenceController.swift) +// +// The new architecture uses SwiftData with automatic iCloud CloudKit synchronization, +// replacing the manual single-file JSON sync. One-time migration from the legacy +// user_collection.json is handled in BackgroundPersistenceActor.migrateFromJSONIfNeeded(). +// +// ACTION: Remove this file from the Xcode project navigator once confirmed unused. diff --git a/IYmtg_App_iOS/Data/Persistence/PersistenceController.swift b/IYmtg_App_iOS/Data/Persistence/PersistenceController.swift new file mode 100644 index 0000000..c77750e --- /dev/null +++ b/IYmtg_App_iOS/Data/Persistence/PersistenceController.swift @@ -0,0 +1,146 @@ +import SwiftData +import Foundation + +// MARK: - BACKGROUND PERSISTENCE ACTOR +// Performs all SwiftData I/O on a dedicated background context. +// CloudKit synchronization is handled automatically by the ModelContainer configuration. +// @ModelActor provides a ModelContext bound to this actor's executor (background thread). + +@ModelActor +actor BackgroundPersistenceActor { + + // MARK: - Load + + func load() -> [SavedCard] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.dateAdded, order: .reverse)] + ) + let models = (try? modelContext.fetch(descriptor)) ?? [] + return models.map { $0.toSavedCard() } + } + + // MARK: - Save (diff-based to minimise CloudKit writes) + + func save(_ cards: [SavedCard]) { + do { + let existing = try modelContext.fetch(FetchDescriptor()) + let existingMap = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) }) + let incomingIDs = Set(cards.map { $0.id }) + + // Delete cards no longer in collection + for model in existing where !incomingIDs.contains(model.id) { + modelContext.delete(model) + } + + // Update existing or insert new + for card in cards { + if let model = existingMap[card.id] { + model.update(from: card) + } else { + modelContext.insert(SavedCardModel(from: card)) + } + } + + try modelContext.save() + } catch { + print("BackgroundPersistenceActor save error: \(error)") + } + } + + // MARK: - One-time JSON Migration + + /// Imports cards from the legacy user_collection.json file into SwiftData. + /// Runs once on first launch after the SwiftData upgrade; subsequent calls are no-ops. + /// Also triggers image migration from the flat Documents/ layout to Documents/UserContent/. + func migrateFromJSONIfNeeded() { + let migrationKey = "swiftdata_migration_complete_v1" + guard !UserDefaults.standard.bool(forKey: migrationKey) else { return } + + let iCloudDocuments = FileManager.default + .url(forUbiquityContainerIdentifier: nil)? + .appendingPathComponent("Documents") + let localDocuments = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + + let candidates: [URL] = [ + iCloudDocuments?.appendingPathComponent("user_collection.json"), + localDocuments.appendingPathComponent("user_collection.json") + ].compactMap { $0 } + + for jsonURL in candidates { + guard FileManager.default.fileExists(atPath: jsonURL.path), + let data = try? Data(contentsOf: jsonURL), + let legacyCards = try? JSONDecoder().decode([SavedCard].self, from: data), + !legacyCards.isEmpty else { continue } + + let existingIDs: Set + do { + let existing = try modelContext.fetch(FetchDescriptor()) + existingIDs = Set(existing.map { $0.id }) + } catch { existingIDs = [] } + + var imported = 0 + for card in legacyCards where !existingIDs.contains(card.id) { + modelContext.insert(SavedCardModel(from: card)) + imported += 1 + } + try? modelContext.save() + + // Rename old JSON — keeps it as a fallback without re-triggering import + let migratedURL = jsonURL.deletingLastPathComponent() + .appendingPathComponent("user_collection.migrated.json") + try? FileManager.default.moveItem(at: jsonURL, to: migratedURL) + + print("MIGRATION: Imported \(imported) cards from JSON into SwiftData.") + ImageManager.migrateImagesToUserContent() + break + } + + UserDefaults.standard.set(true, forKey: migrationKey) + } +} + +// MARK: - PERSISTENCE CONTROLLER +// Owns and provides access to the SwiftData ModelContainer. +// The container is configured for automatic iCloud CloudKit synchronization. +// +// XCODE SETUP REQUIRED (one-time): +// 1. Signing & Capabilities → iCloud → enable CloudKit +// 2. Add a CloudKit container: "iCloud." +// 3. Signing & Capabilities → Background Modes → enable "Remote notifications" +// Without this setup the app runs in local-only mode (no cross-device sync). + +@MainActor +final class PersistenceController { + static let shared = PersistenceController() + + let container: ModelContainer + let backgroundActor: BackgroundPersistenceActor + + private init() { + let schema = Schema([SavedCardModel.self]) + let resolvedContainer: ModelContainer + + do { + // .automatic derives the CloudKit container from the app bundle ID. + // Degrades gracefully to local storage if iCloud is unavailable. + let config = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false, + cloudKitDatabase: .automatic + ) + resolvedContainer = try ModelContainer(for: schema, configurations: [config]) + } catch { + print("⚠️ PersistenceController: CloudKit init failed (\(error)). Using local storage.") + do { + let fallback = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + resolvedContainer = try ModelContainer(for: schema, configurations: [fallback]) + } catch let fatal { + fatalError("PersistenceController: Cannot create ModelContainer. Error: \(fatal)") + } + } + + self.container = resolvedContainer + self.backgroundActor = BackgroundPersistenceActor(modelContainer: resolvedContainer) + } +} diff --git a/IYmtg_App_iOS/Engines.swift b/IYmtg_App_iOS/Engines.swift index 569a527..09fdb0a 100644 --- a/IYmtg_App_iOS/Engines.swift +++ b/IYmtg_App_iOS/Engines.swift @@ -1,1114 +1,15 @@ -import CoreML -import Vision -import UIKit -import StoreKit -import PDFKit -import FirebaseFirestore -import FirebaseStorage -import FirebaseAuth -import Network -import ImageIO -import CoreImage - -struct SharedEngineResources { - static let context = CIContext() -} - -// MARK: - MODEL MANAGER (OTA Updates) -class ModelManager { - static let shared = ModelManager() - private let defaults = UserDefaults.standard - - func getModel(name: String) -> VNCoreMLModel? { - // 1. Check Documents (Downloaded Update) - if let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let modelURL = docDir.appendingPathComponent("models/\(name).mlmodelc") - if FileManager.default.fileExists(atPath: modelURL.path), - let model = try? MLModel(contentsOf: modelURL), - let vnModel = try? VNCoreMLModel(for: model) { - return vnModel - } - } - - // 2. Check Bundle (Built-in Fallback) - if let bundleURL = Bundle.main.url(forResource: name, withExtension: "mlmodelc"), - let model = try? MLModel(contentsOf: bundleURL), - let vnModel = try? VNCoreMLModel(for: model) { - return vnModel - } - return nil - } - - func checkForUpdates() { - guard FirebaseApp.app() != nil else { return } - let models = ["IYmtgFoilClassifier", "IYmtgConditionClassifier", "IYmtgStampClassifier", "IYmtgSetClassifier"] - - for name in models { - let ref = Storage.storage().reference().child("models/\(name).mlmodel") - ref.getMetadata { meta, error in - guard let meta = meta, let remoteDate = meta.updated else { return } - let localDate = self.defaults.object(forKey: "ModelDate_\(name)") as? Date ?? Date.distantPast - - if remoteDate > localDate { - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).mlmodel") - ref.write(toFile: tempURL) { url, error in - guard let url = url else { return } - DispatchQueue.global(qos: .utility).async { - do { - let compiledURL = try MLModel.compileModel(at: url) - let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("models") - try FileManager.default.createDirectory(at: docDir, withIntermediateDirectories: true) - let destURL = docDir.appendingPathComponent("\(name).mlmodelc") - let tempDestURL = docDir.appendingPathComponent("temp_\(name).mlmodelc") - - try? FileManager.default.removeItem(at: tempDestURL) - try FileManager.default.copyItem(at: compiledURL, to: tempDestURL) - - if FileManager.default.fileExists(atPath: destURL.path) { - _ = try FileManager.default.replaceItem(at: destURL, withItemAt: tempDestURL, backupItemName: nil, options: []) - } else { - try FileManager.default.moveItem(at: tempDestURL, to: destURL) - } - self.defaults.set(remoteDate, forKey: "ModelDate_\(name)") - print("✅ OTA Model Updated: \(name)") - } catch { - print("❌ Model Update Failed for \(name): \(error)") - } - } - } - } - } - } - } -} - -// MARK: - 0. ANALYSIS ACTOR -actor AnalysisActor { - private var database: [CardMetadata] = [] - private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:] - - func loadDatabase(from url: URL) throws { - let data = try Data(contentsOf: url, options: .mappedIfSafe) - let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data) - self.fingerprintCache.removeAll() - self.database.removeAll() - - for card in loaded { - if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) { - self.fingerprintCache[card.id] = obs - } - self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned)) - } - } - - func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) { - guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) } - let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache) - - var resolvedCard: CardMetadata? - var detectedSerialized = false - - switch result { - case .exact(let card): - resolvedCard = card - case .unknown: - return (nil, false) - case .ambiguous(_, let candidates): - // FIX: Pass orientation to OCR, otherwise it fails on portrait images (sideways text) - let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) - detectedSerialized = isSerialized - - // Run Heuristics to resolve specific ambiguities - let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation) - let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation) - let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation) - let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation) - let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation) - - var filtered = candidates - - // 1. Alpha (LEA) vs Beta (LEB) - if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) { - if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } } - else { filtered = filtered.filter { $0.setCode == "LEB" } } - } - - // 2. Unlimited (2ED) vs Revised (3ED) - if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) { - if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } } - else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } } - } - - // 3. The List / Mystery - if hasListSymbol { - let listSets = ["PLIST", "MB1", "UPLIST", "H1R"] - let listCandidates = filtered.filter { listSets.contains($0.setCode) } - if !listCandidates.isEmpty { filtered = listCandidates } - } - - // 4. World Champ (Gold Border) - if borderColor == .gold { - let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") } - if !wcCandidates.isEmpty { filtered = wcCandidates } - } - - // 5. Promo Stamps - if hasStamp { - let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") } - if !promoCandidates.isEmpty { filtered = promoCandidates } - } - - // 6. Chronicles (White Border) vs Originals (Black Border) - let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"] - if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) { - if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } } - else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } } - } - - // 7. Summer Magic (Edgar) - 1994 Copyright on Revised - if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) { - let sumCandidates = filtered.filter { $0.setCode == "SUM" } - if !sumCandidates.isEmpty { filtered = sumCandidates } - } else if candidates.contains(where: { $0.setCode == "3ED" }) { - // If not explicitly 1994, assume Revised (3ED) and filter out Summer Magic (SUM) - filtered = filtered.filter { $0.setCode != "SUM" } - } - - var resolved: CardMetadata? - if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match } - else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match } - else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match } - else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match } - else { resolved = filtered.first ?? candidates.first } - - resolvedCard = resolved - } - - guard let card = resolvedCard else { return (nil, false) } - - // DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing - if card.hasSerializedPrinting { - // If we had an exact match, OCR wasn't run yet. Run it now specifically for serialization. - if case .exact = result { - let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) - detectedSerialized = isSer - } - } else { - detectedSerialized = false - } - - return (card, detectedSerialized) - } -} - -// MARK: - 1. STORE ENGINE -@MainActor -class StoreEngine: ObservableObject { - @Published var products: [Product] = [] - @Published var showThankYou = false - var transactionListener: Task? = nil - - init() { - // FIX: Added [weak self] to prevent retain cycle - transactionListener = Task.detached { [weak self] in - for await result in Transaction.updates { - do { - guard let self = self else { return } - let transaction = try self.checkVerified(result) - await transaction.finish() - await MainActor.run { self.showThankYou = true } - } catch {} - } - } - } - - deinit { transactionListener?.cancel() } - - func loadProducts() async { - do { - let p = try await Product.products(for: AppConfig.tipJarProductIDs) - self.products = p - #if DEBUG - if p.isEmpty { print("⚠️ StoreEngine: No products found. Verify IAP ID in AppConfig.") } - #endif - } catch { print("Store Error: \(error)") } - } - - func purchase(_ product: Product) async { - guard let result = try? await product.purchase() else { return } - switch result { - case .success(let verification): - if let transaction = try? checkVerified(verification) { - await transaction.finish() - self.showThankYou = true - } - case .pending, .userCancelled: break - @unknown default: break - } - } - - nonisolated func checkVerified(_ result: VerificationResult) throws -> T { - switch result { - case .unverified: throw StoreError.failedVerification - case .verified(let safe): return safe - } - } - enum StoreError: Error { case failedVerification } -} - -// MARK: - 2. PRICING ENGINE -actor ScryfallThrottler { - static let shared = ScryfallThrottler() - private var nextAllowedTime = Date.distantPast - - func wait() async { - let now = Date() - let targetTime = max(now, nextAllowedTime) - nextAllowedTime = targetTime.addingTimeInterval(0.1) - - let waitTime = targetTime.timeIntervalSince(now) - if waitTime > 0 { - try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) - } - } -} - -class InsuranceEngine { - struct PricingIdentifier: Codable { let set: String; let collector_number: String } - struct ScryfallData { - let price: Double? - let typeLine: String - let rarity: String? - let colors: [String]? - let isSerialized: Bool - } - - static func getPriceKey(foilType: String, currency: CurrencyCode) -> String { - if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" } - let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil - let base = currency.scryfallKey - return isFoil ? "\(base)_foil" : base - } - - static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) { - if !NetworkMonitor.shared.isConnected { return ([:], [:]) } - let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast - let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400 - - // Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh) - let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil } - if cardsToFetch.isEmpty { return ([:], [:]) } - - var updates: [UUID: Double] = [:] - var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:] - let marketCards = cardsToFetch.filter { !$0.isCustomValuation } - let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0.. ScryfallData { - if !NetworkMonitor.shared.isConnected { return ScryfallData(price: nil, typeLine: "Offline", rarity: nil, colors: nil, isSerialized: false) } - await ScryfallThrottler.shared.wait() - - let encodedSet = setCode.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? setCode - let encodedNum = number.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? number - - guard let url = URL(string: "https://api.scryfall.com/cards/\(encodedSet)/\(encodedNum)") else { return ScryfallData(price: nil, typeLine: "Error", rarity: nil, colors: nil, isSerialized: false) } - var request = URLRequest(url: url) - request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent") - - guard let (data, _) = try? await URLSession.shared.data(for: request), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return ScryfallData(price: nil, typeLine: "Unknown", rarity: nil, colors: nil, isSerialized: false) } - - let prices = json["prices"] as? [String: Any] - let priceKey = getPriceKey(foilType: foilType, currency: currency) - let price = Double(prices?[priceKey] as? String ?? "") - let rarity = json["rarity"] as? String - let colors = json["color_identity"] as? [String] - let promoTypes = json["promo_types"] as? [String] - let isSer = promoTypes?.contains("serialized") ?? false - return ScryfallData(price: price, typeLine: (json["type_line"] as? String) ?? "", rarity: rarity, colors: colors, isSerialized: isSer) - } -} - -// MARK: - 3. SECURE DEV MODE -class DevEngine { - static var isDevMode = false - static func activateIfCompiled() { - #if ENABLE_DEV_MODE - isDevMode = true - print("⚠️ DEV MODE COMPILED") - #endif - } - static func saveRaw(image: UIImage, label: String) { - #if ENABLE_DEV_MODE - if isDevMode, let data = image.jpegData(compressionQuality: 1.0) { - let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("RawTrainingData") - try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) - try? data.write(to: path.appendingPathComponent("TRAIN_\(label)_\(UUID().uuidString).jpg")) - } - #endif - } -} - -// MARK: - 4. CONDITION & ML -struct DamageObservation: Identifiable, Sendable { - let id = UUID() - let type: String - let rect: CGRect - let confidence: Float -} - -class ConditionEngine { - enum Severity: Int { case minor=1; case critical=10 } - - static var model: VNCoreMLModel? = { - guard AppConfig.enableConditionGrading else { return nil } - return ModelManager.shared.getModel(name: "IYmtgConditionClassifier") - }() - - static func getSeverity(for type: String) -> Severity { - return (type == "Inking" || type == "Rips" || type == "WaterDamage") ? .critical : .minor - } - - static func detectDamage(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> [DamageObservation] { - guard let model = model else { return [] } - let request = VNCoreMLRequest(model: model) - request.imageCropAndScaleOption = .scaleFill - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) - do { - try handler.perform([request]) - guard let results = request.results as? [VNRecognizedObjectObservation] else { return [] } - return results.filter { $0.confidence > 0.7 }.map { obs in - DamageObservation(type: obs.labels.first?.identifier ?? "Unknown", rect: obs.boundingBox, confidence: obs.confidence) - } - } catch { return [] } - } - - static func overallGrade(damages: [DamageObservation]) -> String { - if model == nil && damages.isEmpty { return "Ungraded" } - var score = 0 - for d in damages { - if getSeverity(for: d.type) == .critical { return "Damaged" } - score += 1 - } - if score == 0 { return "Near Mint (NM)" } - if score <= 2 { return "Excellent (EX)" } - return "Played (PL)" - } -} - -class OCREngine { - static func readCardDetails(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> (setCode: String?, number: String?, year: String?, isSerialized: Bool) { - let request = VNRecognizeTextRequest() - request.recognitionLevel = .accurate - request.usesLanguageCorrection = false - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation) - try? handler.perform([request]) - guard let obs = request.results as? [VNRecognizedTextObservation] else { return (nil, nil, nil, false) } - - var possibleSetCode: String? - var possibleNumber: String? - var possibleYear: String? - var isSerialized = false - - for observation in obs { - guard let candidate = observation.topCandidates(1).first else { continue } - let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) - - // Set Code: 3-5 chars, uppercase - // FIX: Ensure it's in the bottom half to avoid reading Card Name (e.g. "FOG") as Set Code - // FIX: Must contain at least one letter to avoid reading years or numbers as Set Codes - if text.count >= 3 && text.count <= 5 && text == text.uppercased() && possibleSetCode == nil && text.rangeOfCharacter(from: .letters) != nil { - // Vision coordinates: (0,0) is Bottom-Left. y < 0.5 is Bottom Half. - // FIX: Tighten to y < 0.2 to match Collector Number and fully exclude Text Box (e.g. "FLY") - if observation.boundingBox.origin.y < 0.2 { possibleSetCode = text } - } - - // Collector Number & Serialized - if text.contains("/") && text.count <= 10 { - // Detect "XXX/500" pattern - // FILTER: Ignore Power/Toughness in bottom-right corner (x > 0.75, y < 0.2) - // VNObservation bbox is normalized (0,0 is bottom-left) - if observation.boundingBox.origin.x > 0.75 && observation.boundingBox.origin.y < 0.2 { continue } - - // FILTER: Treat anything below the type line (y < 0.5) as standard info (Set/Collector Number) - // This prevents false positives from text box numbers or standard collector info being flagged as serialized. - // FIX: Tighten standard location to bottom 20% to avoid text box stats (e.g. "10/10" token) - let isStandardLocation = observation.boundingBox.origin.y < 0.2 - - let parts = text.split(separator: "/") - if parts.count == 2 { - let numStr = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let denomStr = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if let num = Int(numStr), let denom = Int(denomStr) { - if isStandardLocation { - // Ignore small denominators (e.g. 1/1, 2/2) in text box to avoid P/T false positives - if denom < 10 { continue } - if possibleNumber == nil { possibleNumber = numStr } - } else if observation.boundingBox.origin.y > 0.5 { - // FIX: Only consider Top Half (Art) as Serialized to avoid Text Box false positives - isSerialized = true - } - } - } - } else if text.count >= 1 && text.count <= 5, let first = text.first, first.isNumber, possibleNumber == nil { - // FIX: Only accept simple numbers if they are at the very bottom (Collector Number location) - // AND not in the bottom-right corner (Power/Toughness zone) - // This prevents reading mana costs, power/toughness, or damage values as collector numbers - if observation.boundingBox.origin.y < 0.2 && observation.boundingBox.origin.x < 0.75 { possibleNumber = text } - } - - // Copyright Year (for Summer Magic detection) - // Look for 1993-2025 - if let range = text.range(of: #"(19|20)\d{2}"#, options: .regularExpression) { - if observation.boundingBox.origin.y < 0.15 { - possibleYear = String(text[range]) - } - } - } - return (possibleSetCode, possibleNumber, possibleYear, isSerialized) - } -} - -class TrainingUploader { - static func upload(image: UIImage, label: String, force: Bool = false) { - if !force && !AppConfig.isTrainingOptIn { return } - guard FirebaseApp.app() != nil else { return } - let safeLabel = label.replacingOccurrences(of: "/", with: "-") - let ref = Storage.storage().reference().child("training/\(safeLabel)/\(UUID().uuidString).jpg") - if let data = image.jpegData(compressionQuality: 0.9) { ref.putData(data, metadata: nil) } - } -} - -class CloudEngine { - static var db: Firestore? { - return FirebaseApp.app() != nil ? Firestore.firestore() : nil - } - static func signInSilently() { - guard FirebaseApp.app() != nil else { return } - if Auth.auth().currentUser == nil { Auth.auth().signInAnonymously() } - } - - static func save(card: SavedCard) async { - guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } - let data: [String: Any] = [ - "name": card.name, - "set": card.setCode, - "num": card.collectorNumber, - "val": card.currentValuation ?? 0.0, - "cond": card.condition, - "foil": card.foilType, - "coll": card.collectionName, - "loc": card.storageLocation, - "grade": card.grade ?? "", - "svc": card.gradingService ?? "", - "cert": card.certNumber ?? "", - "date": card.dateAdded, - "curr": card.currencyCode ?? "USD", - "rarity": card.rarity ?? "", - "colors": card.colorIdentity ?? [], - "ser": card.isSerialized ?? false, - "img": card.imageFileName, - "custom": card.isCustomValuation - ] - do { - try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).setData(data, merge: true) - } catch { print("Cloud Save Error: \(error)") } - } - - static func delete(card: SavedCard) async { - guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } - do { - try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).delete() - } catch { print("Cloud Delete Error: \(error)") } - } - - static func batchUpdatePrices(cards: [SavedCard]) async { - guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } - let ref = db.collection("users").document(uid).collection("inventory") - - let chunks = stride(from: 0, to: cards.count, by: 400).map { Array(cards[$0.. String { - if format == .csv { - func cleanCSV(_ text: String) -> String { return text.replacingOccurrences(of: "\"", with: "\"\"") } - var csv = "Count,Name,Set,Number,Condition,Foil,Price,Serialized\n" - for card in cards { - let ser = (card.isSerialized ?? false) ? "Yes" : "No" - let line = "1,\"\(cleanCSV(card.name))\",\(card.setCode),\(card.collectorNumber),\(card.condition),\(card.foilType),\(card.currentValuation ?? 0.0),\(ser)\n" - csv.append(line) - } - return csv - } else if format == .mtgo { - return cards.map { "1 \($0.name)" }.joined(separator: "\n") - } else { - // Arena - return cards.map { "1 \($0.name) (\($0.setCode)) \($0.collectorNumber)" }.joined(separator: "\n") - } - } - - static func generate(cards: [SavedCard], format: ExportFormat) -> URL? { - if format == .insurance { return generatePDF(cards: cards) } - - let text = generateString(cards: cards, format: format) - let filename = format == .csv ? "Collection.csv" : (format == .mtgo ? "MTGO_List.txt" : "Arena_Decklist.txt") - let path = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - try? text.write(to: path, atomically: true, encoding: .utf8) - return path - } - - private static func generatePDF(cards: [SavedCard]) -> URL? { - let fmt = UIGraphicsPDFRendererFormat() - fmt.documentInfo = [kCGPDFContextCreator: "IYmtg"] as [String: Any] - let url = FileManager.default.temporaryDirectory.appendingPathComponent("Insurance.pdf") - - let totalVal = cards.reduce(0) { $0 + ($1.currentValuation ?? 0) } - let count = cards.count - - UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792), format: fmt).writePDF(to: url) { ctx in - var pageY: CGFloat = 50 - let headerAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 24)] - let subHeaderAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14)] - let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)] - - func drawHeader() { - "Insurance Schedule - \(Date().formatted(date: .abbreviated, time: .shortened))".draw(at: CGPoint(x: 50, y: 50), withAttributes: headerAttrs) - } - - func drawColumnHeaders(y: CGFloat) { - let hAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: UIColor.darkGray] - "CARD DETAILS".draw(at: CGPoint(x: 100, y: y), withAttributes: hAttrs) - "SET / #".draw(at: CGPoint(x: 310, y: y), withAttributes: hAttrs) - "COND".draw(at: CGPoint(x: 420, y: y), withAttributes: hAttrs) - "VALUE".draw(at: CGPoint(x: 520, y: y), withAttributes: hAttrs) - } - - ctx.beginPage() - drawHeader() - - // Summary Section - "Policy Holder: __________________________".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) - "Total Items: \(count)".draw(at: CGPoint(x: 50, y: 115), withAttributes: subHeaderAttrs) - "Total Value: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 200, y: 115), withAttributes: subHeaderAttrs) - drawColumnHeaders(y: 135) - - pageY = 150 - - for card in cards { - // Check for page break (Row height increased to 60 for image) - if pageY + 65 > 740 { - ctx.beginPage() - drawHeader() - drawColumnHeaders(y: 75) - pageY = 90 - } - - // Column 0: Image - if let img = ImageManager.load(name: card.imageFileName) { - img.draw(in: CGRect(x: 50, y: pageY, width: 40, height: 56)) - } - - let textY = pageY + 20 - // Column 1: Name (Truncate if long) - let name = "\(card.name)\((card.isSerialized ?? false) ? " [S]" : "")" - name.draw(in: CGRect(x: 100, y: textY, width: 200, height: 18), withAttributes: textAttrs) - // Column 2: Set - let setInfo = "\(card.setCode.uppercased()) #\(card.collectorNumber)" - setInfo.draw(at: CGPoint(x: 310, y: textY), withAttributes: textAttrs) - // Column 3: Condition (Short code) - let cond = card.condition.components(separatedBy: "(").last?.replacingOccurrences(of: ")", with: "") ?? card.condition - cond.draw(at: CGPoint(x: 420, y: textY), withAttributes: textAttrs) - // Column 4: Value - let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" - val.draw(at: CGPoint(x: 520, y: textY), withAttributes: textAttrs) - - pageY += 65 - } - - // Condensed Summary Section - ctx.beginPage() - drawHeader() - "Condensed Manifest".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) - pageY = 120 - - for card in cards { - if pageY > 740 { - ctx.beginPage() - drawHeader() - "Condensed Manifest (Cont.)".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) - pageY = 120 - } - - let line = "\(card.name) (\(card.setCode) #\(card.collectorNumber))" - line.draw(in: CGRect(x: 50, y: pageY, width: 400, height: 18), withAttributes: textAttrs) - - let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" - val.draw(at: CGPoint(x: 500, y: pageY), withAttributes: textAttrs) - pageY += 20 - } - - // FIX: Ensure Grand Total doesn't fall off the page - if pageY + 40 > 792 { - ctx.beginPage() - drawHeader() - pageY = 90 - } - "GRAND TOTAL: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 400, y: pageY + 20), withAttributes: subHeaderAttrs) - } - return url - } -} -enum ExportFormat: String, CaseIterable { case insurance="PDF"; case arena="Arena"; case mtgo="MTGO"; case csv="CSV" } - -// MARK: - 5. IMAGE MANAGER -class ImageManager { - static var dir: URL { - if FileManager.default.ubiquityIdentityToken != nil { - return FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - } - return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - } - - static func save(_ img: UIImage, name: String) throws { - let url = dir.appendingPathComponent(name) - if !FileManager.default.fileExists(atPath: dir.path) { - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } - - let maxSize: CGFloat = 1024 - var actualImg = img - if img.size.width > maxSize || img.size.height > maxSize { - let scale = maxSize / max(img.size.width, img.size.height) - let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - let renderer = UIGraphicsImageRenderer(size: newSize, format: format) - actualImg = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) } - } - - guard let data = actualImg.jpegData(compressionQuality: 0.7) else { return } - try data.write(to: url, options: .atomic) - } - - static func load(name: String) -> UIImage? { - let p = dir.appendingPathComponent(name) - if FileManager.default.fileExists(atPath: p.path) { return UIImage(contentsOfFile: p.path) } - try? FileManager.default.startDownloadingUbiquitousItem(at: p) - return UIImage(contentsOfFile: p.path) - } - - static func migrateToCloud() { - guard let cloudURL = FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") else { return } - let localURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - - if !FileManager.default.fileExists(atPath: cloudURL.path) { try? FileManager.default.createDirectory(at: cloudURL, withIntermediateDirectories: true) } - - let fileManager = FileManager.default - guard let files = try? fileManager.contentsOfDirectory(at: localURL, includingPropertiesForKeys: nil) else { return } - - for file in files { - if file.pathExtension == "jpg" { - let dest = cloudURL.appendingPathComponent(file.lastPathComponent) - if !fileManager.fileExists(atPath: dest.path) { try? fileManager.moveItem(at: file, to: dest) } - } - } - } - - static func delete(name: String) async { - let url = dir.appendingPathComponent(name) - try? FileManager.default.removeItem(at: url) - } - - static func cleanupOrphans(activeImages: Set) { - Task.detached { - guard !activeImages.isEmpty else { return } - let fileManager = FileManager.default - guard let files = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } - - for fileURL in files { - if fileURL.pathExtension == "jpg" { - if !activeImages.contains(fileURL.lastPathComponent) { - if let attrs = try? fileManager.attributesOfItem(atPath: fileURL.path), - let date = attrs[.modificationDate] as? Date, - Date().timeIntervalSince(date) > 3600 { - try? fileManager.removeItem(at: fileURL) - } - } - } - } - } - } -} - -class ClusterEngine { - static func refine(candidates: [CardMetadata]) -> String? { - // Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt - var scores: [String: Int] = [:] - for (index, card) in candidates.prefix(5).enumerated() { - let weight = max(1, 3 - index) - scores[card.setCode, default: 0] += weight - } - return scores.sorted { $0.value > $1.value }.first?.key - } -} - -// MARK: - 10. SET SYMBOL ENGINE -class SetSymbolEngine { - static var model: VNCoreMLModel? = { - guard AppConfig.enableSetSymbolDetection else { return nil } - return ModelManager.shared.getModel(name: "IYmtgSetClassifier") - }() - - static func recognizeSet(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { - guard let model = model else { return nil } - let request = VNCoreMLRequest(model: model) - request.imageCropAndScaleOption = .scaleFill - - // FIX: Use regionOfInterest (Normalized 0..1, Bottom-Left origin) - // Target: x=0.85 (Right), y=0.36 (Mid-Lower), w=0.12, h=0.09 - // Adjusted to ensure we catch the symbol which sits on the type line (approx y=0.40) - request.regionOfInterest = CGRect(x: 0.85, y: 0.36, width: 0.12, height: 0.09) - - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) - - do { - try handler.perform([request]) - guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return nil } - return top.confidence > 0.6 ? top.identifier : nil - } catch { return nil } - } -} - -// MARK: - 11. BORDER DETECTOR -class BorderDetector { - enum BorderColor { case black, white, gold, other } - - static func detect(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> BorderColor { - // Sample pixels from the edge (e.g., 5% margin) - let context = SharedEngineResources.context - let ciImage = CIImage(cgImage: image).oriented(orientation) - let width = ciImage.extent.width - let height = ciImage.extent.height - - // Crop a small strip from the left edge - let cropRect = CGRect(x: CGFloat(width) * 0.02, y: CGFloat(height) * 0.4, width: CGFloat(width) * 0.05, height: CGFloat(height) * 0.2) - let vector = CIVector(cgRect: cropRect) - let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]) - - guard let output = filter?.outputImage else { return .other } - var bitmap = [UInt8](repeating: 0, count: 4) - context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - let r = Int(bitmap[0]) - let g = Int(bitmap[1]) - let b = Int(bitmap[2]) - let brightness = (r + g + b) / 3 - - // Gold/Yellow detection (World Champ Decks): High Red/Green, Low Blue - if r > 140 && g > 120 && b < 100 && r > b + 40 { return .gold } - - if brightness < 60 { return .black } - if brightness > 180 { return .white } - return .other - } -} - -// MARK: - 12. LIST SYMBOL DETECTOR -class ListSymbolDetector { - static func hasListSymbol(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { - // The "List" / "Mystery" symbol is a small white icon in the bottom-left corner. - // It sits roughly at x: 3-7%, y: 93-97% of the card frame. - - let context = SharedEngineResources.context - let ciImage = CIImage(cgImage: image).oriented(orientation) - let width = ciImage.extent.width - let height = ciImage.extent.height - - // FIX: CIImage uses Bottom-Left origin. The List symbol is Bottom-Left. - // y: 0.03 is Bottom. y: 0.93 is Top. - let cropRect = CGRect(x: width * 0.03, y: height * 0.03, width: width * 0.05, height: height * 0.04) - let vector = CIVector(cgRect: cropRect) - - // Calculate average brightness in this spot - guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), - let output = filter.outputImage else { return false } - - var bitmap = [UInt8](repeating: 0, count: 4) - context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - // A white symbol on a black border will significantly raise the average brightness compared to a pure black border. - // Pure black ~ 0-20. With symbol ~ 80-150. - let brightness = (Int(bitmap[0]) + Int(bitmap[1]) + Int(bitmap[2])) / 3 - return brightness > 60 - } -} - -// MARK: - 13. CORNER DETECTOR (Alpha vs Beta) -class CornerDetector { - static func isAlphaCorner(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { - // Alpha corners are 2mm radius (very round). Beta are 1mm (standard). - // We analyze the top-left corner (4% of width). - // If significantly more "background" (non-black) pixels exist in the corner square, it's Alpha. - - let context = SharedEngineResources.context - let ciImage = CIImage(cgImage: image).oriented(orientation) - let width = ciImage.extent.width - let height = ciImage.extent.height - - let cornerSize = Int(Double(width) * 0.04) - // FIX: Analyze Top-Left corner (y is at top in CIImage coordinates) - let cropRect = CGRect(x: 0, y: CGFloat(height) - CGFloat(cornerSize), width: CGFloat(cornerSize), height: CGFloat(cornerSize)) - - let cropped = ciImage.cropped(to: cropRect) - - var bitmap = [UInt8](repeating: 0, count: cornerSize * cornerSize * 4) - context.render(cropped, toBitmap: &bitmap, rowBytes: cornerSize * 4, bounds: cropRect, format: .RGBA8, colorSpace: nil) - - var backgroundPixelCount = 0 - let totalPixels = cornerSize * cornerSize - - for i in stride(from: 0, to: bitmap.count, by: 4) { - let r = Int(bitmap[i]) - let g = Int(bitmap[i+1]) - let b = Int(bitmap[i+2]) - let brightness = (r + g + b) / 3 - - // Assuming Black Border is < 60 brightness. - // If pixel is brighter, it's likely the background revealed by the round corner. - if brightness > 80 { backgroundPixelCount += 1 } - } - - // Alpha corners reveal roughly 30-40% background in a tight corner crop. Beta reveals < 20%. - return Double(backgroundPixelCount) / Double(totalPixels) > 0.25 - } -} - -// MARK: - 14. SATURATION DETECTOR (Unlimited vs Revised) -class SaturationDetector { - static func analyze(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Double { - // Crop to center 50% to analyze artwork saturation, ignoring borders - let ciImage = CIImage(cgImage: image).oriented(orientation) - let width = ciImage.extent.width - let height = ciImage.extent.height - let cropRect = CGRect(x: width * 0.25, y: height * 0.25, width: width * 0.5, height: height * 0.5) - - let vector = CIVector(cgRect: cropRect) - guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), - let output = filter.outputImage else { return 0 } - - var bitmap = [UInt8](repeating: 0, count: 4) - let context = SharedEngineResources.context - context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - // Simple Saturation approximation: (Max - Min) / Max - let r = Double(bitmap[0]) / 255.0; let g = Double(bitmap[1]) / 255.0; let b = Double(bitmap[2]) / 255.0 - let maxC = max(r, max(g, b)); let minC = min(r, min(g, b)) - - return maxC == 0 ? 0 : (maxC - minC) / maxC - } -} - -// MARK: - 15. STAMP DETECTOR (Promos) -class StampDetector { - static var model: VNCoreMLModel? = { - guard AppConfig.enableStampDetection else { return nil } - return ModelManager.shared.getModel(name: "IYmtgStampClassifier") - }() - - static func hasStamp(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { - guard let model = model else { return false } - let request = VNCoreMLRequest(model: model) - request.imageCropAndScaleOption = .scaleFill - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) - try? handler.perform([request]) - guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return false } - return top.identifier == "Stamped" && top.confidence > 0.8 - } -} - -// MARK: - 6. FOIL ENGINE -actor FoilEngine { - private var frameCounter = 0 - private var lowConfidenceStreak = 0 - static var model: VNCoreMLModel? = { - guard AppConfig.enableFoilDetection else { return nil } - return ModelManager.shared.getModel(name: "IYmtgFoilClassifier") - }() - - private lazy var request: VNCoreMLRequest? = { - guard let model = FoilEngine.model else { return nil } - let req = VNCoreMLRequest(model: model) - req.imageCropAndScaleOption = .scaleFill - return req - }() - - func addFrame(_ image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { - frameCounter += 1 - // SAFETY: Prevent integer overflow in long-running sessions - if frameCounter > 1000 { frameCounter = 0 } - if frameCounter % 5 != 0 { return nil } - guard let request = self.request else { return AppConfig.Defaults.defaultFoil } - - let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) - - do { - try handler.perform([request]) - guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return AppConfig.Defaults.defaultFoil } - - if top.confidence > 0.85 { - lowConfidenceStreak = 0 - return top.identifier - } else { - lowConfidenceStreak += 1 - if lowConfidenceStreak >= 3 { - return AppConfig.Defaults.defaultFoil - } else { - return nil - } - } - } catch { return AppConfig.Defaults.defaultFoil } - } -} - -// MARK: - 7. PERSISTENCE ACTOR (V1.1.0: iCloud Migration) -actor PersistenceActor { - static let shared = PersistenceActor() - private let fileName = "user_collection.json" - private let backupName = "user_collection.bak" - - // Helper to access cloud URL for migration checks - private var cloudURL: URL? { FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") } - private var localURL: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] } - - private var fileURL: URL { ImageManager.dir.appendingPathComponent(fileName) } - private var backupURL: URL { ImageManager.dir.appendingPathComponent(backupName) } - - init() {} - - func save(_ cards: [SavedCard]) { - do { - let data = try JSONEncoder().encode(cards) - if FileManager.default.fileExists(atPath: fileURL.path) { - try? FileManager.default.removeItem(at: backupURL) - try? FileManager.default.copyItem(at: fileURL, to: backupURL) - } - try data.write(to: fileURL, options: .atomic) - } catch { print("Save failed: \(error)") } - } - - func load() -> [SavedCard] { - - migrateIfNeeded() - try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL) - - if let data = try? Data(contentsOf: fileURL), let list = try? JSONDecoder().decode([SavedCard].self, from: data) { return list } - if let data = try? Data(contentsOf: backupURL), let list = try? JSONDecoder().decode([SavedCard].self, from: data) { return list } - return [] - } - - private func migrateIfNeeded() { - guard let cloud = cloudURL else { return } - let cloudFile = cloud.appendingPathComponent(fileName) - let localFile = localURL.appendingPathComponent(fileName) - - if !FileManager.default.fileExists(atPath: cloud.path) { - try? FileManager.default.createDirectory(at: cloud, withIntermediateDirectories: true) - } - - if FileManager.default.fileExists(atPath: localFile.path) && !FileManager.default.fileExists(atPath: cloudFile.path) { - try? FileManager.default.moveItem(at: localFile, to: cloudFile) - print("MIGRATION: Moved local DB to iCloud.") - ImageManager.migrateToCloud() - } - } -} - -// MARK: - 8. NETWORK MONITOR -class NetworkMonitor: ObservableObject { - static let shared = NetworkMonitor() - private let monitor = NWPathMonitor() - @Published var isConnected = true - init() { - monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.isConnected = path.status == .satisfied } } - monitor.start(queue: DispatchQueue.global(qos: .background)) - } -} - -// MARK: - 9. REVIEW ENGINE -class ReviewEngine { - static func logScan() { - let count = UserDefaults.standard.integer(forKey: "scanCount") + 1 - UserDefaults.standard.set(count, forKey: "scanCount") - if count == 20 || count == 100 { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - SKStoreReviewController.requestReview(in: scene) - } - } - } - } -} \ No newline at end of file +// ============================================================ +// MIGRATION NOTICE — This file has been refactored. +// All code has been moved to the new modular structure. +// Remove this file from your Xcode project and add the +// new files from the following directories: +// +// Services/CoreML/ — ModelManager, ConditionEngine, FoilEngine, SetSymbolEngine +// Services/Vision/ — CardRecognizer (AnalysisActor), OCREngine +// Services/Vision/Heuristics/ — BorderDetector, CornerDetector, ListSymbolDetector, +// SaturationDetector, StampDetector +// Services/Cloud/ — CloudEngine, TrainingUploader +// Services/Utilities/ — StoreEngine, ExportEngine, DevEngine, ReviewEngine +// Data/Network/ — ScryfallAPI (fka InsuranceEngine), NetworkMonitor +// Data/Persistence/ — ImageManager, PersistenceActor +// ============================================================ diff --git a/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift b/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift new file mode 100644 index 0000000..a542e6f --- /dev/null +++ b/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift @@ -0,0 +1,383 @@ +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) } + 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 {} + } + } +} diff --git a/IYmtg_App_iOS/Features/Scanner/ScannerViewModel.swift b/IYmtg_App_iOS/Features/Scanner/ScannerViewModel.swift new file mode 100644 index 0000000..58874a8 --- /dev/null +++ b/IYmtg_App_iOS/Features/Scanner/ScannerViewModel.swift @@ -0,0 +1,449 @@ +import SwiftUI +import UIKit +import Vision +import AVFoundation +import AudioToolbox +import Combine +import os +import ImageIO +import CoreMedia +import CoreImage + +// MARK: - SCANNER VIEW MODEL +// Focused exclusively on camera management, frame processing, and the scanning pipeline. +// Collection state and persistence are delegated to CollectionViewModel. +@MainActor +class ScannerViewModel: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate { + + // MARK: Scanner State + @Published var statusText = "Initializing Database..." + @Published var isDatabaseLoading = true + @Published var detectedCard: CardMetadata? + @Published var detectedDamages: [DamageObservation] = [] + @Published var isProcessing = false + @Published var isFound = false + @Published var isPermissionDenied = false + @Published var showDatabaseAlert = false + @Published var databaseAlertTitle = "" + @Published var databaseAlertMessage = "" + @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 currentFoilType: String = AppConfig.Defaults.defaultFoil + + // MARK: Collection Forwarding (read-only convenience for ScannerView & CardDetailView) + // Changes to collectionVM's @Published properties propagate via objectWillChange forwarding. + var isCollectionLoading: Bool { collectionVM.isCollectionLoading } + var isConnected: Bool { collectionVM.isConnected } + var collections: [String] { collectionVM.collections } + var boxes: [String] { collectionVM.boxes } + var selectedCurrency: CurrencyCode { collectionVM.selectedCurrency } + var currentCollection: String { + get { collectionVM.currentCollection } + set { collectionVM.currentCollection = newValue } + } + var currentBox: String { + get { collectionVM.currentBox } + set { collectionVM.currentBox = newValue } + } + + // MARK: Collection Method Forwarding (for CardDetailView backward compatibility) + func saveManualCard(_ card: SavedCard) { collectionVM.saveManualCard(card) } + func updateCardDetails(_ card: SavedCard) { collectionVM.updateCardDetails(card) } + + // MARK: Dependencies + let collectionVM: CollectionViewModel + var currentFrameImage: CGImage? + + private var collectionVMObservation: AnyCancellable? + private var cancellables = Set() + 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? + + // MARK: Init + + init(collectionVM: CollectionViewModel) { + self.collectionVM = collectionVM + super.init() + + // Forward collectionVM changes so ScannerView re-renders on collection state updates + collectionVMObservation = collectionVM.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + + // Pre-warm ML models in background + Task(priority: .background) { let _ = ConditionEngine.model; let _ = FoilEngine.model } + + // Load card fingerprint database + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + if let url = Bundle.main.url(forResource: "cards", withExtension: "json") { + do { + try 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 + guard let self = self else { return } + self.isDatabaseLoading = false + self.databaseAlertTitle = "Database Error" + self.databaseAlertMessage = "Failed to load card database. Please try restarting the app." + self.showDatabaseAlert = true + } + } + } else { + await MainActor.run { [weak self] in + guard let self = self else { return } + self.isDatabaseLoading = false + self.databaseAlertTitle = "Database Missing" + self.databaseAlertMessage = "The card database could not be found. Please reinstall the app." + self.showDatabaseAlert = true + } + } + } + + DevEngine.activateIfCompiled() + ModelManager.shared.checkForUpdates() + } + + deinit { + UIDevice.current.endGeneratingDeviceOrientationNotifications() + processingTask?.cancel() + saveTask?.cancel() + focusResetTask?.cancel() + } + + // MARK: - Camera Permissions & Setup + + 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 + 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() { + self.isScanningActive = false + focusResetTask?.cancel() + processingTask?.cancel() + collectionVM.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 + } + } + + // MARK: - Frame Processing + + 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 } + + 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 + if now.timeIntervalSince(self.lastFrameTime) < 0.06 { return (false, CGImagePropertyOrientation.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 } } + + guard !isBusy else { return } + + 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 { + if self.isFound || self.isProcessing { return false } + self.isProcessing = true + self.detectHaptics.prepare() + return true + } + guard shouldProceed else { return } + + let croppedImage: CGImage? = await Task.detached { + let width = CGFloat(fullImage.width) + let height = CGFloat(fullImage.height) + 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 + } + + if DevEngine.isDevMode { DevEngine.saveRaw(image: UIImage(cgImage: cropped), label: "AutoCrop") } + if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return } + + // OPTIMIZATION: Run identification and damage grading 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 + } + } + } + + // MARK: - Scan Actions + + 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) + + var entry = SavedCard(from: card, imageName: imageName, collection: collectionVM.currentCollection, location: collectionVM.currentBox) + entry.foilType = self.currentFoilType + entry.condition = ConditionEngine.overallGrade(damages: self.detectedDamages) + + let autoScan = self.isAutoScanEnabled + + // Delegate persistence and cloud sync to CollectionViewModel + collectionVM.addCard(entry, cgImage: cgImage, uiImage: uiImage, autoScan: autoScan) + + if autoScan { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.cancelScan() + self?.isSaving = false + } + } else { + self.cancelScan() + self.isSaving = false + } + } + + // MARK: - Training Uploads + + 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 } + + 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) } + } + } + + // MARK: - Helpers + + 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) + } + } +} diff --git a/IYmtg_App_iOS/IYmtgApp.swift b/IYmtg_App_iOS/IYmtgApp.swift index 9273d7c..082c453 100644 --- a/IYmtg_App_iOS/IYmtgApp.swift +++ b/IYmtg_App_iOS/IYmtgApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import FirebaseCore @main @@ -13,5 +14,6 @@ struct IYmtgApp: App { } var body: some Scene { WindowGroup { ContentView() } + .modelContainer(PersistenceController.shared.container) } } \ No newline at end of file diff --git a/IYmtg_App_iOS/ScannerViewModel.swift b/IYmtg_App_iOS/ScannerViewModel.swift index e4b189f..93c4186 100644 --- a/IYmtg_App_iOS/ScannerViewModel.swift +++ b/IYmtg_App_iOS/ScannerViewModel.swift @@ -1,776 +1,11 @@ -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) - } - } -} \ No newline at end of file +// ============================================================ +// MIGRATION NOTICE — This file has been refactored. +// ScannerViewModel has been split into two view models: +// +// Features/Scanner/ScannerViewModel.swift — Camera & scanning pipeline only +// Features/Collection/CollectionViewModel.swift — Collection state & persistence +// +// Remove this file from your Xcode project and add the +// two new files above. ContentView.swift has already been +// updated to use the new dual-ViewModel injection pattern. +// ============================================================ diff --git a/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift b/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift new file mode 100644 index 0000000..a1f7e60 --- /dev/null +++ b/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift @@ -0,0 +1,70 @@ +import FirebaseFirestore +import FirebaseAuth + +// MARK: - CLOUD ENGINE +class CloudEngine { + static var db: Firestore? { + return FirebaseApp.app() != nil ? Firestore.firestore() : nil + } + + static func signInSilently() { + guard FirebaseApp.app() != nil else { return } + if Auth.auth().currentUser == nil { Auth.auth().signInAnonymously() } + } + + static func save(card: SavedCard) async { + guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } + let data: [String: Any] = [ + "name": card.name, + "set": card.setCode, + "num": card.collectorNumber, + "val": card.currentValuation ?? 0.0, + "cond": card.condition, + "foil": card.foilType, + "coll": card.collectionName, + "loc": card.storageLocation, + "grade": card.grade ?? "", + "svc": card.gradingService ?? "", + "cert": card.certNumber ?? "", + "date": card.dateAdded, + "curr": card.currencyCode ?? "USD", + "rarity": card.rarity ?? "", + "colors": card.colorIdentity ?? [], + "ser": card.isSerialized ?? false, + "img": card.imageFileName, + "custom": card.isCustomValuation + ] + do { + try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).setData(data, merge: true) + } catch { print("Cloud Save Error: \(error)") } + } + + static func delete(card: SavedCard) async { + guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } + do { + try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).delete() + } catch { print("Cloud Delete Error: \(error)") } + } + + static func batchUpdatePrices(cards: [SavedCard]) async { + guard let db = db, let uid = Auth.auth().currentUser?.uid else { return } + let ref = db.collection("users").document(uid).collection("inventory") + + let chunks = stride(from: 0, to: cards.count, by: 400).map { Array(cards[$0.. Severity { + return (type == "Inking" || type == "Rips" || type == "WaterDamage") ? .critical : .minor + } + + static func detectDamage(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> [DamageObservation] { + guard let model = model else { return [] } + let request = VNCoreMLRequest(model: model) + request.imageCropAndScaleOption = .scaleFill + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) + do { + try handler.perform([request]) + guard let results = request.results as? [VNRecognizedObjectObservation] else { return [] } + return results.filter { $0.confidence > 0.7 }.map { obs in + DamageObservation(type: obs.labels.first?.identifier ?? "Unknown", rect: obs.boundingBox, confidence: obs.confidence) + } + } catch { return [] } + } + + static func overallGrade(damages: [DamageObservation]) -> String { + if model == nil && damages.isEmpty { return "Ungraded" } + var score = 0 + for d in damages { + if getSeverity(for: d.type) == .critical { return "Damaged" } + score += 1 + } + if score == 0 { return "Near Mint (NM)" } + if score <= 2 { return "Excellent (EX)" } + return "Played (PL)" + } +} diff --git a/IYmtg_App_iOS/Services/CoreML/FoilEngine.swift b/IYmtg_App_iOS/Services/CoreML/FoilEngine.swift new file mode 100644 index 0000000..0c4970a --- /dev/null +++ b/IYmtg_App_iOS/Services/CoreML/FoilEngine.swift @@ -0,0 +1,48 @@ +import Vision +import CoreML +import CoreGraphics + +// MARK: - FOIL ENGINE +actor FoilEngine { + private var frameCounter = 0 + private var lowConfidenceStreak = 0 + + static var model: VNCoreMLModel? = { + guard AppConfig.enableFoilDetection else { return nil } + return ModelManager.shared.getModel(name: "IYmtgFoilClassifier") + }() + + private lazy var request: VNCoreMLRequest? = { + guard let model = FoilEngine.model else { return nil } + let req = VNCoreMLRequest(model: model) + req.imageCropAndScaleOption = .scaleFill + return req + }() + + func addFrame(_ image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { + frameCounter += 1 + // SAFETY: Prevent integer overflow in long-running sessions + if frameCounter > 1000 { frameCounter = 0 } + if frameCounter % 5 != 0 { return nil } + guard let request = self.request else { return AppConfig.Defaults.defaultFoil } + + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) + + do { + try handler.perform([request]) + guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return AppConfig.Defaults.defaultFoil } + + if top.confidence > 0.85 { + lowConfidenceStreak = 0 + return top.identifier + } else { + lowConfidenceStreak += 1 + if lowConfidenceStreak >= 3 { + return AppConfig.Defaults.defaultFoil + } else { + return nil + } + } + } catch { return AppConfig.Defaults.defaultFoil } + } +} diff --git a/IYmtg_App_iOS/Services/CoreML/ModelManager.swift b/IYmtg_App_iOS/Services/CoreML/ModelManager.swift new file mode 100644 index 0000000..f650285 --- /dev/null +++ b/IYmtg_App_iOS/Services/CoreML/ModelManager.swift @@ -0,0 +1,75 @@ +import CoreML +import Vision +import FirebaseStorage + +struct SharedEngineResources { + static let context = CIContext() +} + +// MARK: - MODEL MANAGER (OTA Updates) +class ModelManager { + static let shared = ModelManager() + private let defaults = UserDefaults.standard + + func getModel(name: String) -> VNCoreMLModel? { + // 1. Check Documents (Downloaded Update) + if let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let modelURL = docDir.appendingPathComponent("models/\(name).mlmodelc") + if FileManager.default.fileExists(atPath: modelURL.path), + let model = try? MLModel(contentsOf: modelURL), + let vnModel = try? VNCoreMLModel(for: model) { + return vnModel + } + } + + // 2. Check Bundle (Built-in Fallback) + if let bundleURL = Bundle.main.url(forResource: name, withExtension: "mlmodelc"), + let model = try? MLModel(contentsOf: bundleURL), + let vnModel = try? VNCoreMLModel(for: model) { + return vnModel + } + return nil + } + + func checkForUpdates() { + guard FirebaseApp.app() != nil else { return } + let models = ["IYmtgFoilClassifier", "IYmtgConditionClassifier", "IYmtgStampClassifier", "IYmtgSetClassifier"] + + for name in models { + let ref = Storage.storage().reference().child("models/\(name).mlmodel") + ref.getMetadata { meta, error in + guard let meta = meta, let remoteDate = meta.updated else { return } + let localDate = self.defaults.object(forKey: "ModelDate_\(name)") as? Date ?? Date.distantPast + + if remoteDate > localDate { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).mlmodel") + ref.write(toFile: tempURL) { url, error in + guard let url = url else { return } + DispatchQueue.global(qos: .utility).async { + do { + let compiledURL = try MLModel.compileModel(at: url) + let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("models") + try FileManager.default.createDirectory(at: docDir, withIntermediateDirectories: true) + let destURL = docDir.appendingPathComponent("\(name).mlmodelc") + let tempDestURL = docDir.appendingPathComponent("temp_\(name).mlmodelc") + + try? FileManager.default.removeItem(at: tempDestURL) + try FileManager.default.copyItem(at: compiledURL, to: tempDestURL) + + if FileManager.default.fileExists(atPath: destURL.path) { + _ = try FileManager.default.replaceItem(at: destURL, withItemAt: tempDestURL, backupItemName: nil, options: []) + } else { + try FileManager.default.moveItem(at: tempDestURL, to: destURL) + } + self.defaults.set(remoteDate, forKey: "ModelDate_\(name)") + print("✅ OTA Model Updated: \(name)") + } catch { + print("❌ Model Update Failed for \(name): \(error)") + } + } + } + } + } + } + } +} diff --git a/IYmtg_App_iOS/Services/CoreML/SetSymbolEngine.swift b/IYmtg_App_iOS/Services/CoreML/SetSymbolEngine.swift new file mode 100644 index 0000000..7e49c1f --- /dev/null +++ b/IYmtg_App_iOS/Services/CoreML/SetSymbolEngine.swift @@ -0,0 +1,29 @@ +import Vision +import CoreML +import CoreGraphics + +// MARK: - SET SYMBOL ENGINE +class SetSymbolEngine { + static var model: VNCoreMLModel? = { + guard AppConfig.enableSetSymbolDetection else { return nil } + return ModelManager.shared.getModel(name: "IYmtgSetClassifier") + }() + + static func recognizeSet(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? { + guard let model = model else { return nil } + let request = VNCoreMLRequest(model: model) + request.imageCropAndScaleOption = .scaleFill + + // FIX: Use regionOfInterest (Normalized 0..1, Bottom-Left origin) + // Target: x=0.85 (Right), y=0.36 (Mid-Lower), w=0.12, h=0.09 + request.regionOfInterest = CGRect(x: 0.85, y: 0.36, width: 0.12, height: 0.09) + + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) + + do { + try handler.perform([request]) + guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return nil } + return top.confidence > 0.6 ? top.identifier : nil + } catch { return nil } + } +} diff --git a/IYmtg_App_iOS/Services/Utilities/DevEngine.swift b/IYmtg_App_iOS/Services/Utilities/DevEngine.swift new file mode 100644 index 0000000..0ef0316 --- /dev/null +++ b/IYmtg_App_iOS/Services/Utilities/DevEngine.swift @@ -0,0 +1,24 @@ +import Foundation +import UIKit + +// MARK: - SECURE DEV MODE +class DevEngine { + static var isDevMode = false + + static func activateIfCompiled() { + #if ENABLE_DEV_MODE + isDevMode = true + print("⚠️ DEV MODE COMPILED") + #endif + } + + static func saveRaw(image: UIImage, label: String) { + #if ENABLE_DEV_MODE + if isDevMode, let data = image.jpegData(compressionQuality: 1.0) { + let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("RawTrainingData") + try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + try? data.write(to: path.appendingPathComponent("TRAIN_\(label)_\(UUID().uuidString).jpg")) + } + #endif + } +} diff --git a/IYmtg_App_iOS/Services/Utilities/ExportEngine.swift b/IYmtg_App_iOS/Services/Utilities/ExportEngine.swift new file mode 100644 index 0000000..8088ef5 --- /dev/null +++ b/IYmtg_App_iOS/Services/Utilities/ExportEngine.swift @@ -0,0 +1,135 @@ +import UIKit +import PDFKit + +enum ExportFormat: String, CaseIterable { case insurance = "PDF"; case arena = "Arena"; case mtgo = "MTGO"; case csv = "CSV" } + +// MARK: - EXPORT ENGINE +class ExportEngine { + static func generateString(cards: [SavedCard], format: ExportFormat) -> String { + if format == .csv { + func cleanCSV(_ text: String) -> String { return text.replacingOccurrences(of: "\"", with: "\"\"") } + var csv = "Count,Name,Set,Number,Condition,Foil,Price,Serialized\n" + for card in cards { + let ser = (card.isSerialized ?? false) ? "Yes" : "No" + let line = "1,\"\(cleanCSV(card.name))\",\(card.setCode),\(card.collectorNumber),\(card.condition),\(card.foilType),\(card.currentValuation ?? 0.0),\(ser)\n" + csv.append(line) + } + return csv + } else if format == .mtgo { + return cards.map { "1 \($0.name)" }.joined(separator: "\n") + } else { + // Arena + return cards.map { "1 \($0.name) (\($0.setCode)) \($0.collectorNumber)" }.joined(separator: "\n") + } + } + + static func generate(cards: [SavedCard], format: ExportFormat) -> URL? { + if format == .insurance { return generatePDF(cards: cards) } + + let text = generateString(cards: cards, format: format) + let filename = format == .csv ? "Collection.csv" : (format == .mtgo ? "MTGO_List.txt" : "Arena_Decklist.txt") + let path = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + try? text.write(to: path, atomically: true, encoding: .utf8) + return path + } + + private static func generatePDF(cards: [SavedCard]) -> URL? { + let fmt = UIGraphicsPDFRendererFormat() + fmt.documentInfo = [kCGPDFContextCreator: "IYmtg"] as [String: Any] + let url = FileManager.default.temporaryDirectory.appendingPathComponent("Insurance.pdf") + + let totalVal = cards.reduce(0) { $0 + ($1.currentValuation ?? 0) } + let count = cards.count + + UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792), format: fmt).writePDF(to: url) { ctx in + var pageY: CGFloat = 50 + let headerAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 24)] + let subHeaderAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14)] + let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)] + + func drawHeader() { + "Insurance Schedule - \(Date().formatted(date: .abbreviated, time: .shortened))".draw(at: CGPoint(x: 50, y: 50), withAttributes: headerAttrs) + } + + func drawColumnHeaders(y: CGFloat) { + let hAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: UIColor.darkGray] + "CARD DETAILS".draw(at: CGPoint(x: 100, y: y), withAttributes: hAttrs) + "SET / #".draw(at: CGPoint(x: 310, y: y), withAttributes: hAttrs) + "COND".draw(at: CGPoint(x: 420, y: y), withAttributes: hAttrs) + "VALUE".draw(at: CGPoint(x: 520, y: y), withAttributes: hAttrs) + } + + ctx.beginPage() + drawHeader() + + // Summary Section + "Policy Holder: __________________________".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) + "Total Items: \(count)".draw(at: CGPoint(x: 50, y: 115), withAttributes: subHeaderAttrs) + "Total Value: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 200, y: 115), withAttributes: subHeaderAttrs) + drawColumnHeaders(y: 135) + + pageY = 150 + + for card in cards { + if pageY + 65 > 740 { + ctx.beginPage() + drawHeader() + drawColumnHeaders(y: 75) + pageY = 90 + } + + // Column 0: Image + if let img = ImageManager.load(name: card.imageFileName) { + img.draw(in: CGRect(x: 50, y: pageY, width: 40, height: 56)) + } + + let textY = pageY + 20 + // Column 1: Name (Truncate if long) + let name = "\(card.name)\((card.isSerialized ?? false) ? " [S]" : "")" + name.draw(in: CGRect(x: 100, y: textY, width: 200, height: 18), withAttributes: textAttrs) + // Column 2: Set + let setInfo = "\(card.setCode.uppercased()) #\(card.collectorNumber)" + setInfo.draw(at: CGPoint(x: 310, y: textY), withAttributes: textAttrs) + // Column 3: Condition (Short code) + let cond = card.condition.components(separatedBy: "(").last?.replacingOccurrences(of: ")", with: "") ?? card.condition + cond.draw(at: CGPoint(x: 420, y: textY), withAttributes: textAttrs) + // Column 4: Value + let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" + val.draw(at: CGPoint(x: 520, y: textY), withAttributes: textAttrs) + + pageY += 65 + } + + // Condensed Summary Section + ctx.beginPage() + drawHeader() + "Condensed Manifest".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) + pageY = 120 + + for card in cards { + if pageY > 740 { + ctx.beginPage() + drawHeader() + "Condensed Manifest (Cont.)".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs) + pageY = 120 + } + + let line = "\(card.name) (\(card.setCode) #\(card.collectorNumber))" + line.draw(in: CGRect(x: 50, y: pageY, width: 400, height: 18), withAttributes: textAttrs) + + let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))" + val.draw(at: CGPoint(x: 500, y: pageY), withAttributes: textAttrs) + pageY += 20 + } + + // FIX: Ensure Grand Total doesn't fall off the page + if pageY + 40 > 792 { + ctx.beginPage() + drawHeader() + pageY = 90 + } + "GRAND TOTAL: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 400, y: pageY + 20), withAttributes: subHeaderAttrs) + } + return url + } +} diff --git a/IYmtg_App_iOS/Services/Utilities/ReviewEngine.swift b/IYmtg_App_iOS/Services/Utilities/ReviewEngine.swift new file mode 100644 index 0000000..b2da115 --- /dev/null +++ b/IYmtg_App_iOS/Services/Utilities/ReviewEngine.swift @@ -0,0 +1,17 @@ +import StoreKit +import UIKit + +// MARK: - REVIEW ENGINE +class ReviewEngine { + static func logScan() { + let count = UserDefaults.standard.integer(forKey: "scanCount") + 1 + UserDefaults.standard.set(count, forKey: "scanCount") + if count == 20 || count == 100 { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + SKStoreReviewController.requestReview(in: scene) + } + } + } + } +} diff --git a/IYmtg_App_iOS/Services/Utilities/StoreEngine.swift b/IYmtg_App_iOS/Services/Utilities/StoreEngine.swift new file mode 100644 index 0000000..23212dd --- /dev/null +++ b/IYmtg_App_iOS/Services/Utilities/StoreEngine.swift @@ -0,0 +1,55 @@ +import StoreKit + +@MainActor +class StoreEngine: ObservableObject { + @Published var products: [Product] = [] + @Published var showThankYou = false + var transactionListener: Task? = nil + + init() { + // FIX: Added [weak self] to prevent retain cycle + transactionListener = Task.detached { [weak self] in + for await result in Transaction.updates { + do { + guard let self = self else { return } + let transaction = try self.checkVerified(result) + await transaction.finish() + await MainActor.run { self.showThankYou = true } + } catch {} + } + } + } + + deinit { transactionListener?.cancel() } + + func loadProducts() async { + do { + let p = try await Product.products(for: AppConfig.tipJarProductIDs) + self.products = p + #if DEBUG + if p.isEmpty { print("⚠️ StoreEngine: No products found. Verify IAP ID in AppConfig.") } + #endif + } catch { print("Store Error: \(error)") } + } + + func purchase(_ product: Product) async { + guard let result = try? await product.purchase() else { return } + switch result { + case .success(let verification): + if let transaction = try? checkVerified(verification) { + await transaction.finish() + self.showThankYou = true + } + case .pending, .userCancelled: break + @unknown default: break + } + } + + nonisolated func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: throw StoreError.failedVerification + case .verified(let safe): return safe + } + } + enum StoreError: Error { case failedVerification } +} diff --git a/IYmtg_App_iOS/Services/Vision/CardRecognizer.swift b/IYmtg_App_iOS/Services/Vision/CardRecognizer.swift new file mode 100644 index 0000000..5316700 --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/CardRecognizer.swift @@ -0,0 +1,131 @@ +import Vision +import CoreML + +// MARK: - ANALYSIS ACTOR (Core Card Recognition) +actor AnalysisActor { + private var database: [CardMetadata] = [] + private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:] + + func loadDatabase(from url: URL) throws { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data) + self.fingerprintCache.removeAll() + self.database.removeAll() + + for card in loaded { + if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) { + self.fingerprintCache[card.id] = obs + } + self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned)) + } + } + + func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) { + guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) } + let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache) + + var resolvedCard: CardMetadata? + var detectedSerialized = false + + switch result { + case .exact(let card): + resolvedCard = card + case .unknown: + return (nil, false) + case .ambiguous(_, let candidates): + let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) + detectedSerialized = isSerialized + + // Run Heuristics to resolve specific ambiguities + let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation) + let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation) + let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation) + let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation) + let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation) + + var filtered = candidates + + // 1. Alpha (LEA) vs Beta (LEB) + if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) { + if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } } + else { filtered = filtered.filter { $0.setCode == "LEB" } } + } + + // 2. Unlimited (2ED) vs Revised (3ED) + if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) { + if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } } + else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } } + } + + // 3. The List / Mystery + if hasListSymbol { + let listSets = ["PLIST", "MB1", "UPLIST", "H1R"] + let listCandidates = filtered.filter { listSets.contains($0.setCode) } + if !listCandidates.isEmpty { filtered = listCandidates } + } + + // 4. World Champ (Gold Border) + if borderColor == .gold { + let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") } + if !wcCandidates.isEmpty { filtered = wcCandidates } + } + + // 5. Promo Stamps + if hasStamp { + let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") } + if !promoCandidates.isEmpty { filtered = promoCandidates } + } + + // 6. Chronicles (White Border) vs Originals (Black Border) + let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"] + if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) { + if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } } + else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } } + } + + // 7. Summer Magic (Edgar) - 1994 Copyright on Revised + if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) { + let sumCandidates = filtered.filter { $0.setCode == "SUM" } + if !sumCandidates.isEmpty { filtered = sumCandidates } + } else if candidates.contains(where: { $0.setCode == "3ED" }) { + filtered = filtered.filter { $0.setCode != "SUM" } + } + + var resolved: CardMetadata? + if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match } + else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match } + else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match } + else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match } + else { resolved = filtered.first ?? candidates.first } + + resolvedCard = resolved + } + + guard let card = resolvedCard else { return (nil, false) } + + // DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing + if card.hasSerializedPrinting { + if case .exact = result { + let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation) + detectedSerialized = isSer + } + } else { + detectedSerialized = false + } + + return (card, detectedSerialized) + } +} + +// MARK: - CLUSTER ENGINE (Ambiguity Resolution) +class ClusterEngine { + static func refine(candidates: [CardMetadata]) -> String? { + // Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt + var scores: [String: Int] = [:] + for (index, card) in candidates.prefix(5).enumerated() { + let weight = max(1, 3 - index) + scores[card.setCode, default: 0] += weight + } + return scores.sorted { $0.value > $1.value }.first?.key + } +} diff --git a/IYmtg_App_iOS/Services/Vision/FeatureMatcher.swift b/IYmtg_App_iOS/Services/Vision/FeatureMatcher.swift new file mode 100644 index 0000000..03366a9 --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/FeatureMatcher.swift @@ -0,0 +1,38 @@ +import Vision +import CoreGraphics +import ImageIO + +class FeatureMatcher { + static let revision = VNGenerateImageFeaturePrintRequest.Revision.revision1 + + static func generateFingerprint(from image: CGImage, orientation: CGImagePropertyOrientation = .up) async throws -> VNFeaturePrintObservation { + let req = VNGenerateImageFeaturePrintRequest() + req.revision = revision + req.imageCropAndScaleOption = .scaleFill + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) + try handler.perform([req]) + + guard let result = req.results?.first as? VNFeaturePrintObservation else { + throw NSError(domain: "FeatureMatcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "No features detected"]) + } + return result + } + + static func identify(scan: VNFeaturePrintObservation, database: [CardMetadata], cache: [UUID: VNFeaturePrintObservation]) -> MatchResult { + var candidates: [(CardMetadata, Float)] = [] + for card in database { + guard let obs = cache[card.id] else { continue } + var dist: Float = 0 + if (try? scan.computeDistance(&dist, to: obs)) != nil && dist < 18.0 { + candidates.append((card, dist)) + } + } + let sorted = candidates.sorted { $0.1 < $1.1 } + guard let best = sorted.first else { return .unknown } + + if best.1 < 6.0 { return .exact(best.0) } + let close = sorted.filter { $0.1 < (best.1 + 3.0) } + if close.count > 1 { return .ambiguous(name: best.0.name, candidates: close.map { $0.0 }) } + return .exact(best.0) + } +} diff --git a/IYmtg_App_iOS/Services/Vision/Heuristics/BorderDetector.swift b/IYmtg_App_iOS/Services/Vision/Heuristics/BorderDetector.swift new file mode 100644 index 0000000..1c9181c --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/Heuristics/BorderDetector.swift @@ -0,0 +1,35 @@ +import CoreImage +import CoreGraphics + +// MARK: - BORDER DETECTOR +class BorderDetector { + enum BorderColor { case black, white, gold, other } + + static func detect(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> BorderColor { + let context = SharedEngineResources.context + let ciImage = CIImage(cgImage: image).oriented(orientation) + let width = ciImage.extent.width + let height = ciImage.extent.height + + // Crop a small strip from the left edge + let cropRect = CGRect(x: CGFloat(width) * 0.02, y: CGFloat(height) * 0.4, width: CGFloat(width) * 0.05, height: CGFloat(height) * 0.2) + let vector = CIVector(cgRect: cropRect) + let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]) + + guard let output = filter?.outputImage else { return .other } + var bitmap = [UInt8](repeating: 0, count: 4) + context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) + + let r = Int(bitmap[0]) + let g = Int(bitmap[1]) + let b = Int(bitmap[2]) + let brightness = (r + g + b) / 3 + + // Gold/Yellow detection (World Champ Decks): High Red/Green, Low Blue + if r > 140 && g > 120 && b < 100 && r > b + 40 { return .gold } + + if brightness < 60 { return .black } + if brightness > 180 { return .white } + return .other + } +} diff --git a/IYmtg_App_iOS/Services/Vision/Heuristics/CornerDetector.swift b/IYmtg_App_iOS/Services/Vision/Heuristics/CornerDetector.swift new file mode 100644 index 0000000..57eaa04 --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/Heuristics/CornerDetector.swift @@ -0,0 +1,39 @@ +import CoreImage +import CoreGraphics + +// MARK: - CORNER DETECTOR (Alpha vs Beta) +class CornerDetector { + static func isAlphaCorner(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { + // Alpha corners are 2mm radius (very round). Beta are 1mm (standard). + // We analyze the top-left corner (4% of width). + // If significantly more "background" (non-black) pixels exist in the corner square, it's Alpha. + + let context = SharedEngineResources.context + let ciImage = CIImage(cgImage: image).oriented(orientation) + let width = ciImage.extent.width + let height = ciImage.extent.height + + let cornerSize = Int(Double(width) * 0.04) + // FIX: Analyze Top-Left corner (y is at top in CIImage coordinates) + let cropRect = CGRect(x: 0, y: CGFloat(height) - CGFloat(cornerSize), width: CGFloat(cornerSize), height: CGFloat(cornerSize)) + + let cropped = ciImage.cropped(to: cropRect) + + var bitmap = [UInt8](repeating: 0, count: cornerSize * cornerSize * 4) + context.render(cropped, toBitmap: &bitmap, rowBytes: cornerSize * 4, bounds: cropRect, format: .RGBA8, colorSpace: nil) + + var backgroundPixelCount = 0 + let totalPixels = cornerSize * cornerSize + + for i in stride(from: 0, to: bitmap.count, by: 4) { + let r = Int(bitmap[i]) + let g = Int(bitmap[i + 1]) + let b = Int(bitmap[i + 2]) + let brightness = (r + g + b) / 3 + if brightness > 80 { backgroundPixelCount += 1 } + } + + // Alpha corners reveal roughly 30-40% background in a tight corner crop. Beta reveals < 20%. + return Double(backgroundPixelCount) / Double(totalPixels) > 0.25 + } +} diff --git a/IYmtg_App_iOS/Services/Vision/Heuristics/ListSymbolDetector.swift b/IYmtg_App_iOS/Services/Vision/Heuristics/ListSymbolDetector.swift new file mode 100644 index 0000000..eab4228 --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/Heuristics/ListSymbolDetector.swift @@ -0,0 +1,30 @@ +import CoreImage +import CoreGraphics + +// MARK: - LIST SYMBOL DETECTOR +class ListSymbolDetector { + static func hasListSymbol(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { + // The "List" / "Mystery" symbol is a small white icon in the bottom-left corner. + // It sits roughly at x: 3-7%, y: 93-97% of the card frame. + + let context = SharedEngineResources.context + let ciImage = CIImage(cgImage: image).oriented(orientation) + let width = ciImage.extent.width + let height = ciImage.extent.height + + // FIX: CIImage uses Bottom-Left origin. The List symbol is Bottom-Left. + let cropRect = CGRect(x: width * 0.03, y: height * 0.03, width: width * 0.05, height: height * 0.04) + let vector = CIVector(cgRect: cropRect) + + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), + let output = filter.outputImage else { return false } + + var bitmap = [UInt8](repeating: 0, count: 4) + context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) + + // A white symbol on a black border will significantly raise the average brightness. + // Pure black ~ 0-20. With symbol ~ 80-150. + let brightness = (Int(bitmap[0]) + Int(bitmap[1]) + Int(bitmap[2])) / 3 + return brightness > 60 + } +} diff --git a/IYmtg_App_iOS/Services/Vision/Heuristics/SaturationDetector.swift b/IYmtg_App_iOS/Services/Vision/Heuristics/SaturationDetector.swift new file mode 100644 index 0000000..c7a5e0a --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/Heuristics/SaturationDetector.swift @@ -0,0 +1,30 @@ +import CoreImage +import CoreGraphics + +// MARK: - SATURATION DETECTOR (Unlimited vs Revised) +class SaturationDetector { + static func analyze(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Double { + // Crop to center 50% to analyze artwork saturation, ignoring borders + let ciImage = CIImage(cgImage: image).oriented(orientation) + let width = ciImage.extent.width + let height = ciImage.extent.height + let cropRect = CGRect(x: width * 0.25, y: height * 0.25, width: width * 0.5, height: height * 0.5) + + let vector = CIVector(cgRect: cropRect) + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]), + let output = filter.outputImage else { return 0 } + + var bitmap = [UInt8](repeating: 0, count: 4) + let context = SharedEngineResources.context + context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) + + // Simple Saturation approximation: (Max - Min) / Max + let r = Double(bitmap[0]) / 255.0 + let g = Double(bitmap[1]) / 255.0 + let b = Double(bitmap[2]) / 255.0 + let maxC = max(r, max(g, b)) + let minC = min(r, min(g, b)) + + return maxC == 0 ? 0 : (maxC - minC) / maxC + } +} diff --git a/IYmtg_App_iOS/Services/Vision/Heuristics/StampDetector.swift b/IYmtg_App_iOS/Services/Vision/Heuristics/StampDetector.swift new file mode 100644 index 0000000..badc723 --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/Heuristics/StampDetector.swift @@ -0,0 +1,21 @@ +import Vision +import CoreML +import CoreGraphics + +// MARK: - STAMP DETECTOR (Promos) +class StampDetector { + static var model: VNCoreMLModel? = { + guard AppConfig.enableStampDetection else { return nil } + return ModelManager.shared.getModel(name: "IYmtgStampClassifier") + }() + + static func hasStamp(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool { + guard let model = model else { return false } + let request = VNCoreMLRequest(model: model) + request.imageCropAndScaleOption = .scaleFill + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:]) + try? handler.perform([request]) + guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return false } + return top.identifier == "Stamped" && top.confidence > 0.8 + } +} diff --git a/IYmtg_App_iOS/Services/Vision/OCREngine.swift b/IYmtg_App_iOS/Services/Vision/OCREngine.swift new file mode 100644 index 0000000..c00040e --- /dev/null +++ b/IYmtg_App_iOS/Services/Vision/OCREngine.swift @@ -0,0 +1,69 @@ +import Vision +import CoreGraphics + +// MARK: - OCR ENGINE +class OCREngine { + static func readCardDetails(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> (setCode: String?, number: String?, year: String?, isSerialized: Bool) { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + let handler = VNImageRequestHandler(cgImage: image, orientation: orientation) + try? handler.perform([request]) + guard let obs = request.results as? [VNRecognizedTextObservation] else { return (nil, nil, nil, false) } + + var possibleSetCode: String? + var possibleNumber: String? + var possibleYear: String? + var isSerialized = false + + for observation in obs { + guard let candidate = observation.topCandidates(1).first else { continue } + let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) + + // Set Code: 3-5 chars, uppercase + // FIX: Ensure it's in the bottom half to avoid reading Card Name (e.g. "FOG") as Set Code + // FIX: Must contain at least one letter to avoid reading years or numbers as Set Codes + if text.count >= 3 && text.count <= 5 && text == text.uppercased() && possibleSetCode == nil && text.rangeOfCharacter(from: .letters) != nil { + // Vision coordinates: (0,0) is Bottom-Left. y < 0.5 is Bottom Half. + // FIX: Tighten to y < 0.2 to match Collector Number and fully exclude Text Box (e.g. "FLY") + if observation.boundingBox.origin.y < 0.2 { possibleSetCode = text } + } + + // Collector Number & Serialized + if text.contains("/") && text.count <= 10 { + // FILTER: Ignore Power/Toughness in bottom-right corner (x > 0.75, y < 0.2) + if observation.boundingBox.origin.x > 0.75 && observation.boundingBox.origin.y < 0.2 { continue } + + // FIX: Tighten standard location to bottom 20% to avoid text box stats (e.g. "10/10" token) + let isStandardLocation = observation.boundingBox.origin.y < 0.2 + + let parts = text.split(separator: "/") + if parts.count == 2 { + let numStr = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let denomStr = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + if let num = Int(numStr), let denom = Int(denomStr) { + if isStandardLocation { + if denom < 10 { continue } + if possibleNumber == nil { possibleNumber = numStr } + } else if observation.boundingBox.origin.y > 0.5 { + // FIX: Only consider Top Half (Art) as Serialized to avoid Text Box false positives + isSerialized = true + } + } + } + } else if text.count >= 1 && text.count <= 5, let first = text.first, first.isNumber, possibleNumber == nil { + // FIX: Only accept simple numbers if they are at the very bottom (Collector Number location) + // AND not in the bottom-right corner (Power/Toughness zone) + if observation.boundingBox.origin.y < 0.2 && observation.boundingBox.origin.x < 0.75 { possibleNumber = text } + } + + // Copyright Year (for Summer Magic detection) + if let range = text.range(of: #"(19|20)\d{2}"#, options: .regularExpression) { + if observation.boundingBox.origin.y < 0.15 { + possibleYear = String(text[range]) + } + } + } + return (possibleSetCode, possibleNumber, possibleYear, isSerialized) + } +} diff --git a/IYmtg_Automation/fetch_set_symbols.py b/IYmtg_Automation/fetch_set_symbols.py index d0b46b2..00f563f 100644 --- a/IYmtg_Automation/fetch_set_symbols.py +++ b/IYmtg_Automation/fetch_set_symbols.py @@ -1,18 +1,31 @@ # INSTALL: pip install requests pillow +# +# SETUP: Replace the email below with your real contact email. +# Scryfall requires an accurate User-Agent per their API policy. +# See: https://scryfall.com/docs/api +CONTACT_EMAIL = "support@iymtg.com" # <-- UPDATE THIS + import os, requests, time, concurrent.futures from PIL import Image from io import BytesIO -HEADERS = { 'User-Agent': 'IYmtg/1.0 (contact@yourdomain.com)', 'Accept': 'application/json' } +HEADERS = { 'User-Agent': f'IYmtg/1.0 ({CONTACT_EMAIL})', 'Accept': 'application/json' } OUTPUT_DIR = "Set_Symbol_Training" +# Layouts where the set symbol is NOT at the standard M15 position. +# Excluding these prevents corrupt/misaligned crops in the training set. +EXCLUDED_LAYOUTS = {'transform', 'modal_dfc', 'reversible_card', 'planeswalker', 'saga', 'battle', 'split', 'flip'} + def main(): + if CONTACT_EMAIL == "support@iymtg.com": + print("⚠️ WARNING: Using default contact email in User-Agent. Update CONTACT_EMAIL at the top of this script.") + if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR) print("--- IYmtg Symbol Harvester ---") - + session = requests.Session() session.headers.update(HEADERS) - + try: response = session.get("https://api.scryfall.com/sets", timeout=15) response.raise_for_status() @@ -20,13 +33,14 @@ def main(): except Exception as e: print(f"Error fetching sets: {e}") return - + valid_types = ['core', 'expansion', 'masters', 'draft_innovation'] target_sets = [s for s in all_sets if s.get('set_type') in valid_types] - + print(f"Found {len(target_sets)} valid sets.") - - # OPTIMIZATION: Process sets in parallel to speed up dataset creation + + # OPTIMIZATION: Process sets in parallel to speed up dataset creation. + # max_workers=5 keeps concurrent requests well within Scryfall's 10 req/s limit. with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: executor.map(lambda s: process_set(s, session), target_sets) @@ -35,57 +49,75 @@ def main(): def process_set(set_obj, session): set_code = set_obj['code'] print(f"Processing {set_code}...") - + set_dir = os.path.join(OUTPUT_DIR, set_code.upper()) os.makedirs(set_dir, exist_ok=True) - + try: - url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5" + # Filter to standard single-faced cards to guarantee reliable symbol crop coordinates. + url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints+layout:normal&per_page=5" resp = session.get(url, timeout=10) - + + if resp.status_code == 404: + # No normal-layout cards found; fall back to any card type + url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5" + resp = session.get(url, timeout=10) + if resp.status_code != 200: print(f"Skipping {set_code}: HTTP {resp.status_code}") return - + cards_resp = resp.json() - + saved = 0 + for i, card in enumerate(cards_resp.get('data', [])): + # Skip layouts where the symbol position differs from the standard M15 crop area + if card.get('layout', '') in EXCLUDED_LAYOUTS: + continue + image_url = None uris = {} - + if 'image_uris' in card: uris = card['image_uris'] elif 'card_faces' in card and len(card['card_faces']) > 0 and 'image_uris' in card['card_faces'][0]: uris = card['card_faces'][0]['image_uris'] - + if 'large' in uris: image_url = uris['large'] elif 'normal' in uris: image_url = uris['normal'] - + if image_url: try: + # Brief sleep between image downloads to respect Scryfall rate limits + time.sleep(0.05) img_resp = session.get(image_url, timeout=10) if img_resp.status_code != 200: continue - + try: img = Image.open(BytesIO(img_resp.content)) - img.verify() # Verify integrity - img = Image.open(BytesIO(img_resp.content)) # Re-open after verify + img.verify() # Verify integrity + img = Image.open(BytesIO(img_resp.content)) # Re-open after verify except Exception: print(f"Skipping corrupt image in {set_code}") continue - + width, height = img.size - # WARNING: This crop area is tuned for modern card frames (M15+). - # Older sets or special frames (Planeswalkers, Sagas) may require different coordinates. + # WARNING: This crop area is tuned for standard M15+ single-faced cards. + # Excluded layouts (DFC, Planeswalker, Saga, etc.) are filtered above. crop_area = (width * 0.85, height * 0.58, width * 0.95, height * 0.65) - + symbol = img.crop(crop_area) symbol = symbol.convert("RGB") symbol.save(os.path.join(set_dir, f"sample_{i}.jpg")) - + saved += 1 + except Exception as e: print(f"Error downloading image for {set_code}: {e}") + + if saved == 0: + print(f"⚠️ No usable images saved for {set_code}") + except Exception as e: print(f"Error searching cards for {set_code}: {e}") -if __name__ == "__main__": main() \ No newline at end of file +if __name__ == "__main__": main() diff --git a/IYmtg_Automation/resize_assets.py b/IYmtg_Automation/resize_assets.py index 1a27e2f..f695033 100644 --- a/IYmtg_Automation/resize_assets.py +++ b/IYmtg_Automation/resize_assets.py @@ -1,5 +1,4 @@ -import os -import sys +import os, sys, argparse # Dependency Check try: @@ -17,17 +16,34 @@ OUTPUT_DIR = os.path.join(BASE_DIR, "Ready_Assets") # Target Dimensions (Width, Height) ASSETS = { - "AppIcon": (1024, 1024), - "logo_header": (300, 80), - "scanner_frame": (600, 800), - "empty_library": (800, 800), - "share_watermark": (400, 100), - "card_placeholder": (600, 840) + "AppIcon": (1024, 1024), + "logo_header": (300, 80), + "scanner_frame": (600, 800), + "empty_library": (800, 800), + "share_watermark": (400, 100), + "card_placeholder": (600, 840), } -def process_assets(): +# Assets whose target aspect ratio differs significantly from a typical AI square output. +# These use thumbnail+pad (letterbox) instead of center-crop to preserve full image content. +PAD_ASSETS = {"logo_header", "share_watermark"} + +def resize_fit(img, size): + """Center-crop and scale to exact size. Best when source matches target aspect ratio.""" + return ImageOps.fit(img, size, method=Image.Resampling.LANCZOS) + +def resize_pad(img, size, bg_color=(0, 0, 0, 0)): + """Scale to fit within size, then pad with bg_color to reach exact dimensions. + Preserves the full image — nothing is cropped. Ideal for logos and banners.""" + img.thumbnail(size, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", size, bg_color) + offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2) + canvas.paste(img, offset) + return canvas + +def process_assets(force_pad=False, force_crop=False): print(f"📂 Working Directory: {BASE_DIR}") - + # Create directories if they don't exist if not os.path.exists(SOURCE_DIR): os.makedirs(SOURCE_DIR) @@ -51,19 +67,32 @@ def process_assets(): img = Image.open(source_path) if img.mode != 'RGBA': img = img.convert('RGBA') - # Smart Crop & Resize (Center focus) - processed_img = ImageOps.fit(img, size, method=Image.Resampling.LANCZOS) - + # Choose resize strategy: + # - PAD_ASSETS (and --pad flag) use thumbnail+letterbox to avoid cropping banner images. + # - Everything else uses center-crop (ImageOps.fit) for a clean full-bleed fill. + use_pad = (name in PAD_ASSETS or force_pad) and not force_crop + if use_pad: + processed_img = resize_pad(img, size) + mode_label = "pad" + else: + processed_img = resize_fit(img, size) + mode_label = "crop" + output_path = os.path.join(OUTPUT_DIR, name + ".png") processed_img.save(output_path, "PNG") - print(f"✅ Generated: {name}.png ({size[0]}x{size[1]})") + print(f"✅ Generated: {name}.png ({size[0]}x{size[1]}, {mode_label})") found = True break except Exception as e: print(f"❌ Error processing {name}: {e}") - + if not found: print(f"⚠️ Skipped: {name} (File not found in Raw_Assets)") if __name__ == "__main__": - process_assets() \ No newline at end of file + parser = argparse.ArgumentParser(description="Resize raw AI assets to Xcode-ready dimensions.") + group = parser.add_mutually_exclusive_group() + group.add_argument("--pad", action="store_true", help="Force thumbnail+pad for ALL assets (no cropping).") + group.add_argument("--crop", action="store_true", help="Force center-crop for ALL assets (overrides default pad assets).") + args = parser.parse_args() + process_assets(force_pad=args.pad, force_crop=args.crop) diff --git a/README.md b/README.md index 8c9e9f5..dff0ffc 100644 --- a/README.md +++ b/README.md @@ -208,12 +208,34 @@ IYmtg_Training/ ## 4. Backend & Security -### Firebase Configuration (Required for Cloud Sync) +### Cloud Storage Architecture +The app uses a two-tier cloud strategy: + +| Tier | Technology | What it stores | Cost | +| :--- | :--- | :--- | :--- | +| **Primary** | iCloud + CloudKit (SwiftData) | All card metadata, synced automatically across devices | Free (user's iCloud) | +| **Secondary** | Firebase Firestore | Metadata only — no images — optional manual backup | Free (Firestore free tier) | + +Card images are stored in the user's iCloud Drive under `Documents/UserContent/` and are **never** uploaded to Firebase. + +### iCloud / CloudKit Setup (Required for Primary Sync) +1. In Xcode, open **Signing & Capabilities**. +2. Add the **iCloud** capability. Enable **CloudKit**. +3. Add a CloudKit container named `iCloud.`. +4. Add the **Background Modes** capability. Enable **Remote notifications**. +5. Set the minimum deployment target to **iOS 17** (required by SwiftData). + +Without this setup the app falls back to local-only storage automatically. + +### Firebase Configuration (Optional Secondary Backup) +Firebase is no longer the primary sync mechanism. It serves as a user-triggered metadata backup. 1. **Create Project:** Go to the Firebase Console and create a new project. 2. **Authentication:** Enable "Anonymous" sign-in in the Authentication tab. 3. **Firestore Database:** Create a database and apply the rules from `IYmtg_App_iOS/Firebase/firestore.rules`. -4. **Storage:** Enable Storage and apply the rules from `IYmtg_App_iOS/Firebase/storage.rules`. -5. **Setup:** Download `GoogleService-Info.plist` from Project Settings and drag it into the `IYmtg_App_iOS` folder in Xcode (ensure "Copy items if needed" is checked). +4. **Setup:** Download `GoogleService-Info.plist` from Project Settings and drag it into the `IYmtg_App_iOS` folder in Xcode (ensure "Copy items if needed" is checked). +5. Users trigger backup manually via **Library → Cloud Backup → Backup Metadata to Firebase Now**. + +The app runs fully without `GoogleService-Info.plist` (Local Mode — iCloud sync still works). ### Over-the-Air (OTA) Model Updates To update ML models without an App Store release: @@ -249,6 +271,7 @@ chmod +x IYmtg_Automation/weekly_update.sh **CRITICAL:** Edit `IYmtg_App_iOS/AppConfig.swift` before building to ensure payments and support work correctly: 1. Set `contactEmail` to your real email address. 2. Set `tipJarProductIDs` to your actual In-App Purchase IDs. +3. `isFirebaseBackupEnabled` defaults to `false`. Users opt-in from Library settings. ## 7. Development Mode @@ -279,12 +302,17 @@ Perform these steps before submitting to the App Store. * [ ] Verify `contactEmail` is valid. * [ ] Verify `tipJarProductIDs` match App Store Connect. * [ ] Ensure `enableFoilDetection` and other flags are `true`. -2. **Assets:** +2. **iCloud / CloudKit:** + * [ ] Signing & Capabilities → iCloud → CloudKit enabled. + * [ ] CloudKit container added: `iCloud.`. + * [ ] Background Modes → Remote notifications enabled. + * [ ] Minimum deployment target set to **iOS 17**. +3. **Assets:** * [ ] Ensure `Assets.xcassets` has the AppIcon filled for all sizes. -3. **Testing:** +4. **Testing:** * [ ] Run Unit Tests (`Cmd+U`) - All must pass. * [ ] Run on Physical Device - Verify Camera permissions prompt appears. -4. **Build:** +5. **Build:** * [ ] Select "Any iOS Device (arm64)". * [ ] Product -> Archive. * [ ] Validate App in Organizer. diff --git a/ai_blueprint.md b/ai_blueprint.md new file mode 100644 index 0000000..090cf7c --- /dev/null +++ b/ai_blueprint.md @@ -0,0 +1,41 @@ +# AI Blueprint + +This document outlines the architecture and plan for development tasks. It is intended to be used by the development team to guide implementation. + +## Instructions for the Architect AI + +1. **Understand the Goal:** Your primary role is to understand the user's request and create a detailed, step-by-step plan for another AI (Claude) to execute. You do not write code. +2. **Analyze the codebase:** Use the available tools to explore the existing files and understand the current state of the project. +3. **Create a Plan:** Based on the user's request and your analysis, create a comprehensive plan. This plan should be broken down into clear, actionable steps. Each step should include: + * A description of the task. + * The specific files to be modified. + * The high-level changes required. +4. **Update this Blueprint:** Overwrite this `ai_blueprint.md` file with the generated plan. +5. **Include README and Commit Steps:** The plan must always include steps for: + * Updating the `README.md` if the changes affect the project's description, setup, or usage. + * Creating a Git commit with a descriptive message to save the changes. +6. **Delegate Implementation:** After creating the plan, your job is complete. Another AI will take over to implement the plan. + +## Development Plan + +> **Note to the Architect AI:** Replace this section with the actual plan for the user's request. + +### 1. Analyze Existing Code + +- **File:** `[Path to relevant file]` +- **Description:** Briefly describe the purpose of the file and the area to be modified. + +### 2. Implement Changes + +- **File:** `[Path to file to be created or modified]` +- **Description:** Detail the changes to be made. For new files, describe the file's purpose and structure. + +### 3. Update Documentation + +- **File:** [`README.md`](README.md) +- **Description:** Update the README to reflect the new changes, features, or bug fixes. + +### 4. Commit Changes + +- **Action:** Create a Git commit. +- **Commit Message:** `feat: [A brief, descriptive message of the changes]`