Initial commit: IYmtg Master project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
62
IYmtg_App_iOS/AppConfig.swift
Normal file
62
IYmtg_App_iOS/AppConfig.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
116
IYmtg_App_iOS/CardLogic.swift
Normal file
116
IYmtg_App_iOS/CardLogic.swift
Normal file
@@ -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
|
||||
}
|
||||
418
IYmtg_App_iOS/ContentView.swift
Normal file
418
IYmtg_App_iOS/ContentView.swift
Normal file
@@ -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<Void, Never>? = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1114
IYmtg_App_iOS/Engines.swift
Normal file
1114
IYmtg_App_iOS/Engines.swift
Normal file
File diff suppressed because it is too large
Load Diff
8
IYmtg_App_iOS/Firebase/firestore.rules
Normal file
8
IYmtg_App_iOS/Firebase/firestore.rules
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
IYmtg_App_iOS/Firebase/storage.rules
Normal file
12
IYmtg_App_iOS/Firebase/storage.rules
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
IYmtg_App_iOS/IYmtgApp.swift
Normal file
17
IYmtg_App_iOS/IYmtgApp.swift
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
149
IYmtg_App_iOS/IYmtgTests.swift
Normal file
149
IYmtg_App_iOS/IYmtgTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
25
IYmtg_App_iOS/PrivacyInfo.xcprivacy
Normal file
25
IYmtg_App_iOS/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array><string>C617.1</string></array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array><string>CA92.1</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
776
IYmtg_App_iOS/ScannerViewModel.swift
Normal file
776
IYmtg_App_iOS/ScannerViewModel.swift
Normal file
@@ -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<AnyCancellable>()
|
||||
var currentFrameImage: CGImage?
|
||||
private var recalcTask: Task<Void, Never>?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var processingTask: Task<Void, Never>?
|
||||
private let analyzer = AnalysisActor()
|
||||
public let session = AVCaptureSession()
|
||||
private let sessionQueue = DispatchQueue(label: "com.iymtg.sessionQueue")
|
||||
private let foilEngine = FoilEngine()
|
||||
private var lastFrameTime = Date.distantPast
|
||||
private var lastSaveTime = Date.distantPast
|
||||
|
||||
private let successHaptics = UINotificationFeedbackGenerator()
|
||||
private let detectHaptics = UIImpactFeedbackGenerator(style: .light)
|
||||
private var saveTask: Task<Void, Never>?
|
||||
private let processingLock = OSAllocatedUnfairLock(initialState: false)
|
||||
private var isScanningActive = false
|
||||
|
||||
private var isSessionConfigured = false
|
||||
private var focusResetTask: Task<Void, Never>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||
Task(priority: .background) { let _ = ConditionEngine.model; let _ = FoilEngine.model }
|
||||
NetworkMonitor.shared.$isConnected.receive(on: RunLoop.main).sink { [weak self] status in self?.isConnected = status }.store(in: &cancellables)
|
||||
|
||||
Task { [weak self] in
|
||||
let loaded = await PersistenceActor.shared.load()
|
||||
guard let self = self else { return }
|
||||
self.scannedList = loaded
|
||||
self.isCollectionLoading = false
|
||||
|
||||
// Derive collections and boxes from loaded data
|
||||
let loadedCollections = Set(loaded.map { $0.collectionName })
|
||||
let loadedBoxes = Set(loaded.map { $0.storageLocation })
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.collections = Array(loadedCollections.union([AppConfig.Defaults.masterCollectionName])).sorted()
|
||||
self.boxes = Array(loadedBoxes.union([AppConfig.Defaults.unsortedBoxName])).sorted()
|
||||
}
|
||||
|
||||
Task.detached {
|
||||
let activeImages = Set(loaded.map { $0.imageFileName })
|
||||
ImageManager.cleanupOrphans(activeImages: activeImages)
|
||||
}
|
||||
self.refreshPrices(force: false)
|
||||
}
|
||||
CloudEngine.signInSilently()
|
||||
DevEngine.activateIfCompiled()
|
||||
ModelManager.shared.checkForUpdates()
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let url = Bundle.main.url(forResource: "cards", withExtension: "json") {
|
||||
do {
|
||||
await self.analyzer.loadDatabase(from: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.isDatabaseLoading = false
|
||||
self.statusText = "Ready to Scan"
|
||||
self.checkCameraPermissions()
|
||||
}
|
||||
} catch { await MainActor.run { [weak self] in self?.statusText = "Error: Database Corrupt" } }
|
||||
} else { await MainActor.run { [weak self] in self?.statusText = "Database Missing" } }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
recalcTask?.cancel()
|
||||
refreshTask?.cancel()
|
||||
processingTask?.cancel()
|
||||
saveTask?.cancel()
|
||||
focusResetTask?.cancel()
|
||||
}
|
||||
|
||||
func recalcStats() {
|
||||
let currentColl = self.currentCollection
|
||||
let searchText = self.librarySearchText.lowercased()
|
||||
let sort = self.sortOption
|
||||
let sourceList = self.scannedList
|
||||
|
||||
recalcTask?.cancel()
|
||||
recalcTask = Task.detached { [weak self] in
|
||||
let filtered = sourceList.filter { card in
|
||||
(searchText.isEmpty ||
|
||||
card.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
card.setCode.localizedCaseInsensitiveContains(searchText) ||
|
||||
card.collectorNumber.localizedCaseInsensitiveContains(searchText)) &&
|
||||
(currentColl == AppConfig.Defaults.masterCollectionName || card.collectionName == currentColl)
|
||||
}
|
||||
|
||||
let sorted: [SavedCard]
|
||||
switch sort {
|
||||
case .dateAdded: sorted = filtered.sorted { $0.dateAdded > $1.dateAdded }
|
||||
case .valueHighLow: sorted = filtered.sorted { ($0.currentValuation ?? 0) > ($1.currentValuation ?? 0) }
|
||||
case .nameAZ: sorted = filtered.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
let total = filtered.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||
|
||||
// Dashboard Calculation (Global)
|
||||
let globalTotal = sourceList.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||
let globalPrev = sourceList.reduce(0) { $0 + ($1.previousValuation ?? $1.currentValuation ?? 0) }
|
||||
let dailyChange = globalTotal - globalPrev
|
||||
|
||||
let movers = sourceList.filter { ($0.currentValuation ?? 0) != ($0.previousValuation ?? 0) }
|
||||
.sorted { abs(($0.currentValuation ?? 0) - ($0.previousValuation ?? 0)) > abs(($1.currentValuation ?? 0) - ($1.previousValuation ?? 0)) }
|
||||
.prefix(5)
|
||||
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.filteredList = sorted
|
||||
self.totalValue = total
|
||||
self.portfolioValue = globalTotal
|
||||
self.portfolioDailyChange = dailyChange
|
||||
self.topMovers = Array(movers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPrices(force: Bool = false) {
|
||||
refreshTask?.cancel()
|
||||
let cardsSnapshot = self.scannedList
|
||||
let currencySnapshot = self.selectedCurrency
|
||||
|
||||
refreshTask = Task.detached { [weak self] in
|
||||
let (priceUpdates, metadataUpdates) = await InsuranceEngine.updateTrends(cards: cardsSnapshot, currency: currencySnapshot, force: force)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if priceUpdates.isEmpty && metadataUpdates.isEmpty { return }
|
||||
|
||||
var listCopy = self.scannedList
|
||||
var changedIndices = Set<Int>()
|
||||
var hasChanges = false
|
||||
|
||||
// OPTIMIZATION: Map IDs to indices for O(1) lookup
|
||||
var indexMap = [UUID: Int]()
|
||||
for (index, card) in listCopy.enumerated() { indexMap[card.id] = index }
|
||||
|
||||
// Apply Price Updates
|
||||
for (id, newPrice) in priceUpdates {
|
||||
if let index = indexMap[id] {
|
||||
let oldCurrency = listCopy[index].currencyCode
|
||||
let oldPrice = listCopy[index].currentValuation
|
||||
|
||||
if oldCurrency != currencySnapshot.rawValue {
|
||||
// Currency Switch: Reset history to prevent invalid math (e.g. EUR - USD)
|
||||
listCopy[index].previousValuation = newPrice
|
||||
listCopy[index].currentValuation = newPrice
|
||||
listCopy[index].currencyCode = currencySnapshot.rawValue
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
} else if oldPrice != newPrice {
|
||||
// Price Change: Only shift history if value actually differs
|
||||
listCopy[index].previousValuation = oldPrice
|
||||
listCopy[index].currentValuation = newPrice
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Metadata Updates
|
||||
for (id, meta) in metadataUpdates {
|
||||
if let index = indexMap[id] {
|
||||
// Only update if values differ to avoid unnecessary writes
|
||||
if listCopy[index].rarity != meta.0 || listCopy[index].colorIdentity != meta.1 || (meta.2 && listCopy[index].isSerialized != true) {
|
||||
listCopy[index].rarity = meta.0
|
||||
listCopy[index].colorIdentity = meta.1
|
||||
if meta.2 { listCopy[index].isSerialized = true }
|
||||
changedIndices.insert(index)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
self.scannedList = listCopy
|
||||
self.saveCollectionAsync()
|
||||
|
||||
// Reconstruct changedCards from indices to ensure latest data (price + metadata) is sent
|
||||
let changedCards = changedIndices.map { listCopy[$0] }
|
||||
// COST OPTIMIZATION: Only upload cards that actually changed to save Firestore writes
|
||||
Task { await CloudEngine.batchUpdatePrices(cards: changedCards) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkCameraPermissions() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
self.isPermissionDenied = false
|
||||
if !isSessionConfigured { self.setupCamera() } else { self.startSession() }
|
||||
case .notDetermined:
|
||||
Task {
|
||||
[weak self] in
|
||||
if await AVCaptureDevice.requestAccess(for: .video) {
|
||||
self?.isPermissionDenied = false; self?.setupCamera()
|
||||
} else {
|
||||
self?.isPermissionDenied = true; self?.statusText = "Camera Access Denied"
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.isPermissionDenied = true; self.statusText = "Camera Access Denied"
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
func setupCamera() {
|
||||
if self.isPermissionDenied { return }
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default, options: .mixWithOthers)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: dev) else { return }
|
||||
|
||||
self.session.beginConfiguration()
|
||||
for i in self.session.inputs { self.session.removeInput(i) }
|
||||
for o in self.session.outputs { self.session.removeOutput(o) }
|
||||
|
||||
if self.session.canSetSessionPreset(.hd1920x1080) { self.session.sessionPreset = .hd1920x1080 }
|
||||
if self.session.canAddInput(input) { self.session.addInput(input) }
|
||||
|
||||
let out = AVCaptureVideoDataOutput()
|
||||
out.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video"))
|
||||
if self.session.canAddOutput(out) { self.session.addOutput(out) }
|
||||
|
||||
do {
|
||||
try dev.lockForConfiguration()
|
||||
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
||||
if dev.isSmoothAutoFocusSupported { dev.isSmoothAutoFocusEnabled = true }
|
||||
|
||||
// OPTIMIZATION: Cap at 30 FPS to reduce thermal load and battery usage during scanning
|
||||
dev.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30)
|
||||
|
||||
let zoomFactor = min(dev.maxAvailableVideoZoomFactor, max(1.5, dev.minAvailableVideoZoomFactor))
|
||||
dev.videoZoomFactor = zoomFactor
|
||||
dev.unlockForConfiguration()
|
||||
} catch {}
|
||||
|
||||
self.session.commitConfiguration()
|
||||
Task { @MainActor [weak self] in self?.isSessionConfigured = true }
|
||||
self.session.startRunning()
|
||||
|
||||
if dev.hasTorch {
|
||||
let actualState = dev.torchMode == .on
|
||||
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleTorch() {
|
||||
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch else { return }
|
||||
sessionQueue.async { [weak self] in
|
||||
try? dev.lockForConfiguration()
|
||||
dev.torchMode = dev.torchMode == .on ? .off : .on
|
||||
dev.unlockForConfiguration()
|
||||
let actualState = dev.torchMode == .on
|
||||
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
||||
}
|
||||
}
|
||||
|
||||
func focusCamera(at point: CGPoint) {
|
||||
focusResetTask?.cancel()
|
||||
|
||||
sessionQueue.async {
|
||||
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
||||
do {
|
||||
try dev.lockForConfiguration()
|
||||
if dev.isFocusPointOfInterestSupported { dev.focusPointOfInterest = point; dev.focusMode = .autoFocus }
|
||||
if dev.isExposurePointOfInterestSupported { dev.exposurePointOfInterest = point; dev.exposureMode = .autoExpose }
|
||||
dev.unlockForConfiguration()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
focusResetTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
self?.sessionQueue.async {
|
||||
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
||||
do {
|
||||
try dev.lockForConfiguration()
|
||||
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
||||
dev.unlockForConfiguration()
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
// Task cancelled, do not reset focus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startSession() {
|
||||
self.isScanningActive = true
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !self.session.isRunning { self.session.startRunning() }
|
||||
}
|
||||
}
|
||||
|
||||
func stopSession() {
|
||||
// V1.1.0 FIX: Cancel pending focus reset
|
||||
self.isScanningActive = false
|
||||
focusResetTask?.cancel()
|
||||
processingTask?.cancel()
|
||||
self.forceSave()
|
||||
|
||||
sessionQueue.async { [weak self] in
|
||||
if let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch {
|
||||
try? dev.lockForConfiguration(); dev.torchMode = .off; dev.unlockForConfiguration()
|
||||
}
|
||||
guard let self = self else { return }
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
if !self.isFound { self.cancelScan() }
|
||||
self.isProcessing = false; self.isTorchOn = false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||
guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
guard processingLock.withLock({ if $0 { return false } else { $0 = true; return true } }) else { return }
|
||||
|
||||
// FIX: Retain buffer via CIImage, but defer heavy rendering to background to avoid blocking camera
|
||||
let ciImage = CIImage(cvPixelBuffer: cvBuffer)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
defer { self.processingLock.withLock { $0 = false } }
|
||||
|
||||
let (shouldProcess, orientation, isBusy) = await MainActor.run {
|
||||
let now = Date()
|
||||
// OPTIMIZATION: Throttle analysis pipeline to ~15 FPS. 30 FPS is unnecessary for card scanning and drains battery.
|
||||
if now.timeIntervalSince(self.lastFrameTime) < 0.06 { return (false, .up, false) }
|
||||
self.lastFrameTime = now
|
||||
return (self.isScanningActive && !self.isFound && !self.isSaving && !self.isDatabaseLoading && !self.isCollectionLoading && now.timeIntervalSince(self.lastSaveTime) > 2.0, self.getCurrentOrientations().1, self.isProcessing)
|
||||
}
|
||||
|
||||
guard shouldProcess else { return }
|
||||
guard let cg = SharedEngineResources.context.createCGImage(ciImage, from: ciImage.extent) else { return }
|
||||
|
||||
let foilType = await self.foilEngine.addFrame(cg, orientation: orientation)
|
||||
|
||||
await MainActor.run {
|
||||
if let foilType { self.currentFoilType = foilType }
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Skip rectangle detection if we are already busy processing a crop
|
||||
guard !isBusy else { return }
|
||||
|
||||
// FIX: Run rectangle detection in background to prevent UI stutter
|
||||
let handler = VNImageRequestHandler(cgImage: cg, orientation: .up)
|
||||
let req = VNDetectRectanglesRequest()
|
||||
try? handler.perform([req])
|
||||
if let obs = req.results?.first as? VNRectangleObservation {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self, self.isScanningActive else { return }
|
||||
self.processingTask = Task { [weak self] in await self?.processCrop(obs, from: cg, orientation: orientation) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processCrop(_ obs: VNRectangleObservation, from fullImage: CGImage, orientation: CGImagePropertyOrientation) async {
|
||||
if Task.isCancelled { return }
|
||||
|
||||
let shouldProceed = await MainActor.run {
|
||||
// FIX: Guard against race conditions where another task started processing while this one was queued
|
||||
if self.isFound || self.isProcessing { return false }
|
||||
self.isProcessing = true
|
||||
self.detectHaptics.prepare()
|
||||
return true
|
||||
}
|
||||
guard shouldProceed else { return }
|
||||
|
||||
// FIX: Perform cropping in background to prevent Main Thread UI stutter
|
||||
let croppedImage: CGImage? = await Task.detached {
|
||||
let width = CGFloat(fullImage.width)
|
||||
let height = CGFloat(fullImage.height)
|
||||
|
||||
// FIX: Vision uses Bottom-Left origin, CGImage uses Top-Left. We must flip Y.
|
||||
let bbox = obs.boundingBox
|
||||
let rect = CGRect(x: bbox.origin.x * width,
|
||||
y: (1 - bbox.origin.y - bbox.height) * height,
|
||||
width: bbox.width * width,
|
||||
height: bbox.height * height)
|
||||
return fullImage.cropping(to: rect)
|
||||
}.value
|
||||
|
||||
guard let cropped = croppedImage else {
|
||||
await MainActor.run { self.isProcessing = false }
|
||||
return
|
||||
}
|
||||
|
||||
// Dev Mode: Save Raw Crop
|
||||
if DevEngine.isDevMode {
|
||||
DevEngine.saveRaw(image: UIImage(cgImage: cropped), label: "AutoCrop")
|
||||
}
|
||||
|
||||
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
||||
|
||||
// OPTIMIZATION: Run Identification (Actor) and Grading (Background Task) in parallel
|
||||
async let analysis = self.analyzer.analyze(croppedImage: cropped, orientation: orientation)
|
||||
async let damageCheck = Task.detached(priority: .userInitiated) { ConditionEngine.detectDamage(image: cropped, orientation: orientation) }.value
|
||||
|
||||
let ((finalCard, detectedSerialized), damages) = await (analysis, damageCheck)
|
||||
|
||||
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
||||
|
||||
await MainActor.run {
|
||||
guard !self.isFound && self.isScanningActive else { self.isProcessing = false; return }
|
||||
|
||||
if var card = finalCard {
|
||||
self.isFound = true
|
||||
self.successHaptics.notificationOccurred(.success)
|
||||
AudioServicesPlaySystemSound(1108)
|
||||
|
||||
card.isSerialized = detectedSerialized
|
||||
self.detectedDamages = damages
|
||||
self.detectedCard = card
|
||||
self.currentFrameImage = cropped
|
||||
|
||||
if self.isAutoScanEnabled {
|
||||
self.saveCurrentCard()
|
||||
} else {
|
||||
self.isProcessing = false
|
||||
}
|
||||
} else {
|
||||
self.isProcessing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelScan() {
|
||||
self.isFound = false
|
||||
self.detectedCard = nil
|
||||
self.currentFrameImage = nil
|
||||
self.isProcessing = false
|
||||
self.currentFoilType = AppConfig.Defaults.defaultFoil
|
||||
self.statusText = "Ready to Scan"
|
||||
self.processingTask?.cancel()
|
||||
}
|
||||
|
||||
func saveCurrentCard() {
|
||||
guard let card = detectedCard, let cgImage = currentFrameImage else { return }
|
||||
self.isSaving = true
|
||||
self.lastSaveTime = Date()
|
||||
|
||||
let imageName = "\(UUID().uuidString).jpg"
|
||||
let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: getCurrentOrientations().0)
|
||||
let collection = self.currentCollection
|
||||
let box = self.currentBox
|
||||
|
||||
// 1. Update UI Immediately (Optimistic)
|
||||
var entry = SavedCard(from: card, imageName: imageName, collection: collection, location: box)
|
||||
// FIX: Apply detected Foil and Condition to the saved record
|
||||
entry.foilType = self.currentFoilType
|
||||
entry.condition = ConditionEngine.overallGrade(damages: self.detectedDamages)
|
||||
|
||||
self.scannedList.insert(entry, at: 0)
|
||||
self.saveCollectionAsync()
|
||||
ReviewEngine.logScan()
|
||||
|
||||
if self.isAutoScanEnabled {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.cancelScan()
|
||||
self.isSaving = false
|
||||
}
|
||||
} else {
|
||||
self.cancelScan()
|
||||
self.isSaving = false
|
||||
}
|
||||
|
||||
// FIX: Robust Background Task Pattern to avoid Data Races
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveCard") {
|
||||
// Expiration handler: End task immediately if time runs out
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
// 2. Offload Heavy I/O and Network to Background
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
do {
|
||||
try ImageManager.save(uiImage, name: imageName)
|
||||
} catch {
|
||||
await MainActor.run { self?.statusText = "Warning: Image Save Failed" }
|
||||
}
|
||||
|
||||
await CloudEngine.save(card: entry)
|
||||
guard let self = self else { return }
|
||||
|
||||
if entry.currentValuation == nil {
|
||||
let currency = await MainActor.run { self.selectedCurrency }
|
||||
// FIX: Do not update global cache timestamp for single card scans
|
||||
let (prices, metadata) = await InsuranceEngine.updateTrends(cards: [entry], currency: currency, force: true, updateTimestamp: false)
|
||||
if let newPrice = prices[entry.id] {
|
||||
let updatedCard: SavedCard? = await MainActor.run {
|
||||
guard let self = self else { return nil }
|
||||
if let idx = self.scannedList.firstIndex(where: { $0.id == entry.id }) {
|
||||
self.scannedList[idx].currentValuation = newPrice
|
||||
if let meta = metadata[entry.id] {
|
||||
self.scannedList[idx].rarity = meta.0
|
||||
self.scannedList[idx].colorIdentity = meta.1
|
||||
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||
}
|
||||
self.saveCollectionAsync()
|
||||
return self.scannedList[idx]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let c = updatedCard {
|
||||
await CloudEngine.save(card: c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateCardDetails(_ card: SavedCard) {
|
||||
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
scannedList[idx] = card
|
||||
|
||||
// Learn new Collections/Boxes if manually entered
|
||||
if !collections.contains(card.collectionName) { collections.append(card.collectionName); collections.sort() }
|
||||
if !boxes.contains(card.storageLocation) { boxes.append(card.storageLocation); boxes.sort() }
|
||||
|
||||
self.saveCollectionAsync()
|
||||
|
||||
// FIX: Robust Background Task Pattern for Edits
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "UpdateCard") {
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
// Trigger price update if identity/foil changed
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
await CloudEngine.save(card: card)
|
||||
|
||||
guard let self = self else { return }
|
||||
let currency = await MainActor.run { self.selectedCurrency }
|
||||
// FIX: Do not update global cache timestamp for single card edits
|
||||
let (prices, _) = await InsuranceEngine.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
||||
if let newPrice = prices[card.id] {
|
||||
let updatedCard: SavedCard? = await MainActor.run {
|
||||
guard let self = self else { return nil }
|
||||
if let i = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
self.scannedList[i].currentValuation = newPrice
|
||||
self.saveCollectionAsync()
|
||||
return self.scannedList[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let c = updatedCard {
|
||||
await CloudEngine.save(card: c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveCard(_ card: SavedCard, toCollection: String, toBox: String) {
|
||||
if let idx = scannedList.firstIndex(of: card) {
|
||||
scannedList[idx].collectionName = toCollection
|
||||
scannedList[idx].storageLocation = toBox
|
||||
self.saveCollectionAsync()
|
||||
let updatedCard = scannedList[idx]
|
||||
Task { await CloudEngine.save(card: updatedCard) }
|
||||
}
|
||||
}
|
||||
|
||||
func generateShareImage(for card: SavedCard) async -> UIImage? {
|
||||
let currencySymbol = self.selectedCurrency.symbol
|
||||
return await Task.detached {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1080, height: 1080))
|
||||
return renderer.image { ctx in
|
||||
UIColor.black.setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: 1080, height: 1080))
|
||||
// FIX: Adjusted layout to prevent text/image overlap
|
||||
if let img = ImageManager.load(name: card.imageFileName) { img.draw(in: CGRect(x: 140, y: 180, width: 800, height: 800)) }
|
||||
if let watermark = UIImage(named: "share_watermark") { watermark.draw(in: CGRect(x: 340, y: 990, width: 400, height: 80)) }
|
||||
else { "Verified by IYmtg".draw(at: CGPoint(x: 100, y: 1000), withAttributes: [.font: UIFont.systemFont(ofSize: 40), .foregroundColor: UIColor.gray]) }
|
||||
let price = "\(currencySymbol)\(card.currentValuation ?? 0.0)" as NSString
|
||||
price.draw(at: CGPoint(x: 100, y: 40), withAttributes: [.font: UIFont.systemFont(ofSize: 120, weight: .bold), .foregroundColor: UIColor.green])
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func deleteCard(_ card: SavedCard) {
|
||||
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
let fileName = scannedList[idx].imageFileName
|
||||
Task { await ImageManager.delete(name: fileName) }
|
||||
Task { await CloudEngine.delete(card: card) }
|
||||
scannedList.remove(at: idx)
|
||||
self.saveCollectionAsync()
|
||||
}
|
||||
}
|
||||
|
||||
func uploadTrainingImage(label: String) {
|
||||
guard let cg = currentFrameImage else { return }
|
||||
let orientation = getCurrentOrientations().0
|
||||
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
||||
Task.detached { TrainingUploader.upload(image: img, label: label, force: true) }
|
||||
}
|
||||
|
||||
func uploadCorrection(image: UIImage?, card: SavedCard, original: SavedCard?) {
|
||||
guard let img = image ?? ImageManager.load(name: card.imageFileName) else { return }
|
||||
|
||||
// Determine what changed to label the training data correctly
|
||||
var labels: [String] = []
|
||||
if card.name != original?.name || card.setCode != original?.setCode { labels.append("Identity_\(card.setCode)_\(card.collectorNumber)") }
|
||||
if card.condition != original?.condition { labels.append("Condition_\(card.condition)") }
|
||||
if card.foilType != original?.foilType { labels.append("Foil_\(card.foilType)") }
|
||||
|
||||
if labels.isEmpty { return }
|
||||
|
||||
Task.detached {
|
||||
for label in labels {
|
||||
TrainingUploader.upload(image: img, label: label, force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveManualCard(_ card: SavedCard) {
|
||||
self.scannedList.insert(card, at: 0)
|
||||
self.saveCollectionAsync()
|
||||
self.cancelScan()
|
||||
ReviewEngine.logScan()
|
||||
|
||||
// FIX: Robust Background Task Pattern
|
||||
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveManual") {
|
||||
// Expiration handler: End task immediately if time runs out
|
||||
UIApplication.shared.endBackgroundTask(taskID)
|
||||
taskID = .invalid
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||
await CloudEngine.save(card: card)
|
||||
// Trigger price update for the manually entered card
|
||||
guard let self = self else { return }
|
||||
// FIX: Do not update global cache timestamp for manual entry
|
||||
let (prices, metadata) = await InsuranceEngine.updateTrends(cards: [card], currency: self.selectedCurrency, force: true, updateTimestamp: false)
|
||||
|
||||
if let newPrice = prices[card.id] {
|
||||
let updatedCard: SavedCard? = await MainActor.run { [weak self] in
|
||||
guard let self = self else { return nil }
|
||||
if let idx = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||
self.scannedList[idx].currentValuation = newPrice
|
||||
if let meta = metadata[card.id] {
|
||||
self.scannedList[idx].rarity = meta.0
|
||||
self.scannedList[idx].colorIdentity = meta.1
|
||||
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||
}
|
||||
self.saveCollectionAsync()
|
||||
return self.scannedList[idx]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let c = updatedCard {
|
||||
await CloudEngine.save(card: c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportCollection(format: ExportFormat) async -> URL? {
|
||||
let list = self.scannedList
|
||||
return await Task.detached { ExportEngine.generate(cards: list, format: format) }.value
|
||||
}
|
||||
|
||||
func copyToClipboard(format: ExportFormat) {
|
||||
let text = ExportEngine.generateString(cards: self.scannedList, format: format)
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
|
||||
func forceSave() {
|
||||
saveTask?.cancel()
|
||||
let listSnapshot = self.scannedList
|
||||
Task { await PersistenceActor.shared.save(listSnapshot) }
|
||||
}
|
||||
|
||||
private func saveCollectionAsync() {
|
||||
saveTask?.cancel()
|
||||
let listSnapshot = self.scannedList
|
||||
saveTask = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
await PersistenceActor.shared.save(listSnapshot)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private func getCurrentOrientations() -> (UIImage.Orientation, CGImagePropertyOrientation) {
|
||||
switch UIDevice.current.orientation {
|
||||
case .portrait: return (.right, .right)
|
||||
case .portraitUpsideDown: return (.left, .left)
|
||||
case .landscapeLeft: return (.up, .up)
|
||||
case .landscapeRight: return (.down, .down)
|
||||
default: return (.right, .right)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user