From b993ef4020c6ba74d5b215a344b0e98e7a1b9917 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Tue, 24 Feb 2026 09:12:46 -0500 Subject: [PATCH] Initial commit: IYmtg Master project Co-Authored-By: Claude Sonnet 4.6 --- IYmtg_App_iOS/AppConfig.swift | 62 ++ IYmtg_App_iOS/CardLogic.swift | 116 +++ IYmtg_App_iOS/ContentView.swift | 418 ++++++++ IYmtg_App_iOS/Engines.swift | 1114 +++++++++++++++++++++ IYmtg_App_iOS/Firebase/firestore.rules | 8 + IYmtg_App_iOS/Firebase/storage.rules | 12 + IYmtg_App_iOS/IYmtgApp.swift | 17 + IYmtg_App_iOS/IYmtgTests.swift | 149 +++ IYmtg_App_iOS/PrivacyInfo.xcprivacy | 25 + IYmtg_App_iOS/ScannerViewModel.swift | 776 ++++++++++++++ IYmtg_Automation/fetch_set_symbols.py | 91 ++ IYmtg_Automation/generate_placeholders.py | 58 ++ IYmtg_Automation/resize_assets.py | 69 ++ IYmtg_Automation/weekly_update.sh | 13 + README.md | 294 ++++++ generate_report.py | 81 ++ 16 files changed, 3303 insertions(+) create mode 100644 IYmtg_App_iOS/AppConfig.swift create mode 100644 IYmtg_App_iOS/CardLogic.swift create mode 100644 IYmtg_App_iOS/ContentView.swift create mode 100644 IYmtg_App_iOS/Engines.swift create mode 100644 IYmtg_App_iOS/Firebase/firestore.rules create mode 100644 IYmtg_App_iOS/Firebase/storage.rules create mode 100644 IYmtg_App_iOS/IYmtgApp.swift create mode 100644 IYmtg_App_iOS/IYmtgTests.swift create mode 100644 IYmtg_App_iOS/PrivacyInfo.xcprivacy create mode 100644 IYmtg_App_iOS/ScannerViewModel.swift create mode 100644 IYmtg_Automation/fetch_set_symbols.py create mode 100644 IYmtg_Automation/generate_placeholders.py create mode 100644 IYmtg_Automation/resize_assets.py create mode 100644 IYmtg_Automation/weekly_update.sh create mode 100644 README.md create mode 100644 generate_report.py diff --git a/IYmtg_App_iOS/AppConfig.swift b/IYmtg_App_iOS/AppConfig.swift new file mode 100644 index 0000000..bcee63b --- /dev/null +++ b/IYmtg_App_iOS/AppConfig.swift @@ -0,0 +1,62 @@ +import Foundation + +enum CurrencyCode: String, CaseIterable, Codable { + case usd = "USD" + case eur = "EUR" + + var symbol: String { + switch self { + case .usd: return "$" + case .eur: return "€" + } + } + + var scryfallKey: String { + switch self { + case .usd: return "usd" + case .eur: return "eur" + } + } +} + +struct AppConfig { + // 1. CONTACT EMAIL (Required by Scryfall) + static let contactEmail = "support@iymtg.com" // Example: Use your real email + + // 2. IN-APP PURCHASE ID (Use a "Consumable" type in App Store Connect for repeatable tips) + static let tipJarProductIDs: [String] = [] // Example: Use your real Product ID + + // Feature Flags + static let enableFoilDetection = true + static let enableConditionGrading = true + static let enableSetSymbolDetection = true + static let enableStampDetection = true + static let defaultCurrency: CurrencyCode = .usd + + struct Defaults { + static let masterCollectionName = "Master Collection" + static let unsortedBoxName = "Unsorted" + static let defaultCondition = "Near Mint (NM)" + static let defaultFoil = "None" + } + + static var isTrainingOptIn: Bool { + get { UserDefaults.standard.bool(forKey: "TrainingOptIn") } + set { UserDefaults.standard.set(newValue, forKey: "TrainingOptIn") } + } + + static var scryfallUserAgent: String { + return "IYmtg/1.0 (\(contactEmail))" + } + + static func validate() { + #if DEBUG + 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") { + print("⚠️ CONFIG WARNING: 'tipJarProductIDs' contains placeholder. IAP will not load.") + } + #endif + } +} \ No newline at end of file diff --git a/IYmtg_App_iOS/CardLogic.swift b/IYmtg_App_iOS/CardLogic.swift new file mode 100644 index 0000000..d092dea --- /dev/null +++ b/IYmtg_App_iOS/CardLogic.swift @@ -0,0 +1,116 @@ +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 diff --git a/IYmtg_App_iOS/ContentView.swift b/IYmtg_App_iOS/ContentView.swift new file mode 100644 index 0000000..fe13269 --- /dev/null +++ b/IYmtg_App_iOS/ContentView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UIKit +import AVFoundation +import StoreKit + +struct ContentView: View { + @StateObject var vm = ScannerViewModel() + @StateObject var store = StoreEngine() + @AppStorage("hasLaunchedBefore") var hasLaunchedBefore = false + + 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") } + } + .preferredColorScheme(.dark).accentColor(Color(red: 0.6, green: 0.3, blue: 0.9)) + .task { await store.loadProducts() } + .alert("Thank You!", isPresented: $store.showThankYou) { + Button("OK", role: .cancel) { } + } message: { Text("Your support keeps the app ad-free!") } + .sheet(isPresented: Binding(get: { !hasLaunchedBefore }, set: { _ in })) { + WelcomeView(hasLaunchedBefore: $hasLaunchedBefore) + } + } +} + +// DASHBOARD VIEW +struct DashboardView: View { + @ObservedObject var vm: ScannerViewModel + + var body: some View { + NavigationStack { + List { + Section(header: Text("Market Overview")) { + HStack { + VStack(alignment: .leading) { Text("Total Value").font(.caption).foregroundColor(.gray); Text("\(vm.selectedCurrency.symbol)\(vm.portfolioValue, specifier: "%.2f")").font(.title).bold() } + Spacer() + VStack(alignment: .trailing) { + Text("24h Change").font(.caption).foregroundColor(.gray) + Text((vm.portfolioDailyChange >= 0 ? "+" : "") + String(format: "%.2f", vm.portfolioDailyChange)) + .foregroundColor(vm.portfolioDailyChange >= 0 ? .green : .red).bold() + } + } + } + + Section(header: Text("Top Movers")) { + if vm.topMovers.isEmpty { Text("No price changes detected today.").foregroundColor(.gray) } + ForEach(vm.topMovers) { card in + HStack { + VStack(alignment: .leading) { Text(card.name).font(.headline); Text(card.setCode).font(.caption).foregroundColor(.gray) } + Spacer() + let diff = (card.currentValuation ?? 0) - (card.previousValuation ?? 0) + Text((diff >= 0 ? "+" : "") + String(format: "%.2f", diff)).foregroundColor(diff >= 0 ? .green : .red) + } + } + } + } + .navigationTitle("Dashboard") + } + } +} + +// SCANNER VIEW +struct ScannerView: View { + @ObservedObject var vm: ScannerViewModel + @Environment(\.scenePhase) var scenePhase + @State private var devModeTapCount = 0 + @State private var focusPoint: CGPoint? = nil + @State private var focusTask: Task? = nil + @AppStorage("TrainingOptIn") var isTrainingOptIn = false + @State private var showManualEdit = false + + var body: some View { + ZStack(alignment: .top) { + Group { + if vm.isPermissionDenied { + VStack(spacing: 20) { + Image(systemName: "camera.fill.badge.ellipsis").font(.system(size: 60)).foregroundColor(.red) + Text("Camera Access Required").font(.title2).bold() + Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } }.buttonStyle(.borderedProminent) + } + .padding().background(.ultraThinMaterial).cornerRadius(16).frame(maxHeight: .infinity) + } else if vm.isDatabaseLoading || vm.isCollectionLoading { + VStack { ProgressView().scaleEffect(2).tint(.white); Text(vm.statusText).padding(.top).font(.caption).foregroundColor(.white) }.frame(maxHeight: .infinity) + } else { + ZStack { + GeometryReader { geo in + CameraPreview(session: vm.session) + .onTapGesture { location in + let x = location.x / geo.size.width + let y = location.y / geo.size.height + vm.focusCamera(at: CGPoint(x: y, y: 1 - x)) + focusTask?.cancel() + focusTask = Task { + withAnimation { focusPoint = location } + try? await Task.sleep(nanoseconds: 500_000_000) + withAnimation { focusPoint = nil } + } + } + } + .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) + .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) } + if let card = vm.detectedCard { + VStack(spacing: 12) { + Text(card.name).font(.title).bold().multilineTextAlignment(.center).minimumScaleFactor(0.5).foregroundColor(.black) + if card.isSerialized { Text("SERIALIZED").font(.headline).bold().foregroundColor(.purple).padding(4).overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.purple)) } + Text("Foil: \(vm.currentFoilType)").font(.caption).foregroundColor(.orange) + Text("Cond: \(ConditionEngine.overallGrade(damages: vm.detectedDamages))").font(.caption).foregroundColor(.gray) + HStack { + Button(action: { vm.cancelScan() }) { Image(systemName: "xmark.circle.fill").font(.title).foregroundColor(.gray) }.disabled(vm.isSaving) + Button(action: { vm.saveCurrentCard() }) { if vm.isSaving { ProgressView().tint(.white) } else { Text("Save") } }.buttonStyle(.borderedProminent).disabled(vm.isSaving) + Button(action: { showManualEdit = true }) { Image(systemName: "pencil.circle.fill").font(.title).foregroundColor(.blue) }.disabled(vm.isSaving) + } + Menu { + Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") } + Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { + isTrainingOptIn.toggle() + } + } label: { + Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue) + } + }.padding().background(Color.white).cornerRadius(15).padding().shadow(radius: 10) + } + } + } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack { + HStack { + Image("logo_header").resizable().scaledToFit().frame(height: 32) + .onTapGesture { + #if ENABLE_DEV_MODE + devModeTapCount += 1 + if devModeTapCount >= 5 { vm.statusText = "DEV MODE: CAPTURING" } + #endif + } + Spacer() + Button(action: { vm.isAutoScanEnabled.toggle() }) { Image(systemName: vm.isAutoScanEnabled ? "bolt.badge.a.fill" : "bolt.badge.a").foregroundColor(vm.isAutoScanEnabled ? .green : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) } + Button(action: { vm.toggleTorch() }) { Image(systemName: vm.isTorchOn ? "bolt.fill" : "bolt.slash.fill").foregroundColor(vm.isTorchOn ? .yellow : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) } + 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" } + } 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" } + } 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() + } + } + .onAppear { vm.startSession() } + .onDisappear { vm.stopSession() } + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { vm.checkCameraPermissions() } + else if newPhase == .background { vm.stopSession() } + } + .sheet(isPresented: $showManualEdit) { + if let detected = vm.detectedCard, let cg = vm.currentFrameImage { + let orientation = UIImage.Orientation.right // Default for simplicity in edit mode + 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) + } + } + } +} + +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.videoPreviewLayer.session = session + view.videoPreviewLayer.videoGravity = .resizeAspectFill + return view + } + func updateUIView(_ uiView: PreviewView, context: Context) {} + class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + override func layoutSubviews() { super.layoutSubviews(); videoPreviewLayer.frame = bounds } + } +} + +// COLLECTION VIEW +struct CollectionView: View { + @ObservedObject var vm: ScannerViewModel + @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...") } + else if vm.scannedList.isEmpty { VStack(spacing: 20) { Image("empty_library").resizable().scaledToFit().frame(width: 250).opacity(0.8); Text("No Cards Yet").font(.title2).bold(); Text("Start scanning to build your collection.").foregroundColor(.gray) } } + else if vm.filteredList.isEmpty && !vm.librarySearchText.isEmpty { VStack(spacing: 10) { Image(systemName: "magnifyingglass").font(.largeTitle).foregroundColor(.gray); Text("No results for '\(vm.librarySearchText)'").foregroundColor(.gray) }.frame(maxHeight: .infinity) } + else if vm.filteredList.isEmpty { VStack(spacing: 20) { Image(systemName: "folder.badge.plus").font(.system(size: 60)).foregroundColor(.gray); Text("No Cards in \(vm.currentCollection)").font(.title3).bold(); Text("Scan cards to add them here.").foregroundColor(.gray) } } + else { + List { + 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") { + let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred() + vm.refreshPrices(force: true) + } + 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("Support the App")) { ForEach(store.products) { p in Button("Tip \(p.displayPrice)") { Task { await store.purchase(p) } } } } + ForEach(vm.filteredList) { card in + HStack { + VStack(alignment: .leading) { + HStack { Text(card.name).font(.headline); if card.isSerialized ?? false { Image(systemName: "number.circle.fill").foregroundColor(.purple).font(.caption) } } + Text("\(card.condition) • \(card.foilType)" + (card.gradingService != nil ? " • \(card.gradingService ?? "graded") \(card.grade ?? "")" : "")).font(.caption).foregroundColor(.gray) + } + Spacer() + VStack(alignment: .trailing) { + if vm.isConnected { + 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) } + 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 } } } } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { vm.deleteCard(card) } label: { Label("Delete", systemImage: "trash") } } + } + } + .searchable(text: $vm.librarySearchText).autocorrectionDisabled().textInputAutocapitalization(.never).scrollDismissesKeyboard(.immediately).navigationTitle("Library") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Picker("Sort By", selection: $vm.sortOption) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.rawValue).tag(option) } } + Divider() + Button("Export PDF (Insurance)") { Task { shareItem = await vm.exportCollection(format: .insurance); if shareItem != nil { showShare = true } } } + Button("Export CSV (Moxfield/Archidekt)") { Task { shareItem = await vm.exportCollection(format: .csv); if shareItem != nil { showShare = true } } } + Divider() + Menu("Export Arena") { + Button("Copy to Clipboard") { vm.copyToClipboard(format: .arena) } + Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .arena); if shareItem != nil { showShare = true } } } + } + Menu("Export MTGO") { + Button("Copy to Clipboard") { vm.copyToClipboard(format: .mtgo) } + Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .mtgo); if shareItem != nil { showShare = true } } } + } + } label: { Label("Options", systemImage: "ellipsis.circle") } + } + } + .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) } + } + } + } +} + +// CARD DETAIL VIEW +struct CardDetailView: View { + @State var card: SavedCard + var vm: ScannerViewModel + var isNewEntry: Bool = false + @Environment(\.dismiss) var dismiss + @FocusState private var isInputActive: Bool + @State private var displayImage: UIImage? + @State private var originalCard: SavedCard? + @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 { + Section { + HStack { Spacer(); if let img = displayImage { Image(uiImage: img).resizable().scaledToFit().frame(height: 300).cornerRadius(12).shadow(radius: 5) } else { ProgressView().frame(height: 300) }; Spacer() }.listRowBackground(Color.clear) + } + Section(header: Text("Card Info")) { + TextField("Card Name", text: $card.name) + HStack { + TextField("Set", text: $card.setCode).autocorrectionDisabled() + TextField("Number", text: $card.collectorNumber).keyboardType(.numbersAndPunctuation) + } + Toggle("Serialized Card", isOn: Binding(get: { card.isSerialized ?? false }, set: { card.isSerialized = $0 })) + Picker("Condition", selection: $card.condition) { ForEach(conditions, id: \.self) { Text($0) } } + Picker("Foil / Finish", selection: $card.foilType) { ForEach(foilTypes, id: \.self) { Text($0) } } + } + 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 })) } + } + 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 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) } + } + } + } + .navigationTitle("Edit Card") + .scrollDismissesKeyboard(.interactively) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { Spacer(); Button("Done") { isInputActive = false } } + Button("Save") { isInputActive = false; saveChanges() } + } + .task { + if let img = await Task.detached(priority: .userInitiated, operation: { return ImageManager.load(name: card.imageFileName) }).value { self.displayImage = img } + 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("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) } + finishSave() + } + } + + func finishSave() { + if isNewEntry { vm.saveManualCard(card) } else { vm.updateCardDetails(card) } + dismiss() + } +} + +struct ShareSheet: UIViewControllerRepresentable { + var items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) } + func updateUIViewController(_ ui: UIActivityViewController, context: Context) {} +} + +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.") + } + .padding(.horizontal) + + Spacer() + + Button(action: { hasLaunchedBefore = true }) { + Text("Get Started") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color(red: 0.6, green: 0.3, blue: 0.9)) + .foregroundColor(.white) + .cornerRadius(12) + } + .padding() + } + .padding(.top, 50) + .interactiveDismissDisabled() + } +} + +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) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.headline) + Text(text).font(.subheadline).foregroundColor(.gray) + } + } + } +} \ No newline at end of file diff --git a/IYmtg_App_iOS/Engines.swift b/IYmtg_App_iOS/Engines.swift new file mode 100644 index 0000000..569a527 --- /dev/null +++ b/IYmtg_App_iOS/Engines.swift @@ -0,0 +1,1114 @@ +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 diff --git a/IYmtg_App_iOS/Firebase/firestore.rules b/IYmtg_App_iOS/Firebase/firestore.rules new file mode 100644 index 0000000..89f4ac1 --- /dev/null +++ b/IYmtg_App_iOS/Firebase/firestore.rules @@ -0,0 +1,8 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /users/{userId}/{document=**} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + } +} \ No newline at end of file diff --git a/IYmtg_App_iOS/Firebase/storage.rules b/IYmtg_App_iOS/Firebase/storage.rules new file mode 100644 index 0000000..bb7a150 --- /dev/null +++ b/IYmtg_App_iOS/Firebase/storage.rules @@ -0,0 +1,12 @@ +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /training/{allPaths=**} { + allow write: if request.auth != null; + allow read: if false; + } + match /models/{allPaths=**} { + allow read: if true; + } + } +} \ No newline at end of file diff --git a/IYmtg_App_iOS/IYmtgApp.swift b/IYmtg_App_iOS/IYmtgApp.swift new file mode 100644 index 0000000..9273d7c --- /dev/null +++ b/IYmtg_App_iOS/IYmtgApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import FirebaseCore + +@main +struct IYmtgApp: App { + init() { + AppConfig.validate() + if Bundle.main.path(forResource: "GoogleService-Info", ofType: "plist") != nil { + FirebaseApp.configure() + } else { + print("⚠️ GoogleService-Info.plist missing. Running in Local Mode.") + } + } + var body: some Scene { + WindowGroup { ContentView() } + } +} \ No newline at end of file diff --git a/IYmtg_App_iOS/IYmtgTests.swift b/IYmtg_App_iOS/IYmtgTests.swift new file mode 100644 index 0000000..77fa213 --- /dev/null +++ b/IYmtg_App_iOS/IYmtgTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import IYmtg_App_iOS // Ensure this matches your Target name + +final class IYmtgTests: XCTestCase { + + // MARK: - Card Model Tests + + func testSavedCardInitialization() { + let uuid = UUID() + let metadata = CardMetadata( + id: uuid, + name: "Black Lotus", + setCode: "LEA", + collectorNumber: "232", + hasFoilPrinting: false, + hasSerializedPrinting: false, + priceScanned: 10000.0, + rarity: "rare", + colorIdentity: ["U"], + isSerialized: false + ) + + let savedCard = SavedCard(from: metadata, imageName: "test_image.jpg", collection: "My Binder", location: "Page 1") + + XCTAssertEqual(savedCard.name, "Black Lotus") + XCTAssertEqual(savedCard.setCode, "LEA") + XCTAssertEqual(savedCard.collectorNumber, "232") + XCTAssertEqual(savedCard.scryfallID, "LEA-232") + XCTAssertEqual(savedCard.collectionName, "My Binder") + XCTAssertEqual(savedCard.storageLocation, "Page 1") + XCTAssertEqual(savedCard.currentValuation, 10000.0) + XCTAssertEqual(savedCard.condition, "Near Mint (NM)") // Default + XCTAssertEqual(savedCard.foilType, "None") // Default + } + + // MARK: - Condition Engine Tests + + func testConditionGradingLogic() { + // Mock Damage Observations + let criticalDamage = DamageObservation(type: "Rips", rect: .zero, confidence: 0.9) + let minorDamage1 = DamageObservation(type: "EdgeWear", rect: .zero, confidence: 0.8) + let minorDamage2 = DamageObservation(type: "Scratch", rect: .zero, confidence: 0.8) + let minorDamage3 = DamageObservation(type: "Dent", rect: .zero, confidence: 0.8) + + // 1. Excellent (1-2 minor damages) + XCTAssertEqual(ConditionEngine.overallGrade(damages: [minorDamage1]), "Excellent (EX)") + XCTAssertEqual(ConditionEngine.overallGrade(damages: [minorDamage1, minorDamage2]), "Excellent (EX)") + + // 2. Played (>2 minor damages) + XCTAssertEqual(ConditionEngine.overallGrade(damages: [minorDamage1, minorDamage2, minorDamage3]), "Played (PL)") + + // 3. Damaged (Any critical damage) + XCTAssertEqual(ConditionEngine.overallGrade(damages: [criticalDamage]), "Damaged") + XCTAssertEqual(ConditionEngine.overallGrade(damages: [minorDamage1, criticalDamage]), "Damaged") + + // 4. Ungraded (Empty list + No Model loaded in Test Env) + // Note: If model were loaded, this would return "Near Mint (NM)" + XCTAssertEqual(ConditionEngine.overallGrade(damages: []), "Ungraded") + } + + // MARK: - Export Engine Tests + + func testCSVExportGeneration() { + let card1 = SavedCard( + from: CardMetadata(id: UUID(), name: "Card A", setCode: "SET", collectorNumber: "1", hasFoilPrinting: false, hasSerializedPrinting: false, priceScanned: 10.0), + imageName: "img1.jpg", collection: "Col", location: "Loc" + ) + var card2 = SavedCard( + from: CardMetadata(id: UUID(), name: "Card B", setCode: "SET", collectorNumber: "2", hasFoilPrinting: true, hasSerializedPrinting: true, priceScanned: 20.0), + imageName: "img2.jpg", collection: "Col", location: "Loc" + ) + card2.isSerialized = true + + let csv = ExportEngine.generateString(cards: [card1, card2], format: .csv) + let lines = csv.components(separatedBy: "\n") + + // Header + 2 cards + empty newline at end + XCTAssertTrue(lines[0].contains("Count,Name,Set,Number,Condition,Foil,Price,Serialized")) + + // Check Card 1 + XCTAssertTrue(lines[1].contains("\"Card A\",SET,1")) + XCTAssertTrue(lines[1].contains(",No")) + + // Check Card 2 + XCTAssertTrue(lines[2].contains("\"Card B\",SET,2")) + XCTAssertTrue(lines[2].contains(",Yes")) + } + + func testArenaExportGeneration() { + let card = SavedCard( + from: CardMetadata(id: UUID(), name: "Lightning Bolt", setCode: "LEA", collectorNumber: "161", hasFoilPrinting: false, hasSerializedPrinting: false), + imageName: "img.jpg", collection: "Binder", location: "Box" + ) + + let arena = ExportEngine.generateString(cards: [card], format: .arena) + XCTAssertEqual(arena, "1 Lightning Bolt (LEA) 161") + } + + func testMTGOExportGeneration() { + let card = SavedCard( + from: CardMetadata(id: UUID(), name: "Lightning Bolt", setCode: "LEA", collectorNumber: "161", hasFoilPrinting: false, hasSerializedPrinting: false), + imageName: "img.jpg", collection: "Binder", location: "Box" + ) + + let mtgo = ExportEngine.generateString(cards: [card], format: .mtgo) + XCTAssertEqual(mtgo, "1 Lightning Bolt") + } + + // MARK: - ViewModel Logic Tests + + @MainActor + func testViewModelFiltering() { + let vm = ScannerViewModel() + + let card1 = SavedCard(from: CardMetadata(id: UUID(), name: "Alpha", setCode: "A", collectorNumber: "1", hasFoilPrinting: false, hasSerializedPrinting: false, priceScanned: 100), imageName: "1", collection: "Master Collection", location: "Box") + let card2 = SavedCard(from: CardMetadata(id: UUID(), name: "Beta", setCode: "B", collectorNumber: "2", hasFoilPrinting: false, hasSerializedPrinting: false, priceScanned: 200), imageName: "2", collection: "Master Collection", location: "Box") + + vm.scannedList = [card1, card2] + + // Test Search + vm.librarySearchText = "Alpha" + + // Wait for async recalc + let expectation = XCTestExpectation(description: "Filter updates") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + XCTAssertEqual(vm.filteredList.count, 1) + XCTAssertEqual(vm.filteredList.first?.name, "Alpha") + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + @MainActor + func testPortfolioCalculation() { + let vm = ScannerViewModel() + let card1 = SavedCard(from: CardMetadata(id: UUID(), name: "A", setCode: "A", collectorNumber: "1", hasFoilPrinting: false, hasSerializedPrinting: false, priceScanned: 50.0), imageName: "1", collection: "Master Collection", location: "Box") + let card2 = SavedCard(from: CardMetadata(id: UUID(), name: "B", setCode: "B", collectorNumber: "2", hasFoilPrinting: false, hasSerializedPrinting: false, priceScanned: 150.0), imageName: "2", collection: "Master Collection", location: "Box") + + vm.scannedList = [card1, card2] + + let expectation = XCTestExpectation(description: "Stats update") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + XCTAssertEqual(vm.portfolioValue, 200.0) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/IYmtg_App_iOS/PrivacyInfo.xcprivacy b/IYmtg_App_iOS/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..66a7a93 --- /dev/null +++ b/IYmtg_App_iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,25 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + C617.1 + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + CA92.1 + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + + \ No newline at end of file diff --git a/IYmtg_App_iOS/ScannerViewModel.swift b/IYmtg_App_iOS/ScannerViewModel.swift new file mode 100644 index 0000000..e4b189f --- /dev/null +++ b/IYmtg_App_iOS/ScannerViewModel.swift @@ -0,0 +1,776 @@ +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 diff --git a/IYmtg_Automation/fetch_set_symbols.py b/IYmtg_Automation/fetch_set_symbols.py new file mode 100644 index 0000000..d0b46b2 --- /dev/null +++ b/IYmtg_Automation/fetch_set_symbols.py @@ -0,0 +1,91 @@ +# INSTALL: pip install requests pillow +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' } +OUTPUT_DIR = "Set_Symbol_Training" + +def main(): + 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() + all_sets = response.json().get('data', []) + 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 + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + executor.map(lambda s: process_set(s, session), target_sets) + + print("DONE. Drag 'Set_Symbol_Training' into Create ML -> Image Classification.") + +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" + 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() + + for i, card in enumerate(cards_resp.get('data', [])): + 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: + 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 + 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. + 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")) + + except Exception as e: + print(f"Error downloading image for {set_code}: {e}") + except Exception as e: + print(f"Error searching cards for {set_code}: {e}") + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/IYmtg_Automation/generate_placeholders.py b/IYmtg_Automation/generate_placeholders.py new file mode 100644 index 0000000..6d6a774 --- /dev/null +++ b/IYmtg_Automation/generate_placeholders.py @@ -0,0 +1,58 @@ +import os +import sys + +try: + from PIL import Image, ImageDraw +except ImportError: + print("❌ Error: Pillow library not found.") + print("👉 Please run: pip install Pillow") + sys.exit(1) + +# Configuration +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RAW_DIR = os.path.join(BASE_DIR, "Raw_Assets") + +# Assets to generate (Keys match resize_assets.py) +ASSETS = [ + "AppIcon", + "logo_header", + "scanner_frame", + "empty_library", + "share_watermark", + "card_placeholder" +] + +def create_placeholders(): + if not os.path.exists(RAW_DIR): + os.makedirs(RAW_DIR) + print(f"✅ Created folder: {RAW_DIR}") + + print(f"🚀 Generating placeholder images in 'Raw_Assets'...") + + for name in ASSETS: + # Generate a generic large square for the raw input + # The resize script handles cropping, so we just need a valid image source + # We use 2048x2048 to simulate a large AI output + img = Image.new('RGB', (2048, 2048), color=(40, 40, 50)) + d = ImageDraw.Draw(img) + + # Draw a border and text + d.rectangle([50, 50, 1998, 1998], outline=(0, 255, 0), width=40) + + # Simple text drawing (default font) + # In a real scenario, you'd replace these with your AI images + d.text((100, 100), f"PLACEHOLDER: {name}", fill=(0, 255, 0)) + d.text((100, 200), "Replace with AI Art", fill=(255, 255, 255)) + + filename = f"{name}.png" + path = os.path.join(RAW_DIR, filename) + + # Only create if it doesn't exist to avoid overwriting real AI art + if not os.path.exists(path): + img.save(path) + print(f"✅ Created: {filename}") + else: + print(f"⚠️ Skipped: {filename} (File already exists)") + +if __name__ == "__main__": + create_placeholders() \ No newline at end of file diff --git a/IYmtg_Automation/resize_assets.py b/IYmtg_Automation/resize_assets.py new file mode 100644 index 0000000..1a27e2f --- /dev/null +++ b/IYmtg_Automation/resize_assets.py @@ -0,0 +1,69 @@ +import os +import sys + +# Dependency Check +try: + from PIL import Image, ImageOps +except ImportError: + print("❌ Error: Pillow library not found.") + print("👉 Please run: pip install Pillow") + sys.exit(1) + +# Configuration +# Paths are relative to where the script is run, assuming run from root or automation folder +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SOURCE_DIR = os.path.join(BASE_DIR, "Raw_Assets") +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) +} + +def process_assets(): + print(f"📂 Working Directory: {BASE_DIR}") + + # Create directories if they don't exist + if not os.path.exists(SOURCE_DIR): + os.makedirs(SOURCE_DIR) + print(f"✅ Created source folder: {SOURCE_DIR}") + print("👉 ACTION REQUIRED: Place your raw AI images here (e.g., 'AppIcon.png') and run this script again.") + return + + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR) + print(f"✅ Created output folder: {OUTPUT_DIR}") + + print(f"🚀 Processing images from 'Raw_Assets'...") + + for name, size in ASSETS.items(): + found = False + # Check for common image extensions + for ext in [".png", ".jpg", ".jpeg", ".webp"]: + source_path = os.path.join(SOURCE_DIR, name + ext) + if os.path.exists(source_path): + try: + 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) + + 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]})") + 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 diff --git a/IYmtg_Automation/weekly_update.sh b/IYmtg_Automation/weekly_update.sh new file mode 100644 index 0000000..64fc170 --- /dev/null +++ b/IYmtg_Automation/weekly_update.sh @@ -0,0 +1,13 @@ +#!/bin/bash +echo "--- Starting IYmtg Update ---" +cd ../IYmtg_Builder_Mac +xcodebuild -scheme IYmtg_Builder_Mac -configuration Release +./Build/Products/Release/IYmtg_Builder_Mac +mv ~/IYmtg_Workspace/cards.json ../IYmtg_App_iOS/cards.json + +# Generate Description Update +echo "Generating Model Report..." +cd ../IYmtg_Automation +python3 generate_report.py + +echo "SUCCESS. Deploy App Now." \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c9e9f5 --- /dev/null +++ b/README.md @@ -0,0 +1,294 @@ +# IYmtg Platinum Prime (Version 1.0.0) +**SYSTEM CONTEXT FOR AI (STRICT PRESERVATION)** +CRITICAL INSTRUCTION: This document is the single, authoritative Source of Truth for "IYmtg," an iOS application designed to identify, grade, and insure Magic: The Gathering cards. + +* **Version Authority:** This Version 1.0.0 supersedes all previous iterations. +* **Architecture Mandate:** Any future updates must strictly adhere to the defined pipeline: Vector Fingerprinting (Identity) -> OCR (Validation) -> ML Analysis (Condition/Foil). +* **Preservation Protocol:** Do not summarize, truncate, or remove sections of this manual during review. + +## Part 1: App Store Listing + +### 1. Metadata +* **App Name:** IYmtg: Card Scanner & Insurance +* **Subtitle:** Identify, Grade & Insure Magic +* **Category:** Reference / Utilities +* **Keywords:** magic,gathering,scanner,tcg,card,price,insurance,manager,grade,foil,mtg,free,offline +* **Device Orientation:** Strictly lock to Portrait in Xcode. + +### 2. Description +**Headline:** The Easiest Way to Insure Your Magic Collection. + +**Body:** +Your Magic: The Gathering collection represents years of history and passion. Losing it to theft, fire, or disaster is a nightmare scenario. IYmtg is the first app built specifically to make **insuring your collection** simple, fast, and accurate. + +Forget complex spreadsheets and manual entry. Just point your camera, and IYmtg handles the rest. It identifies the card, grades the condition, detects foiling, and fetches the market price instantly. When you're done, one tap generates a professional **Insurance Schedule PDF** ready for your agent. + +**Why IYmtg?** +* 📄 **Insurance Ready:** Generate a timestamped, itemized PDF Schedule in seconds. +* ⚡ **Effortless Scanning:** Auto-detects Set, Condition, and Foil type (including Etched, Galaxy, and more). +* 🔒 **Private & Secure:** Your data is backed up, but your images stay private in iCloud. +* ✅ **Simple & Clean:** No ads, no subscriptions, just a powerful tool for collectors. + +**Development Transparency:** +This application's code and visual assets were developed with the assistance of Artificial Intelligence. This modern approach allows us to deliver a sophisticated, high-performance tool dedicated to a single goal: helping collectors manage, grade, and insure their valuable history with precision and ease. + +**Community Data Initiative:** +Help us make IYmtg smarter! If you find a card that scans incorrectly, you can correct it in the app. When you do, you'll have the option to securely send that image to our training database. Your contributions directly improve the AI models for the entire community. + +**Features:** +* **Insurance Reports:** Export your entire collection to a PDF ready for your insurance agent. +* **Collection Valuation:** Monitor the total value of your collection with real-time market data. +* **Smart Scanning:** Identify cards, foils, and condition automatically. +* **Cloud Sync:** Keep your collection safe and accessible across your devices. +* **Offline Access:** Scan and manage your cards even without an internet connection. +* **Market Data:** Switch between major pricing sources (TCGPlayer & Cardmarket). +* **Export Options:** Also supports CSV and digital deck formats for other uses. + +## Part 2: Workspace & Assets + +### Step 1: Workspace Setup +1. Create the master folder on your Desktop. +2. Right-click -> Sync with Google Drive (Critical for backups). +3. Organize your sub-folders exactly as shown below: + +```text +IYmtg_Master/ +├── IYmtg_App_iOS/ (The Xcode Project) +├── IYmtg_Builder_Mac/ (The Database Builder) +├── IYmtg_Training/ (ML Image Data) +└── IYmtg_Automation/ (Python/Shell Scripts) +``` + +## 2. Visual Assets + +Place the following assets in `Assets.xcassets` in the Xcode project. + +**Important:** AI tools often generate large files (e.g., 2048x2048). You **must resize and crop** the results to the dimensions listed below. For the AppIcon, exact 1024x1024 dimensions are mandatory. + +| Asset Name | Dimensions | Description | Gemini Generation Prompt | +| :--- | :--- | :--- | :--- | +| **AppIcon** | 1024x1024 | App Icon. | "A high-quality iOS app icon. A stylized neon green cybernetic eye scanning a dark, mystical trading card silhouette. Dark purple and black background. Minimalist, sleek, modern technology meets fantasy magic. No text. Square aspect ratio." | +| **logo_header** | 300x80 | Header Logo. | "A typographic logo for an app named 'IYmtg'. Horizontal layout. Neon green text, futuristic sans-serif font. Dark background. The text should be glowing. High contrast. Aspect ratio 4:1." | +| **scanner_frame** | 600x800 | Viewfinder. | "A HUD viewfinder overlay for a camera app. Glowing white bracket corners. Thin, high-tech lines connecting corners. Center is empty. Sci-fi interface style. Pure white lines on a solid black background. Aspect ratio 3:4." | +| **empty_library** | 800x800 | Empty State. | "Isometric 3D render of a clean, empty wooden desk. A single Magic: The Gathering style card sits in the center. Soft warm lighting. Minimalist design. High resolution. No text. Square aspect ratio." | +| **share_watermark** | 400x100 | Watermark. | "A watermark logo text 'Verified by IYmtg'. White text with a checkmark icon. Clean, bold font. Solid black background. Professional verification seal style. Aspect ratio 4:1." | +| **card_placeholder**| 600x840 | Loading State. | "A generic trading card back design. Grey and silver swirl pattern. Mystical and abstract. No text. Aspect ratio 2.5:3.5." | + +### Automated Resizing +We have provided a Python script to automatically crop and resize your AI-generated images to the exact dimensions required above. + +1. **Setup:** Ensure you have Python installed and run `pip install Pillow`. +2. **Folders:** Run the script once to generate the `Raw_Assets` and `Ready_Assets` folders in `IYmtg_Master`. +3. **Generate Placeholders (Optional):** If you don't have AI images yet, run this script to create dummy files in `Raw_Assets` to test the pipeline. + ```bash + python3 IYmtg_Automation/generate_placeholders.py + ``` +4. **Place Images:** Save your real AI results into `Raw_Assets` (overwrite the placeholders), naming them exactly as listed above (e.g., `AppIcon.png`). +5. **Run:** Execute the resize script: + ```bash + python3 IYmtg_Automation/resize_assets.py + ``` +6. **Result:** Your Xcode-ready images will be in `Ready_Assets`. Drag them into `Assets.xcassets`. + +## 3. Machine Learning Training + +### **General Data Collection Protocol (CRITICAL)** +The app sends **cropped** images (just the card, no background) to the AI. Your training data must match this. + +1. **Capture:** Take photos of the card on a contrasting background. + * **For Foils:** Take 3-5 photos of the *same card* at different tilt angles. The AI needs to see how the light moves across the surface (e.g., flat, tilted left, tilted back). + * **For Damage:** Ensure the lighting specifically highlights the defect (e.g., raking light for dents). +2. **Crop:** Crop the photo so **only the card** is visible (remove the table/background). +3. **Sort:** Place the cropped image into the corresponding folder in `IYmtg_Training`. +4. **Quantity:** Aim for 30-50 images per category for robust results. + +### Step 1: The Master Foil Shopping List (Required for FoilEngine) +Acquire one of each (~$50 total) to train the Foil Classifier. This ensures the app can distinguish complex modern foil types. + +| Foil Type | Recommended Card | Visual Key (For Substitutes) | +| :--- | :--- | :--- | +| **Traditional** | Any Common Foil | Standard rainbow reflection, smooth surface. | +| **Etched** | Harmonize (Strixhaven Archive) | Metallic, grainy texture, matte finish, no rainbow. | +| **Pre-Modern** | Opt (Dominaria Remastered - Retro) | Shooting star in text box, specific retro frame shine. | +| **Textured** | Rivaz of the Claw (Dominaria United) | Raised 3D pattern on surface, fingerprint-like feel. | +| **Gilded** | Riveteers Charm (New Capenna) | Embossed gold frame elements, glossy raised texture. | +| **Galaxy** | Command Performance (Unfinity) | Embedded "stars" or sparkles in the foil pattern. | +| **Surge** | Explore (Warhammer 40k) | Rippling "wave" pattern across the entire card. | +| **Silver Screen** | Otherworldly Gaze (Double Feature) | Grayscale art with silver metallic highlights. | +| **Oil Slick** | Basic Land (Phyrexia: ONE - Compleat) | Raised, slick black-on-black texture, high contrast. | +| **Confetti** | Negate (Wilds of Eldraine - Confetti) | Glittering "confetti" sparkles scattered on art. | +| **Halo** | Uncommon Legend (MOM: Multiverse) | Swirling circular pattern around the frame. | +| **Neon Ink** | Hidetsugu (Neon Yellow) | Bright, fluorescent ink layer on top of foil. | +| **Fracture** | Enduring Vitality (Duskmourn Japan) | Shattered glass pattern, highly reflective. | + +### Step 2: The Stamp Classifier Shopping List +Acquire pairs of cards to train the `StampDetector` (Promo/Date Stamped vs. Regular). This is a **Binary Classifier**, meaning the AI learns by comparing "Yes" vs "No". + +* **Prerelease Promos:** Any card with a Gold Date Stamp (e.g., "29 September 2018"). +* **Promo Pack Cards:** Cards with the Planeswalker Symbol stamp in the bottom right of the art. +* **Purchase List:** Buy 50-100 cheap bulk promos (often <$0.25 each) and their non-promo counterparts. +* **Action:** Place cropped images of promos in `Stamp_Data/Stamped` and regular versions in `Stamp_Data/Clean`. + +### Step 3: The "Damage Simulation Lab" +Techniques to ethically create training data using "Draft Chaff" (worthless cards). + +| Category | Damage Type | Simulation Technique | Capture Tip (Crucial for ML) | +| :--- | :--- | :--- | :--- | +| **Surface** | Light Scratches | Rub foil surface gently with 0000 Steel Wool. | Use flash or moving light source to catch glint. | +| **Surface** | Clouding | Rub white eraser vigorously over foil surface. | Compare side-by-side with a clean card. | +| **Surface** | Dirt | Smudge lightly with potting soil or cocoa powder. | Ensure contrast against card art. | +| **Surface** | Dents | Press a ballpoint pen cap firmly into the surface. | **Raking Light:** Light from side to cast shadows. | +| **Edges** | Whitening | Rub card edges rapidly against denim jeans. | Photograph against a **Black Background**. | +| **Edges** | Chipping | Flake off small bits of black border. | Photograph against a **White Background**. | +| **Edges** | Corner Wear | Rub corners against a rough mousepad. | Macro focus on the corner radius. | +| **Structure** | Creases | Fold a corner until a hard line forms. | Catch the light reflection off the crease ridge. | +| **Structure** | Shuffle Bend | Riffle shuffle aggressively to create an arch. | Profile view (side view) to show curvature. | +| **Structure** | Water Damage | Mist with spray bottle, wait 60s, dry. | Catch the rippled surface texture with side light. | +| **Critical** | Inking | Use a Black Sharpie to "fix" whitened edges. | Use UV/Blacklight if possible, or bright white light. | +| **Critical** | Rips | Tear the edge slightly (approx. 5mm). | High contrast background. | +| **Critical** | Binders Dents | Press a 3-ring binder ring into the card. | Raking light to show the circular crimp. | + +### Step 4: The "Edge Case" Validation List +Acquire these specific cheap cards to verify the logic-based detectors. **Note:** These are for **Manual Verification** (testing the app), not for Create ML training folders. + +| Detector | Target Card Type | Recommended Purchase | +| :--- | :--- | :--- | +| **ListSymbol** | "The List" Reprint | Any common from "The List" (look for planeswalker symbol). | +| **Border** | World Champ Deck | Any 1996-2004 World Champ card (Gold Border). | +| **Border** | Chronicles Reprint | *City of Brass* (Chronicles) vs *City of Brass* (Modern Reprint). | +| **Corner** | Alpha/Beta Sim | *4th Edition* (Standard) vs *Alpha* (Proxy/Counterfeit for testing). | +| **Saturation** | Unl/Revised Sim | *Revised* Basic Land (Washed out) vs *4th Edition* (Saturated). | + +### Step 5: Training Folder Structure +Create the following directory tree inside `IYmtg_Training` to organize your image data for Create ML. + +```text +IYmtg_Training/ +├── Foil_Data/ (Image Classification) +│ ├── NonFoil/ +│ ├── Traditional/ +│ ├── Etched/ +│ ├── PreModern/ +│ ├── Textured/ +│ ├── Galaxy/ +│ ├── Surge/ +│ ├── OilSlick/ +│ ├── StepAndCompleat/ +│ ├── Halo/ +│ ├── Confetti/ +│ ├── NeonInk/ +│ └── Fracture/ +├── Stamp_Data/ (Image Classification) +│ ├── Stamped/ +│ └── Clean/ +└── Condition_Data/ (Object Detection) + ├── Surface/ + │ ├── LightScratches/ + │ ├── Clouding/ + │ ├── Dirt/ + │ └── Dents/ + ├── Edges/ + │ ├── Whitening/ + │ ├── Chipping/ + │ └── CornerWear/ + ├── Structure/ + │ ├── Creases/ + │ ├── ShuffleBend/ + │ └── WaterDamage/ + └── Critical/ + ├── Inking/ + ├── Rips/ + └── BindersDents/ +``` + +### Step 4: Create ML +1. **Foil Classifier:** Train an Image Classification model using `Foil_Data`. Export as `IYmtgFoilClassifier.mlmodel`. +2. **Condition Classifier:** Train an Object Detection model using `Condition_Data`. Export as `IYmtgConditionClassifier.mlmodel`. +3. **Import:** Drag both `.mlmodel` files into the Xcode Project Navigator. + +## 4. Backend & Security + +### Firebase Configuration (Required for Cloud Sync) +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). + +### Over-the-Air (OTA) Model Updates +To update ML models without an App Store release: +1. Train your new model (e.g., `IYmtgFoilClassifier.mlmodel`). +2. Upload the `.mlmodel` file to Firebase Storage in the `models/` folder. +3. The app will automatically detect the newer file, download, compile, and hot-swap it on the next launch. + +### Privacy Manifest +Ensure `PrivacyInfo.xcprivacy` is included in the app target to satisfy Apple's privacy requirements regarding file timestamps and user defaults. + +## 5. Automation + +Scripts are located in `IYmtg_Automation/`. + +### Set Symbol Harvester +Fetches training data for set symbols from Scryfall. + +```bash +pip install requests pillow +python3 IYmtg_Automation/fetch_set_symbols.py +``` + +### Weekly Update Script +Automates the build-and-deploy process for the database builder. + +```bash +chmod +x IYmtg_Automation/weekly_update.sh +./IYmtg_Automation/weekly_update.sh +``` + +## 6. App Configuration + +**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. + +## 7. Development Mode + +To enable saving raw training images during scanning: +1. Add the compilation flag `ENABLE_DEV_MODE` in Xcode Build Settings. +2. Tap the "IYmtg" logo header 5 times in the app to activate. + +## 8. Testing + +The project includes a comprehensive unit test suite located in `IYmtgTests.swift`. + +**How to Run:** +* Press `Cmd+U` in Xcode to execute the test suite. + +**Scope:** +* **Models:** Verifies `SavedCard` initialization and data mapping. +* **Engines:** Tests logic for `ConditionEngine` (grading rules) and `ExportEngine` (CSV/Arena/MTGO formatting). +* **ViewModel:** Validates `ScannerViewModel` state management, including search filtering and portfolio value calculations. + +**Note:** CoreML models are not loaded during unit tests to ensure speed and stability. The tests verify the *logic* surrounding the models (e.g., "If 3 scratches are detected, grade is Played") rather than the ML inference itself. + +## 9. Release Checklist + +Perform these steps before submitting to the App Store. + +1. **Configuration Check:** + * [ ] Open `AppConfig.swift`. + * [ ] Verify `contactEmail` is valid. + * [ ] Verify `tipJarProductIDs` match App Store Connect. + * [ ] Ensure `enableFoilDetection` and other flags are `true`. +2. **Assets:** + * [ ] Ensure `Assets.xcassets` has the AppIcon filled for all sizes. +3. **Testing:** + * [ ] Run Unit Tests (`Cmd+U`) - All must pass. + * [ ] Run on Physical Device - Verify Camera permissions prompt appears. +4. **Build:** + * [ ] Select "Any iOS Device (arm64)". + * [ ] Product -> Archive. + * [ ] Validate App in Organizer. + * [ ] Distribute App -> App Store Connect. + +--- +**Version Authority:** 1.0.0 \ No newline at end of file diff --git a/generate_report.py b/generate_report.py new file mode 100644 index 0000000..fddc2fd --- /dev/null +++ b/generate_report.py @@ -0,0 +1,81 @@ +import os +import datetime + +# Define paths relative to IYmtg_Automation/ +TRAINING_ROOT = "../IYmtg_Training" +OUTPUT_FILE = "../IYmtg_App_iOS/app_description_update.txt" + +if not os.path.exists(TRAINING_ROOT) and os.path.exists("IYmtg_Training"): + TRAINING_ROOT = "IYmtg_Training" + OUTPUT_FILE = "IYmtg_App_iOS/app_description_update.txt" + +def count_images_in_dir(directory): + count = 0 + if os.path.exists(directory): + for root, _, files in os.walk(directory): + count += len([f for f in files if f.lower().endswith(('.jpg', '.jpeg', '.png'))]) + return count + +def get_training_status(count, basic_threshold, advanced_threshold): + if count == 0: + return "❌ Inactive (0 images)" + elif count < basic_threshold: + # Less than 10 per category is generally unstable + return f"⚠️ Experimental ({count} images - Needs {basic_threshold}+)" + elif count < advanced_threshold: + return f"✅ Basic ({count} images - Functional)" + else: + return f"🌟 Advanced ({count} images - High Accuracy)" + +def main(): + print("Generating App Description Update...") + + # 1. Analyze Models + foil_count = count_images_in_dir(os.path.join(TRAINING_ROOT, "Foil_Data")) + stamp_count = count_images_in_dir(os.path.join(TRAINING_ROOT, "Stamp_Data")) + cond_count = count_images_in_dir(os.path.join(TRAINING_ROOT, "Condition_Data")) + + # Set Symbols might be in Automation folder or Training folder + set_sym_count = count_images_in_dir(os.path.join(TRAINING_ROOT, "Set_Symbol_Training")) + if set_sym_count == 0: + set_sym_count = count_images_in_dir("Set_Symbol_Training") + + # 2. Build Report + lines = [] + lines.append(f"IYmtg System Status - {datetime.date.today().strftime('%B %d, %Y')}") + lines.append("==================================================") + lines.append("") + lines.append("🧠 AI MODEL STATUS") + # Foil: 13 Classes. Min 130 (10/class), Adv 650 (50/class) + lines.append(f"• Foil Classification: {get_training_status(foil_count, 130, 650)}") + # Stamp: 2 Classes. Min 20 (10/class), Adv 100 (50/class) + lines.append(f"• Promo Stamp Detection: {get_training_status(stamp_count, 20, 100)}") + # Condition: 13 Classes. Min 130, Adv 650 + lines.append(f"• Condition Grading: {get_training_status(cond_count, 130, 650)}") + # Sets: Hundreds. Min 500, Adv 2000 + lines.append(f"• Set Symbol Recog: {get_training_status(set_sym_count, 500, 2000)}") + lines.append("") + lines.append("👁️ IDENTIFICATION CAPABILITIES") + lines.append("• Standard Cards: ✅ Active (Vector Fingerprinting)") + lines.append("• Alpha vs Beta: ✅ Active (Corner Radius Detection)") + lines.append("• Unlimited vs Revised: ✅ Active (Saturation Analysis)") + lines.append("• The List / Mystery: ✅ Active (Symbol Detection)") + lines.append("• World Champ Decks: ✅ Active (Gold Border Detection)") + lines.append("• Chronicles Reprints: ✅ Active (Border Color Logic)") + lines.append("• Serialized Cards: ✅ Active (OCR Number Pattern)") + lines.append("• Summer Magic (Edgar): ✅ Active (OCR Copyright Date)") + lines.append("") + lines.append("📝 NOTE TO REVIEWER") + lines.append("This build includes updated ML models based on the dataset sizes listed above.") + lines.append("Features marked 'Inactive' will fallback to manual entry or basic heuristics.") + + # 3. Write to File + try: + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + print(f"Success! Description saved to: {os.path.abspath(OUTPUT_FILE)}") + except Exception as e: + print(f"Error writing report: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file