Compare commits
12 Commits
b993ef4020
...
8b57eeb108
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b57eeb108 | |||
| 13753359b3 | |||
| e18a1080de | |||
| 5da5614a10 | |||
| bb4ad9eb7e | |||
| b848a7b169 | |||
| 15abf823be | |||
| d6498d7c14 | |||
| a186101cb8 | |||
| e1c2935093 | |||
| 52afbd56af | |||
| 24dcb44af4 |
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# AI/ML Model Files
|
||||||
|
*.pt
|
||||||
|
*.pth
|
||||||
|
*.onnx
|
||||||
|
*.pb
|
||||||
|
*.h5
|
||||||
|
*.model
|
||||||
|
*.mlmodel
|
||||||
|
|
||||||
|
# AI/ML Data & Cache
|
||||||
|
*.csv
|
||||||
|
*.json
|
||||||
|
*.parquet
|
||||||
|
/data/
|
||||||
|
/datasets/
|
||||||
|
__pycache__/
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# Environment & Tooling Specific
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Mac-specific
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# AI-generated assets
|
||||||
|
Raw_Assets/
|
||||||
|
Ready_Assets/
|
||||||
|
|
||||||
|
# ML Training Data
|
||||||
|
IYmtg_Training/
|
||||||
@@ -20,12 +20,17 @@ enum CurrencyCode: String, CaseIterable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
// 1. CONTACT EMAIL (Required by Scryfall)
|
// 1. CONTACT EMAIL (Required by Scryfall API policy)
|
||||||
static let contactEmail = "support@iymtg.com" // Example: Use your real email
|
// Replace with your real developer email before submitting to the App Store.
|
||||||
|
static let contactEmail = "support@iymtg.com" // TODO: Replace with your real email
|
||||||
|
|
||||||
// 2. IN-APP PURCHASE ID (Use a "Consumable" type in App Store Connect for repeatable tips)
|
// 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
|
static let tipJarProductIDs: [String] = [] // Example: Use your real Product ID
|
||||||
|
|
||||||
|
// 3. VERSIONING
|
||||||
|
static let appVersion = "1.1.0" // Follows Semantic Versioning (Major.Minor.Patch)
|
||||||
|
static let buildNumber = "2" // Increments with each build submitted to App Store Connect
|
||||||
|
|
||||||
// Feature Flags
|
// Feature Flags
|
||||||
static let enableFoilDetection = true
|
static let enableFoilDetection = true
|
||||||
static let enableConditionGrading = true
|
static let enableConditionGrading = true
|
||||||
@@ -45,16 +50,26 @@ struct AppConfig {
|
|||||||
set { UserDefaults.standard.set(newValue, forKey: "TrainingOptIn") }
|
set { UserDefaults.standard.set(newValue, forKey: "TrainingOptIn") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Firebase secondary backup — metadata only (no card images).
|
||||||
|
// Default: false. Users opt-in via the Cloud Backup section in Library settings.
|
||||||
|
static var isFirebaseBackupEnabled: Bool {
|
||||||
|
get { UserDefaults.standard.bool(forKey: "FirebaseBackupEnabled") }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: "FirebaseBackupEnabled") }
|
||||||
|
}
|
||||||
|
|
||||||
static var scryfallUserAgent: String {
|
static var scryfallUserAgent: String {
|
||||||
return "IYmtg/1.0 (\(contactEmail))"
|
return "IYmtg/\(appVersion) (\(contactEmail))"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func validate() {
|
static func validate() {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if contactEmail.contains("yourdomain.com") {
|
let knownPlaceholderDomains = ["yourdomain.com", "example.com", "yourapp.com"]
|
||||||
|
if knownPlaceholderDomains.contains(where: { contactEmail.contains($0) }) || contactEmail.isEmpty {
|
||||||
fatalError("🛑 SETUP ERROR: Change 'contactEmail' in AppConfig.swift to your real email.")
|
fatalError("🛑 SETUP ERROR: Change 'contactEmail' in AppConfig.swift to your real email.")
|
||||||
}
|
}
|
||||||
if let first = tipJarProductIDs.first, (first.contains("yourname") || first == "com.iymtg.app.tip") {
|
if tipJarProductIDs.isEmpty {
|
||||||
|
print("⚠️ CONFIG WARNING: 'tipJarProductIDs' is empty. Tip Jar will not be available.")
|
||||||
|
} else if let first = tipJarProductIDs.first, (first.contains("yourname") || first == "com.iymtg.app.tip") {
|
||||||
print("⚠️ CONFIG WARNING: 'tipJarProductIDs' contains placeholder. IAP will not load.")
|
print("⚠️ CONFIG WARNING: 'tipJarProductIDs' contains placeholder. IAP will not load.")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ import AVFoundation
|
|||||||
import StoreKit
|
import StoreKit
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject var vm = ScannerViewModel()
|
@StateObject private var collectionVM: CollectionViewModel
|
||||||
|
@StateObject private var scannerVM: ScannerViewModel
|
||||||
@StateObject var store = StoreEngine()
|
@StateObject var store = StoreEngine()
|
||||||
@AppStorage("hasLaunchedBefore") var hasLaunchedBefore = false
|
@AppStorage("hasLaunchedBefore") var hasLaunchedBefore = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let colVM = CollectionViewModel()
|
||||||
|
_collectionVM = StateObject(wrappedValue: colVM)
|
||||||
|
_scannerVM = StateObject(wrappedValue: ScannerViewModel(collectionVM: colVM))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
DashboardView(vm: vm).tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") }
|
DashboardView(vm: collectionVM)
|
||||||
ScannerView(vm: vm).tabItem { Label("Scan", systemImage: "viewfinder") }
|
.tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") }
|
||||||
CollectionView(vm: vm, store: store).tabItem { Label("Library", systemImage: "tray.full.fill") }
|
ScannerView(vm: scannerVM)
|
||||||
|
.tabItem { Label("Scan", systemImage: "viewfinder") }
|
||||||
|
CollectionView(vm: collectionVM, scannerVM: scannerVM, store: store)
|
||||||
|
.tabItem { Label("Library", systemImage: "tray.full.fill") }
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark).accentColor(Color(red: 0.6, green: 0.3, blue: 0.9))
|
.preferredColorScheme(.dark).accentColor(Color(red: 0.6, green: 0.3, blue: 0.9))
|
||||||
.task { await store.loadProducts() }
|
.task { await store.loadProducts() }
|
||||||
@@ -25,9 +35,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASHBOARD VIEW
|
// MARK: - DASHBOARD VIEW
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@ObservedObject var vm: ScannerViewModel
|
@ObservedObject var vm: CollectionViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -61,7 +71,7 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SCANNER VIEW
|
// MARK: - SCANNER VIEW
|
||||||
struct ScannerView: View {
|
struct ScannerView: View {
|
||||||
@ObservedObject var vm: ScannerViewModel
|
@ObservedObject var vm: ScannerViewModel
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@@ -101,7 +111,7 @@ struct ScannerView: View {
|
|||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.all).opacity(0.8)
|
.edgesIgnoringSafeArea(.all).opacity(0.8)
|
||||||
if let point = focusPoint { Circle().stroke(Color.yellow, lineWidth: 2).frame(width: 60, height: 60).position(point).transition(.opacity) }
|
if let point = focusPoint { Circle().stroke(Color.yellow, lineWidth: 2).frame(width: 60, height: 60).position(point).transition(.opacity) }
|
||||||
Image("scanner_frame").resizable().scaledToFit().frame(width: geo.size.width * 0.75)
|
Image("scanner_frame").resizable().scaledToFit().frame(width: 300)
|
||||||
.colorMultiply(vm.isFound ? .green : (vm.isProcessing ? .yellow : .white))
|
.colorMultiply(vm.isFound ? .green : (vm.isProcessing ? .yellow : .white))
|
||||||
.opacity(0.8).allowsHitTesting(false).animation(.easeInOut(duration: 0.2), value: vm.isFound)
|
.opacity(0.8).allowsHitTesting(false).animation(.easeInOut(duration: 0.2), value: vm.isFound)
|
||||||
|
|
||||||
@@ -121,9 +131,7 @@ struct ScannerView: View {
|
|||||||
}
|
}
|
||||||
Menu {
|
Menu {
|
||||||
Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") }
|
Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") }
|
||||||
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") {
|
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { isTrainingOptIn.toggle() }
|
||||||
isTrainingOptIn.toggle()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue)
|
Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
@@ -149,11 +157,11 @@ struct ScannerView: View {
|
|||||||
if !vm.isConnected { Image(systemName: "cloud.slash.fill").foregroundColor(.red).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
|
if !vm.isConnected { Image(systemName: "cloud.slash.fill").foregroundColor(.red).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(vm.collections, id: \.self) { name in Button(name) { vm.currentCollection = name } }
|
ForEach(vm.collections, id: \.self) { name in Button(name) { vm.currentCollection = name } }
|
||||||
Button("New Collection...") { vm.collections.append("New Binder"); vm.currentCollection = "New Binder" }
|
Button("New Collection...") { vm.collectionVM.collections.append("New Binder"); vm.currentCollection = "New Binder" }
|
||||||
} label: { HStack { Image(systemName: "folder.fill").font(.caption); Text(vm.currentCollection).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) }
|
} 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 {
|
Menu {
|
||||||
ForEach(vm.boxes, id: \.self) { name in Button(name) { vm.currentBox = name } }
|
ForEach(vm.boxes, id: \.self) { name in Button(name) { vm.currentBox = name } }
|
||||||
Button("New Box/Deck...") { vm.boxes.append("New Box"); vm.currentBox = "New Box" }
|
Button("New Box/Deck...") { vm.collectionVM.boxes.append("New Box"); vm.currentBox = "New Box" }
|
||||||
} label: { HStack { Image(systemName: "archivebox.fill").font(.caption); Text(vm.currentBox).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) }
|
} 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)
|
}.padding(.top, 60).padding(.horizontal)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -161,20 +169,25 @@ struct ScannerView: View {
|
|||||||
}
|
}
|
||||||
.onAppear { vm.startSession() }
|
.onAppear { vm.startSession() }
|
||||||
.onDisappear { vm.stopSession() }
|
.onDisappear { vm.stopSession() }
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active { vm.checkCameraPermissions() }
|
if newPhase == .active { vm.checkCameraPermissions() }
|
||||||
else if newPhase == .background { vm.stopSession() }
|
else if newPhase == .background { vm.stopSession() }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showManualEdit) {
|
.sheet(isPresented: $showManualEdit) {
|
||||||
if let detected = vm.detectedCard, let cg = vm.currentFrameImage {
|
if let detected = vm.detectedCard, let cg = vm.currentFrameImage {
|
||||||
let orientation = UIImage.Orientation.right // Default for simplicity in edit mode
|
let orientation = UIImage.Orientation.right
|
||||||
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
||||||
let tempFileName = "\(UUID().uuidString).jpg"
|
let tempFileName = "\(UUID().uuidString).jpg"
|
||||||
let _ = try? ImageManager.save(img, name: tempFileName)
|
let _ = try? ImageManager.save(img, name: tempFileName)
|
||||||
let tempCard = SavedCard(from: detected, imageName: tempFileName, collection: vm.currentCollection, location: vm.currentBox)
|
let tempCard = SavedCard(from: detected, imageName: tempFileName, collection: vm.currentCollection, location: vm.currentBox)
|
||||||
CardDetailView(card: tempCard, vm: vm, isNewEntry: true)
|
CardDetailView(card: tempCard, vm: vm.collectionVM, scannerVM: vm, isNewEntry: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(vm.databaseAlertTitle, isPresented: $vm.showDatabaseAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(vm.databaseAlertMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,9 +207,10 @@ struct CameraPreview: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// COLLECTION VIEW
|
// MARK: - COLLECTION VIEW
|
||||||
struct CollectionView: View {
|
struct CollectionView: View {
|
||||||
@ObservedObject var vm: ScannerViewModel
|
@ObservedObject var vm: CollectionViewModel
|
||||||
|
var scannerVM: ScannerViewModel // Passed through to CardDetailView for uploadCorrection
|
||||||
@ObservedObject var store: StoreEngine
|
@ObservedObject var store: StoreEngine
|
||||||
@State private var showShare = false
|
@State private var showShare = false
|
||||||
@State private var shareItem: Any?
|
@State private var shareItem: Any?
|
||||||
@@ -220,15 +234,36 @@ struct CollectionView: View {
|
|||||||
List {
|
List {
|
||||||
Section { HStack { Text("Total Value").font(.headline); Spacer(); Text("\(vm.selectedCurrency.symbol)\(vm.totalValue, specifier: "%.2f")").bold().foregroundColor(.green) } }
|
Section { HStack { Text("Total Value").font(.headline); Spacer(); Text("\(vm.selectedCurrency.symbol)\(vm.totalValue, specifier: "%.2f")").bold().foregroundColor(.green) } }
|
||||||
Section(header: Text("Settings")) {
|
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) }
|
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(action: {
|
||||||
Button("Refresh Prices") {
|
|
||||||
let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred()
|
let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred()
|
||||||
vm.refreshPrices(force: true)
|
vm.refreshPrices(force: true)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Refresh Prices")
|
||||||
|
if vm.isRefreshingPrices { ProgressView().tint(.accentColor) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.disabled(vm.isRefreshingPrices)
|
||||||
Toggle("Share Data for Training", isOn: Binding(get: { AppConfig.isTrainingOptIn }, set: { AppConfig.isTrainingOptIn = $0 }))
|
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) } }
|
Button("Contact Support") { if let url = URL(string: "mailto:\(AppConfig.contactEmail)") { UIApplication.shared.open(url) } }
|
||||||
}
|
}
|
||||||
|
Section(header: Text("Cloud Backup")) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Label("Primary: iCloud (automatic, includes images)", systemImage: "icloud.fill")
|
||||||
|
.font(.caption).foregroundColor(.green)
|
||||||
|
Label("Secondary: Firebase (metadata only — no images)", systemImage: "cylinder.split.1x2")
|
||||||
|
.font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
Button(action: { vm.backupAllToFirebase() }) {
|
||||||
|
HStack {
|
||||||
|
Text("Backup Metadata to Firebase Now")
|
||||||
|
if vm.isBackingUpToFirebase { ProgressView().tint(.accentColor) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(vm.isBackingUpToFirebase)
|
||||||
|
}
|
||||||
Section(header: Text("Support the App")) { ForEach(store.products) { p in Button("Tip \(p.displayPrice)") { Task { await store.purchase(p) } } } }
|
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
|
ForEach(vm.filteredList) { card in
|
||||||
HStack {
|
HStack {
|
||||||
@@ -242,17 +277,25 @@ struct CollectionView: View {
|
|||||||
if let val = card.currentValuation, card.currencyCode == vm.selectedCurrency.rawValue {
|
if let val = card.currentValuation, card.currencyCode == vm.selectedCurrency.rawValue {
|
||||||
Text("\(vm.selectedCurrency.symbol)\(val, specifier: "%.2f")").foregroundColor(getPriceColor(card))
|
Text("\(vm.selectedCurrency.symbol)\(val, specifier: "%.2f")").foregroundColor(getPriceColor(card))
|
||||||
} else { Text("Updating...").foregroundColor(.gray).font(.caption) }
|
} else { Text("Updating...").foregroundColor(.gray).font(.caption) }
|
||||||
}
|
} else { Text("Offline").foregroundColor(.red).font(.caption2) }
|
||||||
else { Text("Offline").foregroundColor(.red).font(.caption2) }
|
|
||||||
Text(card.storageLocation).font(.caption2)
|
Text(card.storageLocation).font(.caption2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu { Button("Edit Details / Graded") { editingCard = card }; Button("Move to Binder 1") { vm.moveCard(card, toCollection: "Binder 1", toBox: "Page 1") }; Button("Share") { Task { isSharing = true; shareItem = await vm.generateShareImage(for: card); isSharing = false; if shareItem != nil { showShare = true } } } }
|
.contextMenu {
|
||||||
|
Button("Edit Details / Graded") { editingCard = card }
|
||||||
|
Button("Move to Binder 1") { vm.moveCard(card, toCollection: "Binder 1", toBox: "Page 1") }
|
||||||
|
Button("Share") { Task { isSharing = true; shareItem = await vm.generateShareImage(for: card); isSharing = false; if shareItem != nil { showShare = true } } }
|
||||||
|
}
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { vm.deleteCard(card) } label: { Label("Delete", systemImage: "trash") } }
|
.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")
|
.searchable(text: $vm.librarySearchText).autocorrectionDisabled().textInputAutocapitalization(.never).scrollDismissesKeyboard(.immediately).navigationTitle("Library")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
NavigationLink(destination: TrainingGuideView()) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Picker("Sort By", selection: $vm.sortOption) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.rawValue).tag(option) } }
|
Picker("Sort By", selection: $vm.sortOption) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.rawValue).tag(option) } }
|
||||||
@@ -273,95 +316,20 @@ struct CollectionView: View {
|
|||||||
}
|
}
|
||||||
.overlay { if isSharing { ProgressView().padding().background(.ultraThinMaterial).cornerRadius(10) } }
|
.overlay { if isSharing { ProgressView().padding().background(.ultraThinMaterial).cornerRadius(10) } }
|
||||||
.sheet(isPresented: $showShare) { if let i = shareItem { ShareSheet(items: [i]) } }
|
.sheet(isPresented: $showShare) { if let i = shareItem { ShareSheet(items: [i]) } }
|
||||||
.sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm) }
|
.sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm, scannerVM: scannerVM) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CARD DETAIL VIEW
|
// MARK: - SHARE SHEET
|
||||||
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 {
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
var items: [Any]
|
var items: [Any]
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) }
|
func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) }
|
||||||
func updateUIViewController(_ ui: UIActivityViewController, context: Context) {}
|
func updateUIViewController(_ ui: UIActivityViewController, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - WELCOME VIEW
|
||||||
struct WelcomeView: View {
|
struct WelcomeView: View {
|
||||||
@Binding var hasLaunchedBefore: Bool
|
@Binding var hasLaunchedBefore: Bool
|
||||||
|
|
||||||
@@ -379,7 +347,7 @@ struct WelcomeView: View {
|
|||||||
FeatureRow(icon: "camera.viewfinder", title: "Smart Scanning", text: "Point at any card. We detect Set, Condition, and Foil automatically.")
|
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: "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: "tray.full.fill", title: "Organize", text: "Select your Binder and Box from the top menu to sort as you scan.")
|
||||||
FeatureRow(icon: "lock.shield.fill", title: "Private & Secure", text: "Your collection data is backed up, but your photos stay private in iCloud.")
|
FeatureRow(icon: "lock.shield.fill", title: "Private & Secure", text: "Your collection data is backed up securely to the cloud. Your card photos stay on your device.")
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Vision
|
|
||||||
import CoreGraphics
|
|
||||||
import ImageIO
|
|
||||||
|
|
||||||
struct CardFingerprint: Codable, Identifiable, Sendable {
|
struct CardFingerprint: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
@@ -72,40 +69,36 @@ struct SavedCard: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.colorIdentity = scan.colorIdentity
|
self.colorIdentity = scan.colorIdentity
|
||||||
self.isSerialized = scan.isSerialized
|
self.isSerialized = scan.isSerialized
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class FeatureMatcher {
|
/// Full memberwise init used by SavedCardModel (SwiftData) ↔ SavedCard conversion.
|
||||||
static let revision = VNGenerateImageFeaturePrintRequest.Revision.revision1
|
init(id: UUID, scryfallID: String, name: String, setCode: String, collectorNumber: String,
|
||||||
|
imageFileName: String, condition: String, foilType: String, currentValuation: Double?,
|
||||||
static func generateFingerprint(from image: CGImage, orientation: CGImagePropertyOrientation = .up) async throws -> VNFeaturePrintObservation {
|
previousValuation: Double?, dateAdded: Date, classification: String, collectionName: String,
|
||||||
let req = VNGenerateImageFeaturePrintRequest()
|
storageLocation: String, rarity: String?, colorIdentity: [String]?,
|
||||||
req.revision = revision
|
gradingService: String?, grade: String?, certNumber: String?,
|
||||||
req.imageCropAndScaleOption = .scaleFill
|
isCustomValuation: Bool, isSerialized: Bool?, currencyCode: String?) {
|
||||||
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
|
self.id = id
|
||||||
try handler.perform([req])
|
self.scryfallID = scryfallID
|
||||||
|
self.name = name
|
||||||
guard let result = req.results?.first as? VNFeaturePrintObservation else {
|
self.setCode = setCode
|
||||||
throw NSError(domain: "FeatureMatcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "No features detected"])
|
self.collectorNumber = collectorNumber
|
||||||
}
|
self.imageFileName = imageFileName
|
||||||
return result
|
self.condition = condition
|
||||||
}
|
self.foilType = foilType
|
||||||
|
self.currentValuation = currentValuation
|
||||||
static func identify(scan: VNFeaturePrintObservation, database: [CardMetadata], cache: [UUID: VNFeaturePrintObservation]) -> MatchResult {
|
self.previousValuation = previousValuation
|
||||||
var candidates: [(CardMetadata, Float)] = []
|
self.dateAdded = dateAdded
|
||||||
for card in database {
|
self.classification = classification
|
||||||
guard let obs = cache[card.id] else { continue }
|
self.collectionName = collectionName
|
||||||
var dist: Float = 0
|
self.storageLocation = storageLocation
|
||||||
if (try? scan.computeDistance(&dist, to: obs)) != nil && dist < 18.0 {
|
self.rarity = rarity
|
||||||
candidates.append((card, dist))
|
self.colorIdentity = colorIdentity
|
||||||
}
|
self.gradingService = gradingService
|
||||||
}
|
self.grade = grade
|
||||||
let sorted = candidates.sorted { $0.1 < $1.1 }
|
self.certNumber = certNumber
|
||||||
guard let best = sorted.first else { return .unknown }
|
self.isCustomValuation = isCustomValuation
|
||||||
|
self.isSerialized = isSerialized
|
||||||
if best.1 < 6.0 { return .exact(best.0) }
|
self.currencyCode = currencyCode
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
156
IYmtg_App_iOS/Data/Models/SavedCardModel.swift
Normal file
156
IYmtg_App_iOS/Data/Models/SavedCardModel.swift
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SAVED CARD MODEL
|
||||||
|
// SwiftData @Model class for structured persistence with automatic CloudKit sync.
|
||||||
|
// Mirrors the SavedCard value-type struct so the rest of the app stays unchanged.
|
||||||
|
// Requires iOS 17+. Set minimum deployment target to iOS 17 in Xcode.
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class SavedCardModel {
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var scryfallID: String
|
||||||
|
var name: String
|
||||||
|
var setCode: String
|
||||||
|
var collectorNumber: String
|
||||||
|
var imageFileName: String
|
||||||
|
var condition: String
|
||||||
|
var foilType: String
|
||||||
|
var currentValuation: Double?
|
||||||
|
var previousValuation: Double?
|
||||||
|
var dateAdded: Date
|
||||||
|
var classification: String
|
||||||
|
var collectionName: String
|
||||||
|
var storageLocation: String
|
||||||
|
var rarity: String?
|
||||||
|
var colorIdentity: [String]?
|
||||||
|
var gradingService: String?
|
||||||
|
var grade: String?
|
||||||
|
var certNumber: String?
|
||||||
|
var isCustomValuation: Bool
|
||||||
|
var isSerialized: Bool?
|
||||||
|
var currencyCode: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
scryfallID: String,
|
||||||
|
name: String,
|
||||||
|
setCode: String,
|
||||||
|
collectorNumber: String,
|
||||||
|
imageFileName: String,
|
||||||
|
condition: String,
|
||||||
|
foilType: String,
|
||||||
|
currentValuation: Double? = nil,
|
||||||
|
previousValuation: Double? = nil,
|
||||||
|
dateAdded: Date = Date(),
|
||||||
|
classification: String = "Unknown",
|
||||||
|
collectionName: String,
|
||||||
|
storageLocation: String,
|
||||||
|
rarity: String? = nil,
|
||||||
|
colorIdentity: [String]? = nil,
|
||||||
|
gradingService: String? = nil,
|
||||||
|
grade: String? = nil,
|
||||||
|
certNumber: String? = nil,
|
||||||
|
isCustomValuation: Bool = false,
|
||||||
|
isSerialized: Bool? = false,
|
||||||
|
currencyCode: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.scryfallID = scryfallID
|
||||||
|
self.name = name
|
||||||
|
self.setCode = setCode
|
||||||
|
self.collectorNumber = collectorNumber
|
||||||
|
self.imageFileName = imageFileName
|
||||||
|
self.condition = condition
|
||||||
|
self.foilType = foilType
|
||||||
|
self.currentValuation = currentValuation
|
||||||
|
self.previousValuation = previousValuation
|
||||||
|
self.dateAdded = dateAdded
|
||||||
|
self.classification = classification
|
||||||
|
self.collectionName = collectionName
|
||||||
|
self.storageLocation = storageLocation
|
||||||
|
self.rarity = rarity
|
||||||
|
self.colorIdentity = colorIdentity
|
||||||
|
self.gradingService = gradingService
|
||||||
|
self.grade = grade
|
||||||
|
self.certNumber = certNumber
|
||||||
|
self.isCustomValuation = isCustomValuation
|
||||||
|
self.isSerialized = isSerialized
|
||||||
|
self.currencyCode = currencyCode
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(from card: SavedCard) {
|
||||||
|
self.init(
|
||||||
|
id: card.id,
|
||||||
|
scryfallID: card.scryfallID,
|
||||||
|
name: card.name,
|
||||||
|
setCode: card.setCode,
|
||||||
|
collectorNumber: card.collectorNumber,
|
||||||
|
imageFileName: card.imageFileName,
|
||||||
|
condition: card.condition,
|
||||||
|
foilType: card.foilType,
|
||||||
|
currentValuation: card.currentValuation,
|
||||||
|
previousValuation: card.previousValuation,
|
||||||
|
dateAdded: card.dateAdded,
|
||||||
|
classification: card.classification,
|
||||||
|
collectionName: card.collectionName,
|
||||||
|
storageLocation: card.storageLocation,
|
||||||
|
rarity: card.rarity,
|
||||||
|
colorIdentity: card.colorIdentity,
|
||||||
|
gradingService: card.gradingService,
|
||||||
|
grade: card.grade,
|
||||||
|
certNumber: card.certNumber,
|
||||||
|
isCustomValuation: card.isCustomValuation,
|
||||||
|
isSerialized: card.isSerialized,
|
||||||
|
currencyCode: card.currencyCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from card: SavedCard) {
|
||||||
|
name = card.name
|
||||||
|
setCode = card.setCode
|
||||||
|
collectorNumber = card.collectorNumber
|
||||||
|
condition = card.condition
|
||||||
|
foilType = card.foilType
|
||||||
|
currentValuation = card.currentValuation
|
||||||
|
previousValuation = card.previousValuation
|
||||||
|
classification = card.classification
|
||||||
|
collectionName = card.collectionName
|
||||||
|
storageLocation = card.storageLocation
|
||||||
|
rarity = card.rarity
|
||||||
|
colorIdentity = card.colorIdentity
|
||||||
|
gradingService = card.gradingService
|
||||||
|
grade = card.grade
|
||||||
|
certNumber = card.certNumber
|
||||||
|
isCustomValuation = card.isCustomValuation
|
||||||
|
isSerialized = card.isSerialized
|
||||||
|
currencyCode = card.currencyCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSavedCard() -> SavedCard {
|
||||||
|
SavedCard(
|
||||||
|
id: id,
|
||||||
|
scryfallID: scryfallID,
|
||||||
|
name: name,
|
||||||
|
setCode: setCode,
|
||||||
|
collectorNumber: collectorNumber,
|
||||||
|
imageFileName: imageFileName,
|
||||||
|
condition: condition,
|
||||||
|
foilType: foilType,
|
||||||
|
currentValuation: currentValuation,
|
||||||
|
previousValuation: previousValuation,
|
||||||
|
dateAdded: dateAdded,
|
||||||
|
classification: classification,
|
||||||
|
collectionName: collectionName,
|
||||||
|
storageLocation: storageLocation,
|
||||||
|
rarity: rarity,
|
||||||
|
colorIdentity: colorIdentity,
|
||||||
|
gradingService: gradingService,
|
||||||
|
grade: grade,
|
||||||
|
certNumber: certNumber,
|
||||||
|
isCustomValuation: isCustomValuation,
|
||||||
|
isSerialized: isSerialized,
|
||||||
|
currencyCode: currencyCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
IYmtg_App_iOS/Data/Network/NetworkMonitor.swift
Normal file
15
IYmtg_App_iOS/Data/Network/NetworkMonitor.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import Network
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - NETWORK MONITOR
|
||||||
|
class NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
@Published var isConnected = true
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor.pathUpdateHandler = { path in DispatchQueue.main.async { self.isConnected = path.status == .satisfied } }
|
||||||
|
monitor.start(queue: DispatchQueue.global(qos: .background))
|
||||||
|
}
|
||||||
|
}
|
||||||
121
IYmtg_App_iOS/Data/Network/ScryfallAPI.swift
Normal file
121
IYmtg_App_iOS/Data/Network/ScryfallAPI.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SCRYFALL THROTTLER
|
||||||
|
actor ScryfallThrottler {
|
||||||
|
static let shared = ScryfallThrottler()
|
||||||
|
private var nextAllowedTime = Date.distantPast
|
||||||
|
|
||||||
|
func wait() async {
|
||||||
|
let now = Date()
|
||||||
|
let targetTime = max(now, nextAllowedTime)
|
||||||
|
nextAllowedTime = targetTime.addingTimeInterval(0.1)
|
||||||
|
|
||||||
|
let waitTime = targetTime.timeIntervalSince(now)
|
||||||
|
if waitTime > 0 {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SCRYFALL API (formerly InsuranceEngine)
|
||||||
|
class ScryfallAPI {
|
||||||
|
struct PricingIdentifier: Codable { let set: String; let collector_number: String }
|
||||||
|
struct ScryfallData {
|
||||||
|
let price: Double?
|
||||||
|
let typeLine: String
|
||||||
|
let rarity: String?
|
||||||
|
let colors: [String]?
|
||||||
|
let isSerialized: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getPriceKey(foilType: String, currency: CurrencyCode) -> String {
|
||||||
|
if foilType.caseInsensitiveCompare("Etched") == .orderedSame { return "\(currency.scryfallKey)_etched" }
|
||||||
|
let isFoil = foilType != "None" && foilType != AppConfig.Defaults.defaultFoil
|
||||||
|
let base = currency.scryfallKey
|
||||||
|
return isFoil ? "\(base)_foil" : base
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateTrends(cards: [SavedCard], currency: CurrencyCode, force: Bool = false, updateTimestamp: Bool = true) async -> ([UUID: Double], [UUID: (String?, [String]?, Bool)]) {
|
||||||
|
if !NetworkMonitor.shared.isConnected { return ([:], [:]) }
|
||||||
|
let lastUpdate = UserDefaults.standard.object(forKey: "LastPriceUpdate") as? Date ?? Date.distantPast
|
||||||
|
let isCacheExpired = Date().timeIntervalSince(lastUpdate) >= 86400
|
||||||
|
|
||||||
|
// Optimization: If cache is fresh, only fetch cards that are missing a price (Smart Partial Refresh)
|
||||||
|
let cardsToFetch = (force || isCacheExpired) ? cards : cards.filter { $0.currentValuation == nil }
|
||||||
|
if cardsToFetch.isEmpty { return ([:], [:]) }
|
||||||
|
|
||||||
|
var updates: [UUID: Double] = [:]
|
||||||
|
var metadataUpdates: [UUID: (String?, [String]?, Bool)] = [:]
|
||||||
|
let marketCards = cardsToFetch.filter { !$0.isCustomValuation }
|
||||||
|
let chunks = stride(from: 0, to: marketCards.count, by: 75).map { Array(marketCards[$0..<min($0 + 75, marketCards.count)]) }
|
||||||
|
|
||||||
|
for chunk in chunks {
|
||||||
|
await ScryfallThrottler.shared.wait()
|
||||||
|
let identifiers = chunk.map { PricingIdentifier(set: $0.setCode, collector_number: $0.collectorNumber) }
|
||||||
|
guard let body = try? JSONEncoder().encode(["identifiers": identifiers]) else { continue }
|
||||||
|
|
||||||
|
var request = URLRequest(url: URL(string: "https://api.scryfall.com/cards/collection")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = body
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.addValue(AppConfig.scryfallUserAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||||
|
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let dataArray = json["data"] as? [[String: Any]] {
|
||||||
|
|
||||||
|
for cardData in dataArray {
|
||||||
|
if let set = cardData["set"] as? String, let num = cardData["collector_number"] as? String,
|
||||||
|
let prices = cardData["prices"] as? [String: Any] {
|
||||||
|
|
||||||
|
let matchingCards = chunk.filter { $0.setCode.caseInsensitiveCompare(set) == .orderedSame && $0.collectorNumber == num }
|
||||||
|
for card in matchingCards {
|
||||||
|
let priceKey = getPriceKey(foilType: card.foilType, currency: currency)
|
||||||
|
|
||||||
|
// Capture Metadata (Backfill)
|
||||||
|
let rarity = cardData["rarity"] as? String
|
||||||
|
let colors = cardData["color_identity"] as? [String]
|
||||||
|
let promoTypes = cardData["promo_types"] as? [String]
|
||||||
|
let isSer = promoTypes?.contains("serialized") ?? false
|
||||||
|
metadataUpdates[card.id] = (rarity, colors, isSer)
|
||||||
|
|
||||||
|
if let newPriceStr = prices[priceKey] as? String,
|
||||||
|
let newPrice = Double(newPriceStr),
|
||||||
|
!newPrice.isNaN, !newPrice.isInfinite {
|
||||||
|
updates[card.id] = newPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (force || isCacheExpired) && updateTimestamp { UserDefaults.standard.set(Date(), forKey: "LastPriceUpdate") }
|
||||||
|
return (updates, metadataUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetchPrice(setCode: String, number: String, foilType: String, currency: CurrencyCode) async -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
IYmtg_App_iOS/Data/Persistence/ImageManager.swift
Normal file
137
IYmtg_App_iOS/Data/Persistence/ImageManager.swift
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import UIKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - IMAGE MANAGER
|
||||||
|
// All card images and user content are stored in a dedicated UserContent/ subfolder
|
||||||
|
// inside either iCloud Drive/Documents/ or the local Documents directory.
|
||||||
|
// This keeps binary files separate from the SwiftData store and aligns with the
|
||||||
|
// blueprint's requirement for a named UserContent container in iCloud Drive.
|
||||||
|
|
||||||
|
class ImageManager {
|
||||||
|
|
||||||
|
// MARK: - Directory Helpers
|
||||||
|
|
||||||
|
/// Active working directory: iCloud Documents/UserContent/ or local Documents/UserContent/.
|
||||||
|
static var dir: URL {
|
||||||
|
let base: URL
|
||||||
|
if FileManager.default.ubiquityIdentityToken != nil,
|
||||||
|
let cloud = FileManager.default.url(forUbiquityContainerIdentifier: nil) {
|
||||||
|
base = cloud.appendingPathComponent("Documents")
|
||||||
|
} else {
|
||||||
|
base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
let userContent = base.appendingPathComponent("UserContent")
|
||||||
|
if !FileManager.default.fileExists(atPath: userContent.path) {
|
||||||
|
try? FileManager.default.createDirectory(at: userContent, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return userContent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pre-UserContent base directory. Used only for one-time migration; not for new I/O.
|
||||||
|
private static var legacyDir: URL {
|
||||||
|
if FileManager.default.ubiquityIdentityToken != nil,
|
||||||
|
let cloud = FileManager.default.url(forUbiquityContainerIdentifier: nil) {
|
||||||
|
return cloud.appendingPathComponent("Documents")
|
||||||
|
}
|
||||||
|
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save / Load / Delete
|
||||||
|
|
||||||
|
static func save(_ img: UIImage, name: String) throws {
|
||||||
|
let url = dir.appendingPathComponent(name)
|
||||||
|
|
||||||
|
let maxSize: CGFloat = 1024
|
||||||
|
var actualImg = img
|
||||||
|
if img.size.width > maxSize || img.size.height > maxSize {
|
||||||
|
let scale = maxSize / max(img.size.width, img.size.height)
|
||||||
|
let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale)
|
||||||
|
let format = UIGraphicsImageRendererFormat()
|
||||||
|
format.scale = 1
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize, format: format)
|
||||||
|
actualImg = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = actualImg.jpegData(compressionQuality: 0.7) else { return }
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(name: String) -> UIImage? {
|
||||||
|
let p = dir.appendingPathComponent(name)
|
||||||
|
if FileManager.default.fileExists(atPath: p.path) { return UIImage(contentsOfFile: p.path) }
|
||||||
|
try? FileManager.default.startDownloadingUbiquitousItem(at: p)
|
||||||
|
return UIImage(contentsOfFile: p.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(name: String) async {
|
||||||
|
let url = dir.appendingPathComponent(name)
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Migration
|
||||||
|
|
||||||
|
/// Moves JPG images from the legacy flat directory into the UserContent/ subfolder.
|
||||||
|
/// Called once during the SwiftData migration in BackgroundPersistenceActor.
|
||||||
|
static func migrateImagesToUserContent() {
|
||||||
|
let oldDir = legacyDir
|
||||||
|
let newDir = dir
|
||||||
|
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: oldDir, includingPropertiesForKeys: nil
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
var moved = 0
|
||||||
|
for file in files where file.pathExtension == "jpg" {
|
||||||
|
let dest = newDir.appendingPathComponent(file.lastPathComponent)
|
||||||
|
if !FileManager.default.fileExists(atPath: dest.path) {
|
||||||
|
try? FileManager.default.moveItem(at: file, to: dest)
|
||||||
|
moved += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moved > 0 { print("MIGRATION: Moved \(moved) images → UserContent/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy: moves images from local Documents to iCloud Documents/UserContent/.
|
||||||
|
/// Retained for compatibility but superseded by migrateImagesToUserContent().
|
||||||
|
static func migrateToCloud() {
|
||||||
|
guard let cloudBase = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
|
||||||
|
.appendingPathComponent("Documents") else { return }
|
||||||
|
let cloudURL = cloudBase.appendingPathComponent("UserContent")
|
||||||
|
let localURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
|
if !FileManager.default.fileExists(atPath: cloudURL.path) {
|
||||||
|
try? FileManager.default.createDirectory(at: cloudURL, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: localURL, includingPropertiesForKeys: nil
|
||||||
|
) else { return }
|
||||||
|
for file in files where file.pathExtension == "jpg" {
|
||||||
|
let dest = cloudURL.appendingPathComponent(file.lastPathComponent)
|
||||||
|
if !FileManager.default.fileExists(atPath: dest.path) {
|
||||||
|
try? FileManager.default.moveItem(at: file, to: dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
static func cleanupOrphans(activeImages: Set<String>) {
|
||||||
|
Task.detached {
|
||||||
|
guard !activeImages.isEmpty else { return }
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
guard let files = try? fileManager.contentsOfDirectory(
|
||||||
|
at: dir, includingPropertiesForKeys: [.contentModificationDateKey]
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
for fileURL in files where fileURL.pathExtension == "jpg" {
|
||||||
|
if !activeImages.contains(fileURL.lastPathComponent),
|
||||||
|
let attrs = try? fileManager.attributesOfItem(atPath: fileURL.path),
|
||||||
|
let date = attrs[.modificationDate] as? Date,
|
||||||
|
Date().timeIntervalSince(date) > 3600 {
|
||||||
|
try? fileManager.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
IYmtg_App_iOS/Data/Persistence/PersistenceController.swift
Normal file
146
IYmtg_App_iOS/Data/Persistence/PersistenceController.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - BACKGROUND PERSISTENCE ACTOR
|
||||||
|
// Performs all SwiftData I/O on a dedicated background context.
|
||||||
|
// CloudKit synchronization is handled automatically by the ModelContainer configuration.
|
||||||
|
// @ModelActor provides a ModelContext bound to this actor's executor (background thread).
|
||||||
|
|
||||||
|
@ModelActor
|
||||||
|
actor BackgroundPersistenceActor {
|
||||||
|
|
||||||
|
// MARK: - Load
|
||||||
|
|
||||||
|
func load() -> [SavedCard] {
|
||||||
|
let descriptor = FetchDescriptor<SavedCardModel>(
|
||||||
|
sortBy: [SortDescriptor(\.dateAdded, order: .reverse)]
|
||||||
|
)
|
||||||
|
let models = (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
return models.map { $0.toSavedCard() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save (diff-based to minimise CloudKit writes)
|
||||||
|
|
||||||
|
func save(_ cards: [SavedCard]) {
|
||||||
|
do {
|
||||||
|
let existing = try modelContext.fetch(FetchDescriptor<SavedCardModel>())
|
||||||
|
let existingMap = Dictionary(uniqueKeysWithValues: existing.map { ($0.id, $0) })
|
||||||
|
let incomingIDs = Set(cards.map { $0.id })
|
||||||
|
|
||||||
|
// Delete cards no longer in collection
|
||||||
|
for model in existing where !incomingIDs.contains(model.id) {
|
||||||
|
modelContext.delete(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing or insert new
|
||||||
|
for card in cards {
|
||||||
|
if let model = existingMap[card.id] {
|
||||||
|
model.update(from: card)
|
||||||
|
} else {
|
||||||
|
modelContext.insert(SavedCardModel(from: card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("BackgroundPersistenceActor save error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - One-time JSON Migration
|
||||||
|
|
||||||
|
/// Imports cards from the legacy user_collection.json file into SwiftData.
|
||||||
|
/// Runs once on first launch after the SwiftData upgrade; subsequent calls are no-ops.
|
||||||
|
/// Also triggers image migration from the flat Documents/ layout to Documents/UserContent/.
|
||||||
|
func migrateFromJSONIfNeeded() {
|
||||||
|
let migrationKey = "swiftdata_migration_complete_v1"
|
||||||
|
guard !UserDefaults.standard.bool(forKey: migrationKey) else { return }
|
||||||
|
|
||||||
|
let iCloudDocuments = FileManager.default
|
||||||
|
.url(forUbiquityContainerIdentifier: nil)?
|
||||||
|
.appendingPathComponent("Documents")
|
||||||
|
let localDocuments = FileManager.default
|
||||||
|
.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
|
let candidates: [URL] = [
|
||||||
|
iCloudDocuments?.appendingPathComponent("user_collection.json"),
|
||||||
|
localDocuments.appendingPathComponent("user_collection.json")
|
||||||
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
for jsonURL in candidates {
|
||||||
|
guard FileManager.default.fileExists(atPath: jsonURL.path),
|
||||||
|
let data = try? Data(contentsOf: jsonURL),
|
||||||
|
let legacyCards = try? JSONDecoder().decode([SavedCard].self, from: data),
|
||||||
|
!legacyCards.isEmpty else { continue }
|
||||||
|
|
||||||
|
let existingIDs: Set<UUID>
|
||||||
|
do {
|
||||||
|
let existing = try modelContext.fetch(FetchDescriptor<SavedCardModel>())
|
||||||
|
existingIDs = Set(existing.map { $0.id })
|
||||||
|
} catch { existingIDs = [] }
|
||||||
|
|
||||||
|
var imported = 0
|
||||||
|
for card in legacyCards where !existingIDs.contains(card.id) {
|
||||||
|
modelContext.insert(SavedCardModel(from: card))
|
||||||
|
imported += 1
|
||||||
|
}
|
||||||
|
try? modelContext.save()
|
||||||
|
|
||||||
|
// Rename old JSON — keeps it as a fallback without re-triggering import
|
||||||
|
let migratedURL = jsonURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("user_collection.migrated.json")
|
||||||
|
try? FileManager.default.moveItem(at: jsonURL, to: migratedURL)
|
||||||
|
|
||||||
|
print("MIGRATION: Imported \(imported) cards from JSON into SwiftData.")
|
||||||
|
ImageManager.migrateImagesToUserContent()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDefaults.standard.set(true, forKey: migrationKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PERSISTENCE CONTROLLER
|
||||||
|
// Owns and provides access to the SwiftData ModelContainer.
|
||||||
|
// The container is configured for automatic iCloud CloudKit synchronization.
|
||||||
|
//
|
||||||
|
// XCODE SETUP REQUIRED (one-time):
|
||||||
|
// 1. Signing & Capabilities → iCloud → enable CloudKit
|
||||||
|
// 2. Add a CloudKit container: "iCloud.<your-bundle-id>"
|
||||||
|
// 3. Signing & Capabilities → Background Modes → enable "Remote notifications"
|
||||||
|
// Without this setup the app runs in local-only mode (no cross-device sync).
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PersistenceController {
|
||||||
|
static let shared = PersistenceController()
|
||||||
|
|
||||||
|
let container: ModelContainer
|
||||||
|
let backgroundActor: BackgroundPersistenceActor
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let schema = Schema([SavedCardModel.self])
|
||||||
|
let resolvedContainer: ModelContainer
|
||||||
|
|
||||||
|
do {
|
||||||
|
// .automatic derives the CloudKit container from the app bundle ID.
|
||||||
|
// Degrades gracefully to local storage if iCloud is unavailable.
|
||||||
|
let config = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
isStoredInMemoryOnly: false,
|
||||||
|
cloudKitDatabase: .automatic
|
||||||
|
)
|
||||||
|
resolvedContainer = try ModelContainer(for: schema, configurations: [config])
|
||||||
|
} catch {
|
||||||
|
print("⚠️ PersistenceController: CloudKit init failed (\(error)). Using local storage.")
|
||||||
|
do {
|
||||||
|
let fallback = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
|
resolvedContainer = try ModelContainer(for: schema, configurations: [fallback])
|
||||||
|
} catch let fatal {
|
||||||
|
fatalError("PersistenceController: Cannot create ModelContainer. Error: \(fatal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.container = resolvedContainer
|
||||||
|
self.backgroundActor = BackgroundPersistenceActor(modelContainer: resolvedContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
84
IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift
Normal file
84
IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - CARD DETAIL VIEW
|
||||||
|
struct CardDetailView: View {
|
||||||
|
@State var card: SavedCard
|
||||||
|
@ObservedObject var vm: CollectionViewModel
|
||||||
|
var scannerVM: 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) { scannerVM.uploadCorrection(image: displayImage, card: card, original: originalCard); finishSave() }
|
||||||
|
Button("No Thanks", role: .cancel) { finishSave() }
|
||||||
|
} message: { Text("You changed the card details. Would you like to send the image to help train the AI?") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveChanges() {
|
||||||
|
let hasChanges = card.name != originalCard?.name || card.setCode != originalCard?.setCode || card.condition != originalCard?.condition || card.foilType != originalCard?.foilType
|
||||||
|
if hasChanges && !AppConfig.isTrainingOptIn && !isNewEntry { showContributionAlert = true }
|
||||||
|
else {
|
||||||
|
if hasChanges && AppConfig.isTrainingOptIn && !isNewEntry { scannerVM.uploadCorrection(image: displayImage, card: card, original: originalCard) }
|
||||||
|
finishSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishSave() {
|
||||||
|
if isNewEntry { vm.saveManualCard(card) } else { vm.updateCardDetails(card) }
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
384
IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift
Normal file
384
IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum SortOption: String, CaseIterable {
|
||||||
|
case dateAdded = "Date Added"
|
||||||
|
case valueHighLow = "Value (High-Low)"
|
||||||
|
case nameAZ = "Name (A-Z)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - COLLECTION VIEW MODEL
|
||||||
|
// Owns all collection state: list management, filtering, sorting, pricing, persistence.
|
||||||
|
// Primary sync: SwiftData + CloudKit (via PersistenceController).
|
||||||
|
// Secondary backup: Firebase — user-triggered, metadata only, no images.
|
||||||
|
@MainActor
|
||||||
|
class CollectionViewModel: ObservableObject {
|
||||||
|
@Published var scannedList: [SavedCard] = [] {
|
||||||
|
didSet { recalcStats() }
|
||||||
|
}
|
||||||
|
@Published var collections: [String] = [AppConfig.Defaults.masterCollectionName]
|
||||||
|
@Published var boxes: [String] = [AppConfig.Defaults.unsortedBoxName]
|
||||||
|
|
||||||
|
@Published var currentCollection: String = AppConfig.Defaults.masterCollectionName {
|
||||||
|
didSet { librarySearchText = ""; recalcStats() }
|
||||||
|
}
|
||||||
|
@Published var currentBox: String = AppConfig.Defaults.unsortedBoxName
|
||||||
|
@Published var sortOption: SortOption = .dateAdded
|
||||||
|
@Published var librarySearchText = "" {
|
||||||
|
didSet { recalcStats() }
|
||||||
|
}
|
||||||
|
@Published var isConnected = true
|
||||||
|
@Published var selectedCurrency: CurrencyCode = AppConfig.defaultCurrency
|
||||||
|
|
||||||
|
@Published var filteredList: [SavedCard] = []
|
||||||
|
@Published var totalValue: Double = 0.0
|
||||||
|
|
||||||
|
// Dashboard Stats
|
||||||
|
@Published var portfolioValue: Double = 0.0
|
||||||
|
@Published var portfolioDailyChange: Double = 0.0
|
||||||
|
@Published var topMovers: [SavedCard] = []
|
||||||
|
|
||||||
|
@Published var isCollectionLoading = true
|
||||||
|
@Published var isRefreshingPrices = false
|
||||||
|
@Published var isBackingUpToFirebase = false
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var recalcTask: Task<Void, Never>?
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
private var saveTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
NetworkMonitor.shared.$isConnected
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] status in self?.isConnected = status }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
// One-time JSON → SwiftData migration, then load
|
||||||
|
await PersistenceController.shared.backgroundActor.migrateFromJSONIfNeeded()
|
||||||
|
let loaded = await PersistenceController.shared.backgroundActor.load()
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.scannedList = loaded
|
||||||
|
self.isCollectionLoading = false
|
||||||
|
|
||||||
|
let loadedCollections = Set(loaded.map { $0.collectionName })
|
||||||
|
let loadedBoxes = Set(loaded.map { $0.storageLocation })
|
||||||
|
self.collections = Array(loadedCollections.union([AppConfig.Defaults.masterCollectionName])).sorted()
|
||||||
|
self.boxes = Array(loadedBoxes.union([AppConfig.Defaults.unsortedBoxName])).sorted()
|
||||||
|
|
||||||
|
Task.detached {
|
||||||
|
let activeImages = Set(loaded.map { $0.imageFileName })
|
||||||
|
ImageManager.cleanupOrphans(activeImages: activeImages)
|
||||||
|
}
|
||||||
|
self.refreshPrices(force: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats & Filtering
|
||||||
|
|
||||||
|
func recalcStats() {
|
||||||
|
let currentColl = self.currentCollection
|
||||||
|
let searchText = self.librarySearchText.lowercased()
|
||||||
|
let sort = self.sortOption
|
||||||
|
let sourceList = self.scannedList
|
||||||
|
|
||||||
|
recalcTask?.cancel()
|
||||||
|
recalcTask = Task.detached { [weak self] in
|
||||||
|
let filtered = sourceList.filter { card in
|
||||||
|
(searchText.isEmpty ||
|
||||||
|
card.name.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
card.setCode.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
card.collectorNumber.localizedCaseInsensitiveContains(searchText)) &&
|
||||||
|
(currentColl == AppConfig.Defaults.masterCollectionName || card.collectionName == currentColl)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sorted: [SavedCard]
|
||||||
|
switch sort {
|
||||||
|
case .dateAdded: sorted = filtered.sorted { $0.dateAdded > $1.dateAdded }
|
||||||
|
case .valueHighLow: sorted = filtered.sorted { ($0.currentValuation ?? 0) > ($1.currentValuation ?? 0) }
|
||||||
|
case .nameAZ: sorted = filtered.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = filtered.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||||
|
|
||||||
|
let globalTotal = sourceList.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||||
|
let globalPrev = sourceList.reduce(0) { $0 + ($1.previousValuation ?? $1.currentValuation ?? 0) }
|
||||||
|
let dailyChange = globalTotal - globalPrev
|
||||||
|
|
||||||
|
let movers = sourceList.filter { ($0.currentValuation ?? 0) != ($0.previousValuation ?? 0) }
|
||||||
|
.sorted { abs(($0.currentValuation ?? 0) - ($0.previousValuation ?? 0)) > abs(($1.currentValuation ?? 0) - ($1.previousValuation ?? 0)) }
|
||||||
|
.prefix(5)
|
||||||
|
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.filteredList = sorted
|
||||||
|
self.totalValue = total
|
||||||
|
self.portfolioValue = globalTotal
|
||||||
|
self.portfolioDailyChange = dailyChange
|
||||||
|
self.topMovers = Array(movers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pricing
|
||||||
|
|
||||||
|
func refreshPrices(force: Bool = false) {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
self.isRefreshingPrices = true
|
||||||
|
let cardsSnapshot = self.scannedList
|
||||||
|
let currencySnapshot = self.selectedCurrency
|
||||||
|
|
||||||
|
refreshTask = Task.detached { [weak self] in
|
||||||
|
let (priceUpdates, metadataUpdates) = await ScryfallAPI.updateTrends(cards: cardsSnapshot, currency: currencySnapshot, force: force)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isRefreshingPrices = false
|
||||||
|
if priceUpdates.isEmpty && metadataUpdates.isEmpty { return }
|
||||||
|
|
||||||
|
var listCopy = self.scannedList
|
||||||
|
var changedIndices = Set<Int>()
|
||||||
|
var hasChanges = false
|
||||||
|
|
||||||
|
var indexMap = [UUID: Int]()
|
||||||
|
for (index, card) in listCopy.enumerated() { indexMap[card.id] = index }
|
||||||
|
|
||||||
|
for (id, newPrice) in priceUpdates {
|
||||||
|
if let index = indexMap[id] {
|
||||||
|
let oldCurrency = listCopy[index].currencyCode
|
||||||
|
let oldPrice = listCopy[index].currentValuation
|
||||||
|
|
||||||
|
if oldCurrency != currencySnapshot.rawValue {
|
||||||
|
listCopy[index].previousValuation = newPrice
|
||||||
|
listCopy[index].currentValuation = newPrice
|
||||||
|
listCopy[index].currencyCode = currencySnapshot.rawValue
|
||||||
|
changedIndices.insert(index)
|
||||||
|
hasChanges = true
|
||||||
|
} else if oldPrice != newPrice {
|
||||||
|
listCopy[index].previousValuation = oldPrice
|
||||||
|
listCopy[index].currentValuation = newPrice
|
||||||
|
changedIndices.insert(index)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, meta) in metadataUpdates {
|
||||||
|
if let index = indexMap[id] {
|
||||||
|
if listCopy[index].rarity != meta.0 || listCopy[index].colorIdentity != meta.1 || (meta.2 && listCopy[index].isSerialized != true) {
|
||||||
|
listCopy[index].rarity = meta.0
|
||||||
|
listCopy[index].colorIdentity = meta.1
|
||||||
|
if meta.2 { listCopy[index].isSerialized = true }
|
||||||
|
changedIndices.insert(index)
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasChanges {
|
||||||
|
self.scannedList = listCopy
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection Mutations
|
||||||
|
|
||||||
|
func addCard(_ entry: SavedCard, cgImage: CGImage, uiImage: UIImage, autoScan: Bool) {
|
||||||
|
self.scannedList.insert(entry, at: 0)
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
ReviewEngine.logScan()
|
||||||
|
|
||||||
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||||
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveCard") {
|
||||||
|
UIApplication.shared.endBackgroundTask(taskID)
|
||||||
|
taskID = .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||||
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||||
|
do {
|
||||||
|
try ImageManager.save(uiImage, name: entry.imageFileName)
|
||||||
|
} catch {
|
||||||
|
print("Warning: Image Save Failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if entry.currentValuation == nil {
|
||||||
|
let currency = await MainActor.run { self.selectedCurrency }
|
||||||
|
let (prices, metadata) = await ScryfallAPI.updateTrends(cards: [entry], currency: currency, force: true, updateTimestamp: false)
|
||||||
|
if let newPrice = prices[entry.id] {
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let idx = self.scannedList.firstIndex(where: { $0.id == entry.id }) {
|
||||||
|
self.scannedList[idx].currentValuation = newPrice
|
||||||
|
if let meta = metadata[entry.id] {
|
||||||
|
self.scannedList[idx].rarity = meta.0
|
||||||
|
self.scannedList[idx].colorIdentity = meta.1
|
||||||
|
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||||
|
}
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCardDetails(_ card: SavedCard) {
|
||||||
|
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||||
|
scannedList[idx] = card
|
||||||
|
|
||||||
|
if !collections.contains(card.collectionName) { collections.append(card.collectionName); collections.sort() }
|
||||||
|
if !boxes.contains(card.storageLocation) { boxes.append(card.storageLocation); boxes.sort() }
|
||||||
|
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
|
||||||
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||||
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "UpdateCard") {
|
||||||
|
UIApplication.shared.endBackgroundTask(taskID)
|
||||||
|
taskID = .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||||
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||||
|
guard let self = self else { return }
|
||||||
|
let currency = await MainActor.run { self.selectedCurrency }
|
||||||
|
let (prices, _) = await ScryfallAPI.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
||||||
|
if let newPrice = prices[card.id] {
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let i = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||||
|
self.scannedList[i].currentValuation = newPrice
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveCard(_ card: SavedCard, toCollection: String, toBox: String) {
|
||||||
|
if let idx = scannedList.firstIndex(of: card) {
|
||||||
|
scannedList[idx].collectionName = toCollection
|
||||||
|
scannedList[idx].storageLocation = toBox
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCard(_ card: SavedCard) {
|
||||||
|
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||||
|
let fileName = scannedList[idx].imageFileName
|
||||||
|
Task { await ImageManager.delete(name: fileName) }
|
||||||
|
Task { await CloudEngine.delete(card: card) }
|
||||||
|
scannedList.remove(at: idx)
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveManualCard(_ card: SavedCard) {
|
||||||
|
self.scannedList.insert(card, at: 0)
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
ReviewEngine.logScan()
|
||||||
|
|
||||||
|
var taskID = UIBackgroundTaskIdentifier.invalid
|
||||||
|
taskID = UIApplication.shared.beginBackgroundTask(withName: "SaveManual") {
|
||||||
|
UIApplication.shared.endBackgroundTask(taskID)
|
||||||
|
taskID = .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, taskID] in
|
||||||
|
defer { UIApplication.shared.endBackgroundTask(taskID) }
|
||||||
|
guard let self = self else { return }
|
||||||
|
let currency = await MainActor.run { self.selectedCurrency }
|
||||||
|
let (prices, metadata) = await ScryfallAPI.updateTrends(cards: [card], currency: currency, force: true, updateTimestamp: false)
|
||||||
|
|
||||||
|
if let newPrice = prices[card.id] {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let idx = self.scannedList.firstIndex(where: { $0.id == card.id }) {
|
||||||
|
self.scannedList[idx].currentValuation = newPrice
|
||||||
|
if let meta = metadata[card.id] {
|
||||||
|
self.scannedList[idx].rarity = meta.0
|
||||||
|
self.scannedList[idx].colorIdentity = meta.1
|
||||||
|
if meta.2 { self.scannedList[idx].isSerialized = true }
|
||||||
|
}
|
||||||
|
self.saveCollectionAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export
|
||||||
|
|
||||||
|
func exportCollection(format: ExportFormat) async -> URL? {
|
||||||
|
let list = self.scannedList
|
||||||
|
return await Task.detached { ExportEngine.generate(cards: list, format: format) }.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToClipboard(format: ExportFormat) {
|
||||||
|
let text = ExportEngine.generateString(cards: self.scannedList, format: format)
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateShareImage(for card: SavedCard) async -> UIImage? {
|
||||||
|
let currencySymbol = self.selectedCurrency.symbol
|
||||||
|
return await Task.detached {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1080, height: 1080))
|
||||||
|
return renderer.image { ctx in
|
||||||
|
UIColor.black.setFill()
|
||||||
|
ctx.fill(CGRect(x: 0, y: 0, width: 1080, height: 1080))
|
||||||
|
if let img = ImageManager.load(name: card.imageFileName) { img.draw(in: CGRect(x: 140, y: 180, width: 800, height: 800)) }
|
||||||
|
if let watermark = UIImage(named: "share_watermark") { watermark.draw(in: CGRect(x: 340, y: 990, width: 400, height: 80)) }
|
||||||
|
else { "Verified by IYmtg".draw(at: CGPoint(x: 100, y: 1000), withAttributes: [.font: UIFont.systemFont(ofSize: 40), .foregroundColor: UIColor.gray]) }
|
||||||
|
let price = "\(currencySymbol)\(card.currentValuation ?? 0.0)" as NSString
|
||||||
|
price.draw(at: CGPoint(x: 100, y: 40), withAttributes: [.font: UIFont.systemFont(ofSize: 120, weight: .bold), .foregroundColor: UIColor.green])
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Firebase Backup (manual, metadata only)
|
||||||
|
|
||||||
|
/// Pushes all card metadata to Firebase Firestore as a secondary disaster-recovery backup.
|
||||||
|
/// Does NOT include card images (those remain in iCloud Drive).
|
||||||
|
/// Primary sync is handled automatically by SwiftData + CloudKit.
|
||||||
|
func backupAllToFirebase() {
|
||||||
|
guard !isBackingUpToFirebase else { return }
|
||||||
|
isBackingUpToFirebase = true
|
||||||
|
let snapshot = self.scannedList
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
CloudEngine.signInSilently()
|
||||||
|
for card in snapshot {
|
||||||
|
await CloudEngine.save(card: card)
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.isBackingUpToFirebase = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
func forceSave() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
let listSnapshot = self.scannedList
|
||||||
|
Task { await PersistenceController.shared.backgroundActor.save(listSnapshot) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCollectionAsync() {
|
||||||
|
saveTask?.cancel()
|
||||||
|
let listSnapshot = self.scannedList
|
||||||
|
saveTask = Task {
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
if !Task.isCancelled {
|
||||||
|
await PersistenceController.shared.backgroundActor.save(listSnapshot)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift
Normal file
66
IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrainingCategory: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let group: String
|
||||||
|
let functionalCount: Int
|
||||||
|
let solidCount: Int
|
||||||
|
let highAccuracyCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrainingGuideViewModel: ObservableObject {
|
||||||
|
@Published var categories: [TrainingCategory] = [
|
||||||
|
// MARK: Foil Model (Image Classification)
|
||||||
|
// 13 classes — false negatives cause wrong foil label; all classes need balanced data.
|
||||||
|
.init(name: "NonFoil", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Traditional", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Etched", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "PreModern", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Textured", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Galaxy", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Surge", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Oil Slick", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Step & Compleat", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Halo", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Confetti", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Neon Ink", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Fracture", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
|
||||||
|
// MARK: Stamp Model (Image Classification)
|
||||||
|
// Detects promo stamp presence. Binary classification — easy to train quickly.
|
||||||
|
.init(name: "Stamped (Promo)", group: "Stamp Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Clean (No Stamp)", group: "Stamp Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
|
||||||
|
// MARK: Condition Model (Object Detection — requires bounding-box annotations in Create ML)
|
||||||
|
// Higher minimums than image classification because each image needs manual annotation.
|
||||||
|
// Surface defects
|
||||||
|
.init(name: "Light Scratches", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Clouding", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Dirt", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Dents", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
// Edge defects
|
||||||
|
.init(name: "Whitening (Edges)", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Chipping (Edges)", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Corner Wear", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
// Structure defects
|
||||||
|
.init(name: "Creases", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Shuffle Bend", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
.init(name: "Binder Dents", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100),
|
||||||
|
// Critical damage — a single false positive will downgrade a NM card to Damaged.
|
||||||
|
// Train these more aggressively to minimise false positives.
|
||||||
|
.init(name: "Water Damage", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150),
|
||||||
|
.init(name: "Inking", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150),
|
||||||
|
.init(name: "Rips", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150),
|
||||||
|
]
|
||||||
|
|
||||||
|
var groups: [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
return categories.compactMap { seen.insert($0.group).inserted ? $0.group : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func categories(for group: String) -> [TrainingCategory] {
|
||||||
|
categories.filter { $0.group == group }
|
||||||
|
}
|
||||||
|
}
|
||||||
104
IYmtg_App_iOS/Features/Help/TrainingGuideView.swift
Normal file
104
IYmtg_App_iOS/Features/Help/TrainingGuideView.swift
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// IYmtg_App_iOS/Features/Help/TrainingGuideView.swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrainingGuideView: View {
|
||||||
|
@StateObject private var viewModel = TrainingGuideViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("These are the recommended image counts for each ML training category. Collect cropped card photos and drop them into the matching `IYmtg_Training/` subfolder.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
LegendChip(color: .orange, label: "Functional")
|
||||||
|
LegendChip(color: .green, label: "Solid")
|
||||||
|
LegendChip(color: .blue, label: "High-Accuracy")
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.groups, id: \.self) { group in
|
||||||
|
Section(header: GroupHeader(title: group)) {
|
||||||
|
ForEach(viewModel.categories(for: group)) { category in
|
||||||
|
TrainingCategoryRow(category: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Training Guide")
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
private struct GroupHeader: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
private var icon: String {
|
||||||
|
switch title {
|
||||||
|
case "Foil Model": return "sparkles"
|
||||||
|
case "Stamp Model": return "seal.fill"
|
||||||
|
case "Condition Model": return "exclamationmark.triangle"
|
||||||
|
default: return "circle.grid.2x2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TrainingCategoryRow: View {
|
||||||
|
let category: TrainingCategory
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(category.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.bold()
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
StatusIndicator(level: "Functional", count: category.functionalCount, color: .orange)
|
||||||
|
Spacer()
|
||||||
|
StatusIndicator(level: "Solid", count: category.solidCount, color: .green)
|
||||||
|
Spacer()
|
||||||
|
StatusIndicator(level: "High-Accuracy", count: category.highAccuracyCount, color: .blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusIndicator: View {
|
||||||
|
let level: String
|
||||||
|
let count: Int
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
Text("\(level): \(count)+")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LegendChip: View {
|
||||||
|
let color: Color
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle().fill(color).frame(width: 8, height: 8)
|
||||||
|
Text(label).font(.caption2).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
449
IYmtg_App_iOS/Features/Scanner/ScannerViewModel.swift
Normal file
449
IYmtg_App_iOS/Features/Scanner/ScannerViewModel.swift
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import Vision
|
||||||
|
import AVFoundation
|
||||||
|
import AudioToolbox
|
||||||
|
import Combine
|
||||||
|
import os
|
||||||
|
import ImageIO
|
||||||
|
import CoreMedia
|
||||||
|
import CoreImage
|
||||||
|
|
||||||
|
// MARK: - SCANNER VIEW MODEL
|
||||||
|
// Focused exclusively on camera management, frame processing, and the scanning pipeline.
|
||||||
|
// Collection state and persistence are delegated to CollectionViewModel.
|
||||||
|
@MainActor
|
||||||
|
class ScannerViewModel: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||||
|
|
||||||
|
// MARK: Scanner State
|
||||||
|
@Published var statusText = "Initializing Database..."
|
||||||
|
@Published var isDatabaseLoading = true
|
||||||
|
@Published var detectedCard: CardMetadata?
|
||||||
|
@Published var detectedDamages: [DamageObservation] = []
|
||||||
|
@Published var isProcessing = false
|
||||||
|
@Published var isFound = false
|
||||||
|
@Published var isPermissionDenied = false
|
||||||
|
@Published var showDatabaseAlert = false
|
||||||
|
@Published var databaseAlertTitle = ""
|
||||||
|
@Published var databaseAlertMessage = ""
|
||||||
|
@Published var isSaving = false
|
||||||
|
@Published var isTorchOn = false
|
||||||
|
@Published var isAutoScanEnabled: Bool = UserDefaults.standard.bool(forKey: "isAutoScanEnabled") {
|
||||||
|
didSet { UserDefaults.standard.set(isAutoScanEnabled, forKey: "isAutoScanEnabled") }
|
||||||
|
}
|
||||||
|
@Published var currentFoilType: String = AppConfig.Defaults.defaultFoil
|
||||||
|
|
||||||
|
// MARK: Collection Forwarding (read-only convenience for ScannerView & CardDetailView)
|
||||||
|
// Changes to collectionVM's @Published properties propagate via objectWillChange forwarding.
|
||||||
|
var isCollectionLoading: Bool { collectionVM.isCollectionLoading }
|
||||||
|
var isConnected: Bool { collectionVM.isConnected }
|
||||||
|
var collections: [String] { collectionVM.collections }
|
||||||
|
var boxes: [String] { collectionVM.boxes }
|
||||||
|
var selectedCurrency: CurrencyCode { collectionVM.selectedCurrency }
|
||||||
|
var currentCollection: String {
|
||||||
|
get { collectionVM.currentCollection }
|
||||||
|
set { collectionVM.currentCollection = newValue }
|
||||||
|
}
|
||||||
|
var currentBox: String {
|
||||||
|
get { collectionVM.currentBox }
|
||||||
|
set { collectionVM.currentBox = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Collection Method Forwarding (for CardDetailView backward compatibility)
|
||||||
|
func saveManualCard(_ card: SavedCard) { collectionVM.saveManualCard(card) }
|
||||||
|
func updateCardDetails(_ card: SavedCard) { collectionVM.updateCardDetails(card) }
|
||||||
|
|
||||||
|
// MARK: Dependencies
|
||||||
|
let collectionVM: CollectionViewModel
|
||||||
|
var currentFrameImage: CGImage?
|
||||||
|
|
||||||
|
private var collectionVMObservation: AnyCancellable?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
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>?
|
||||||
|
|
||||||
|
// MARK: Init
|
||||||
|
|
||||||
|
init(collectionVM: CollectionViewModel) {
|
||||||
|
self.collectionVM = collectionVM
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
// Forward collectionVM changes so ScannerView re-renders on collection state updates
|
||||||
|
collectionVMObservation = collectionVM.objectWillChange
|
||||||
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
|
||||||
|
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
||||||
|
|
||||||
|
// Pre-warm ML models in background
|
||||||
|
Task(priority: .background) { let _ = ConditionEngine.model; let _ = FoilEngine.model }
|
||||||
|
|
||||||
|
// Load card fingerprint database
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let url = Bundle.main.url(forResource: "cards", withExtension: "json") {
|
||||||
|
do {
|
||||||
|
try await self.analyzer.loadDatabase(from: url)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isDatabaseLoading = false
|
||||||
|
self.statusText = "Ready to Scan"
|
||||||
|
self.checkCameraPermissions()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isDatabaseLoading = false
|
||||||
|
self.databaseAlertTitle = "Database Error"
|
||||||
|
self.databaseAlertMessage = "Failed to load card database. Please try restarting the app."
|
||||||
|
self.showDatabaseAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isDatabaseLoading = false
|
||||||
|
self.databaseAlertTitle = "Database Missing"
|
||||||
|
self.databaseAlertMessage = "The card database could not be found. Please reinstall the app."
|
||||||
|
self.showDatabaseAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DevEngine.activateIfCompiled()
|
||||||
|
ModelManager.shared.checkForUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||||
|
processingTask?.cancel()
|
||||||
|
saveTask?.cancel()
|
||||||
|
focusResetTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Permissions & Setup
|
||||||
|
|
||||||
|
func checkCameraPermissions() {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
self.isPermissionDenied = false
|
||||||
|
if !isSessionConfigured { self.setupCamera() } else { self.startSession() }
|
||||||
|
case .notDetermined:
|
||||||
|
Task { [weak self] in
|
||||||
|
if await AVCaptureDevice.requestAccess(for: .video) {
|
||||||
|
self?.isPermissionDenied = false; self?.setupCamera()
|
||||||
|
} else {
|
||||||
|
self?.isPermissionDenied = true; self?.statusText = "Camera Access Denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .denied, .restricted:
|
||||||
|
self.isPermissionDenied = true; self.statusText = "Camera Access Denied"
|
||||||
|
@unknown default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCamera() {
|
||||||
|
if self.isPermissionDenied { return }
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .background).async {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default, options: .mixWithOthers)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: dev) else { return }
|
||||||
|
|
||||||
|
self.session.beginConfiguration()
|
||||||
|
for i in self.session.inputs { self.session.removeInput(i) }
|
||||||
|
for o in self.session.outputs { self.session.removeOutput(o) }
|
||||||
|
|
||||||
|
if self.session.canSetSessionPreset(.hd1920x1080) { self.session.sessionPreset = .hd1920x1080 }
|
||||||
|
if self.session.canAddInput(input) { self.session.addInput(input) }
|
||||||
|
|
||||||
|
let out = AVCaptureVideoDataOutput()
|
||||||
|
out.setSampleBufferDelegate(self, queue: DispatchQueue(label: "video"))
|
||||||
|
if self.session.canAddOutput(out) { self.session.addOutput(out) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try dev.lockForConfiguration()
|
||||||
|
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
||||||
|
if dev.isSmoothAutoFocusSupported { dev.isSmoothAutoFocusEnabled = true }
|
||||||
|
// OPTIMIZATION: Cap at 30 FPS to reduce thermal load and battery usage
|
||||||
|
dev.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30)
|
||||||
|
let zoomFactor = min(dev.maxAvailableVideoZoomFactor, max(1.5, dev.minAvailableVideoZoomFactor))
|
||||||
|
dev.videoZoomFactor = zoomFactor
|
||||||
|
dev.unlockForConfiguration()
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
self.session.commitConfiguration()
|
||||||
|
Task { @MainActor [weak self] in self?.isSessionConfigured = true }
|
||||||
|
self.session.startRunning()
|
||||||
|
|
||||||
|
if dev.hasTorch {
|
||||||
|
let actualState = dev.torchMode == .on
|
||||||
|
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTorch() {
|
||||||
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch else { return }
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
try? dev.lockForConfiguration()
|
||||||
|
dev.torchMode = dev.torchMode == .on ? .off : .on
|
||||||
|
dev.unlockForConfiguration()
|
||||||
|
let actualState = dev.torchMode == .on
|
||||||
|
Task { @MainActor [weak self] in self?.isTorchOn = actualState }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusCamera(at point: CGPoint) {
|
||||||
|
focusResetTask?.cancel()
|
||||||
|
|
||||||
|
sessionQueue.async {
|
||||||
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
||||||
|
do {
|
||||||
|
try dev.lockForConfiguration()
|
||||||
|
if dev.isFocusPointOfInterestSupported { dev.focusPointOfInterest = point; dev.focusMode = .autoFocus }
|
||||||
|
if dev.isExposurePointOfInterestSupported { dev.exposurePointOfInterest = point; dev.exposureMode = .autoExpose }
|
||||||
|
dev.unlockForConfiguration()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusResetTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 4_000_000_000)
|
||||||
|
self?.sessionQueue.async {
|
||||||
|
guard let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
|
||||||
|
do {
|
||||||
|
try dev.lockForConfiguration()
|
||||||
|
if dev.isFocusModeSupported(.continuousAutoFocus) { dev.focusMode = .continuousAutoFocus }
|
||||||
|
dev.unlockForConfiguration()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Task cancelled, do not reset focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startSession() {
|
||||||
|
self.isScanningActive = true
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !self.session.isRunning { self.session.startRunning() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSession() {
|
||||||
|
self.isScanningActive = false
|
||||||
|
focusResetTask?.cancel()
|
||||||
|
processingTask?.cancel()
|
||||||
|
collectionVM.forceSave()
|
||||||
|
|
||||||
|
sessionQueue.async { [weak self] in
|
||||||
|
if let dev = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), dev.hasTorch {
|
||||||
|
try? dev.lockForConfiguration(); dev.torchMode = .off; dev.unlockForConfiguration()
|
||||||
|
}
|
||||||
|
guard let self = self else { return }
|
||||||
|
if self.session.isRunning { self.session.stopRunning() }
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if !self.isFound { self.cancelScan() }
|
||||||
|
self.isProcessing = false; self.isTorchOn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Frame Processing
|
||||||
|
|
||||||
|
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
|
||||||
|
guard let cvBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||||
|
guard processingLock.withLock({ if $0 { return false } else { $0 = true; return true } }) else { return }
|
||||||
|
|
||||||
|
let ciImage = CIImage(cvPixelBuffer: cvBuffer)
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
defer { self.processingLock.withLock { $0 = false } }
|
||||||
|
|
||||||
|
let (shouldProcess, orientation, isBusy) = await MainActor.run {
|
||||||
|
let now = Date()
|
||||||
|
// OPTIMIZATION: Throttle analysis pipeline to ~15 FPS
|
||||||
|
if now.timeIntervalSince(self.lastFrameTime) < 0.06 { return (false, CGImagePropertyOrientation.up, false) }
|
||||||
|
self.lastFrameTime = now
|
||||||
|
return (self.isScanningActive && !self.isFound && !self.isSaving && !self.isDatabaseLoading && !self.isCollectionLoading && now.timeIntervalSince(self.lastSaveTime) > 2.0, self.getCurrentOrientations().1, self.isProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard shouldProcess else { return }
|
||||||
|
guard let cg = SharedEngineResources.context.createCGImage(ciImage, from: ciImage.extent) else { return }
|
||||||
|
|
||||||
|
let foilType = await self.foilEngine.addFrame(cg, orientation: orientation)
|
||||||
|
await MainActor.run { if let foilType { self.currentFoilType = foilType } }
|
||||||
|
|
||||||
|
guard !isBusy else { return }
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cg, orientation: .up)
|
||||||
|
let req = VNDetectRectanglesRequest()
|
||||||
|
try? handler.perform([req])
|
||||||
|
if let obs = req.results?.first as? VNRectangleObservation {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self = self, self.isScanningActive else { return }
|
||||||
|
self.processingTask = Task { [weak self] in await self?.processCrop(obs, from: cg, orientation: orientation) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCrop(_ obs: VNRectangleObservation, from fullImage: CGImage, orientation: CGImagePropertyOrientation) async {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
let shouldProceed = await MainActor.run {
|
||||||
|
if self.isFound || self.isProcessing { return false }
|
||||||
|
self.isProcessing = true
|
||||||
|
self.detectHaptics.prepare()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard shouldProceed else { return }
|
||||||
|
|
||||||
|
let croppedImage: CGImage? = await Task.detached {
|
||||||
|
let width = CGFloat(fullImage.width)
|
||||||
|
let height = CGFloat(fullImage.height)
|
||||||
|
let bbox = obs.boundingBox
|
||||||
|
let rect = CGRect(x: bbox.origin.x * width,
|
||||||
|
y: (1 - bbox.origin.y - bbox.height) * height,
|
||||||
|
width: bbox.width * width,
|
||||||
|
height: bbox.height * height)
|
||||||
|
return fullImage.cropping(to: rect)
|
||||||
|
}.value
|
||||||
|
|
||||||
|
guard let cropped = croppedImage else {
|
||||||
|
await MainActor.run { self.isProcessing = false }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if DevEngine.isDevMode { DevEngine.saveRaw(image: UIImage(cgImage: cropped), label: "AutoCrop") }
|
||||||
|
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
||||||
|
|
||||||
|
// OPTIMIZATION: Run identification and damage grading in parallel
|
||||||
|
async let analysis = self.analyzer.analyze(croppedImage: cropped, orientation: orientation)
|
||||||
|
async let damageCheck = Task.detached(priority: .userInitiated) { ConditionEngine.detectDamage(image: cropped, orientation: orientation) }.value
|
||||||
|
|
||||||
|
let ((finalCard, detectedSerialized), damages) = await (analysis, damageCheck)
|
||||||
|
|
||||||
|
if Task.isCancelled { await MainActor.run { self.isProcessing = false }; return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
guard !self.isFound && self.isScanningActive else { self.isProcessing = false; return }
|
||||||
|
|
||||||
|
if var card = finalCard {
|
||||||
|
self.isFound = true
|
||||||
|
self.successHaptics.notificationOccurred(.success)
|
||||||
|
AudioServicesPlaySystemSound(1108)
|
||||||
|
|
||||||
|
card.isSerialized = detectedSerialized
|
||||||
|
self.detectedDamages = damages
|
||||||
|
self.detectedCard = card
|
||||||
|
self.currentFrameImage = cropped
|
||||||
|
|
||||||
|
if self.isAutoScanEnabled {
|
||||||
|
self.saveCurrentCard()
|
||||||
|
} else {
|
||||||
|
self.isProcessing = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scan Actions
|
||||||
|
|
||||||
|
func cancelScan() {
|
||||||
|
self.isFound = false
|
||||||
|
self.detectedCard = nil
|
||||||
|
self.currentFrameImage = nil
|
||||||
|
self.isProcessing = false
|
||||||
|
self.currentFoilType = AppConfig.Defaults.defaultFoil
|
||||||
|
self.statusText = "Ready to Scan"
|
||||||
|
self.processingTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCurrentCard() {
|
||||||
|
guard let card = detectedCard, let cgImage = currentFrameImage else { return }
|
||||||
|
self.isSaving = true
|
||||||
|
self.lastSaveTime = Date()
|
||||||
|
|
||||||
|
let imageName = "\(UUID().uuidString).jpg"
|
||||||
|
let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: getCurrentOrientations().0)
|
||||||
|
|
||||||
|
var entry = SavedCard(from: card, imageName: imageName, collection: collectionVM.currentCollection, location: collectionVM.currentBox)
|
||||||
|
entry.foilType = self.currentFoilType
|
||||||
|
entry.condition = ConditionEngine.overallGrade(damages: self.detectedDamages)
|
||||||
|
|
||||||
|
let autoScan = self.isAutoScanEnabled
|
||||||
|
|
||||||
|
// Delegate persistence and cloud sync to CollectionViewModel
|
||||||
|
collectionVM.addCard(entry, cgImage: cgImage, uiImage: uiImage, autoScan: autoScan)
|
||||||
|
|
||||||
|
if autoScan {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.cancelScan()
|
||||||
|
self?.isSaving = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.cancelScan()
|
||||||
|
self.isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Training Uploads
|
||||||
|
|
||||||
|
func uploadTrainingImage(label: String) {
|
||||||
|
guard let cg = currentFrameImage else { return }
|
||||||
|
let orientation = getCurrentOrientations().0
|
||||||
|
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
||||||
|
Task.detached { TrainingUploader.upload(image: img, label: label, force: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadCorrection(image: UIImage?, card: SavedCard, original: SavedCard?) {
|
||||||
|
guard let img = image ?? ImageManager.load(name: card.imageFileName) else { return }
|
||||||
|
|
||||||
|
var labels: [String] = []
|
||||||
|
if card.name != original?.name || card.setCode != original?.setCode { labels.append("Identity_\(card.setCode)_\(card.collectorNumber)") }
|
||||||
|
if card.condition != original?.condition { labels.append("Condition_\(card.condition)") }
|
||||||
|
if card.foilType != original?.foilType { labels.append("Foil_\(card.foilType)") }
|
||||||
|
|
||||||
|
if labels.isEmpty { return }
|
||||||
|
|
||||||
|
Task.detached {
|
||||||
|
for label in labels { TrainingUploader.upload(image: img, label: label, force: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func getCurrentOrientations() -> (UIImage.Orientation, CGImagePropertyOrientation) {
|
||||||
|
switch UIDevice.current.orientation {
|
||||||
|
case .portrait: return (.right, .right)
|
||||||
|
case .portraitUpsideDown: return (.left, .left)
|
||||||
|
case .landscapeLeft: return (.up, .up)
|
||||||
|
case .landscapeRight: return (.down, .down)
|
||||||
|
default: return (.right, .right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import FirebaseCore
|
import FirebaseCore
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -13,5 +14,6 @@ struct IYmtgApp: App {
|
|||||||
}
|
}
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup { ContentView() }
|
WindowGroup { ContentView() }
|
||||||
|
.modelContainer(PersistenceController.shared.container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,43 +107,46 @@ final class IYmtgTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ViewModel Logic Tests
|
// MARK: - ViewModel Logic Tests
|
||||||
|
// CollectionViewModel owns filtering and portfolio logic; tests target it directly.
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testViewModelFiltering() {
|
func testViewModelFiltering() {
|
||||||
let vm = ScannerViewModel()
|
let vm = CollectionViewModel()
|
||||||
|
|
||||||
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 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")
|
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")
|
||||||
|
|
||||||
|
// Inject test data after the async SwiftData init load has completed.
|
||||||
|
let expectation = XCTestExpectation(description: "Filter updates")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
vm.scannedList = [card1, card2]
|
vm.scannedList = [card1, card2]
|
||||||
|
|
||||||
// Test Search
|
|
||||||
vm.librarySearchText = "Alpha"
|
vm.librarySearchText = "Alpha"
|
||||||
|
|
||||||
// Wait for async recalc
|
|
||||||
let expectation = XCTestExpectation(description: "Filter updates")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
XCTAssertEqual(vm.filteredList.count, 1)
|
XCTAssertEqual(vm.filteredList.count, 1)
|
||||||
XCTAssertEqual(vm.filteredList.first?.name, "Alpha")
|
XCTAssertEqual(vm.filteredList.first?.name, "Alpha")
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
wait(for: [expectation], timeout: 2.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testPortfolioCalculation() {
|
func testPortfolioCalculation() {
|
||||||
let vm = ScannerViewModel()
|
let vm = CollectionViewModel()
|
||||||
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 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")
|
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")
|
||||||
|
|
||||||
|
let expectation = XCTestExpectation(description: "Stats update")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
vm.scannedList = [card1, card2]
|
vm.scannedList = [card1, card2]
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Stats update")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
XCTAssertEqual(vm.portfolioValue, 200.0)
|
XCTAssertEqual(vm.portfolioValue, 200.0)
|
||||||
expectation.fulfill()
|
expectation.fulfill()
|
||||||
}
|
}
|
||||||
wait(for: [expectation], timeout: 1.0)
|
}
|
||||||
|
wait(for: [expectation], timeout: 2.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,776 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
49
IYmtg_App_iOS/Services/Cloud/CloudEngine.swift
Normal file
49
IYmtg_App_iOS/Services/Cloud/CloudEngine.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import FirebaseFirestore
|
||||||
|
import FirebaseAuth
|
||||||
|
|
||||||
|
// MARK: - CLOUD ENGINE
|
||||||
|
class CloudEngine {
|
||||||
|
static var db: Firestore? {
|
||||||
|
return FirebaseApp.app() != nil ? Firestore.firestore() : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func signInSilently() {
|
||||||
|
guard FirebaseApp.app() != nil else { return }
|
||||||
|
if Auth.auth().currentUser == nil { Auth.auth().signInAnonymously() }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(card: SavedCard) async {
|
||||||
|
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
|
||||||
|
let data: [String: Any] = [
|
||||||
|
"name": card.name,
|
||||||
|
"set": card.setCode,
|
||||||
|
"num": card.collectorNumber,
|
||||||
|
"val": card.currentValuation ?? 0.0,
|
||||||
|
"cond": card.condition,
|
||||||
|
"foil": card.foilType,
|
||||||
|
"coll": card.collectionName,
|
||||||
|
"loc": card.storageLocation,
|
||||||
|
"grade": card.grade ?? "",
|
||||||
|
"svc": card.gradingService ?? "",
|
||||||
|
"cert": card.certNumber ?? "",
|
||||||
|
"date": card.dateAdded,
|
||||||
|
"curr": card.currencyCode ?? "USD",
|
||||||
|
"rarity": card.rarity ?? "",
|
||||||
|
"colors": card.colorIdentity ?? [],
|
||||||
|
"ser": card.isSerialized ?? false,
|
||||||
|
"img": card.imageFileName,
|
||||||
|
"custom": card.isCustomValuation
|
||||||
|
]
|
||||||
|
do {
|
||||||
|
try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).setData(data, merge: true)
|
||||||
|
} catch { print("Cloud Save Error: \(error)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(card: SavedCard) async {
|
||||||
|
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
|
||||||
|
do {
|
||||||
|
try await db.collection("users").document(uid).collection("inventory").document(card.id.uuidString).delete()
|
||||||
|
} catch { print("Cloud Delete Error: \(error)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
IYmtg_App_iOS/Services/Cloud/TrainingUploader.swift
Normal file
13
IYmtg_App_iOS/Services/Cloud/TrainingUploader.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import UIKit
|
||||||
|
import FirebaseStorage
|
||||||
|
|
||||||
|
// MARK: - TRAINING UPLOADER
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
50
IYmtg_App_iOS/Services/CoreML/ConditionEngine.swift
Normal file
50
IYmtg_App_iOS/Services/CoreML/ConditionEngine.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreML
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct DamageObservation: Identifiable, Sendable {
|
||||||
|
let id = UUID()
|
||||||
|
let type: String
|
||||||
|
let rect: CGRect
|
||||||
|
let confidence: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CONDITION ENGINE
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
IYmtg_App_iOS/Services/CoreML/FoilEngine.swift
Normal file
48
IYmtg_App_iOS/Services/CoreML/FoilEngine.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreML
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - FOIL ENGINE
|
||||||
|
actor FoilEngine {
|
||||||
|
private var frameCounter = 0
|
||||||
|
private var lowConfidenceStreak = 0
|
||||||
|
|
||||||
|
static var model: VNCoreMLModel? = {
|
||||||
|
guard AppConfig.enableFoilDetection else { return nil }
|
||||||
|
return ModelManager.shared.getModel(name: "IYmtgFoilClassifier")
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var request: VNCoreMLRequest? = {
|
||||||
|
guard let model = FoilEngine.model else { return nil }
|
||||||
|
let req = VNCoreMLRequest(model: model)
|
||||||
|
req.imageCropAndScaleOption = .scaleFill
|
||||||
|
return req
|
||||||
|
}()
|
||||||
|
|
||||||
|
func addFrame(_ image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? {
|
||||||
|
frameCounter += 1
|
||||||
|
// SAFETY: Prevent integer overflow in long-running sessions
|
||||||
|
if frameCounter > 1000 { frameCounter = 0 }
|
||||||
|
if frameCounter % 5 != 0 { return nil }
|
||||||
|
guard let request = self.request else { return AppConfig.Defaults.defaultFoil }
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return AppConfig.Defaults.defaultFoil }
|
||||||
|
|
||||||
|
if top.confidence > 0.85 {
|
||||||
|
lowConfidenceStreak = 0
|
||||||
|
return top.identifier
|
||||||
|
} else {
|
||||||
|
lowConfidenceStreak += 1
|
||||||
|
if lowConfidenceStreak >= 3 {
|
||||||
|
return AppConfig.Defaults.defaultFoil
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { return AppConfig.Defaults.defaultFoil }
|
||||||
|
}
|
||||||
|
}
|
||||||
80
IYmtg_App_iOS/Services/CoreML/ModelManager.swift
Normal file
80
IYmtg_App_iOS/Services/CoreML/ModelManager.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import CoreML
|
||||||
|
import Vision
|
||||||
|
import FirebaseCore
|
||||||
|
import FirebaseStorage
|
||||||
|
|
||||||
|
struct SharedEngineResources {
|
||||||
|
static let context = CIContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MODEL MANAGER (OTA Updates)
|
||||||
|
// Downloads updated .mlmodel files from Firebase Storage and compiles them to Documents/models/.
|
||||||
|
// IMPORTANT: Each engine (FoilEngine, ConditionEngine, etc.) loads its model via a `static var`
|
||||||
|
// that is evaluated once at class-load time. A downloaded update will NOT take effect until the
|
||||||
|
// app is restarted. Inform users via release notes when a model update has been pushed OTA.
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
IYmtg_App_iOS/Services/CoreML/SetSymbolEngine.swift
Normal file
29
IYmtg_App_iOS/Services/CoreML/SetSymbolEngine.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreML
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - SET SYMBOL ENGINE
|
||||||
|
class SetSymbolEngine {
|
||||||
|
static var model: VNCoreMLModel? = {
|
||||||
|
guard AppConfig.enableSetSymbolDetection else { return nil }
|
||||||
|
return ModelManager.shared.getModel(name: "IYmtgSetClassifier")
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func recognizeSet(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> String? {
|
||||||
|
guard let model = model else { return nil }
|
||||||
|
let request = VNCoreMLRequest(model: model)
|
||||||
|
request.imageCropAndScaleOption = .scaleFill
|
||||||
|
|
||||||
|
// FIX: Use regionOfInterest (Normalized 0..1, Bottom-Left origin)
|
||||||
|
// Target: x=0.85 (Right), y=0.36 (Mid-Lower), w=0.12, h=0.09
|
||||||
|
request.regionOfInterest = CGRect(x: 0.85, y: 0.36, width: 0.12, height: 0.09)
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return nil }
|
||||||
|
return top.confidence > 0.6 ? top.identifier : nil
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
IYmtg_App_iOS/Services/Utilities/DevEngine.swift
Normal file
24
IYmtg_App_iOS/Services/Utilities/DevEngine.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - SECURE DEV MODE
|
||||||
|
class DevEngine {
|
||||||
|
static var isDevMode = false
|
||||||
|
|
||||||
|
static func activateIfCompiled() {
|
||||||
|
#if ENABLE_DEV_MODE
|
||||||
|
isDevMode = true
|
||||||
|
print("⚠️ DEV MODE COMPILED")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveRaw(image: UIImage, label: String) {
|
||||||
|
#if ENABLE_DEV_MODE
|
||||||
|
if isDevMode, let data = image.jpegData(compressionQuality: 1.0) {
|
||||||
|
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("RawTrainingData")
|
||||||
|
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||||
|
try? data.write(to: path.appendingPathComponent("TRAIN_\(label)_\(UUID().uuidString).jpg"))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
135
IYmtg_App_iOS/Services/Utilities/ExportEngine.swift
Normal file
135
IYmtg_App_iOS/Services/Utilities/ExportEngine.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import UIKit
|
||||||
|
import PDFKit
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable { case insurance = "PDF"; case arena = "Arena"; case mtgo = "MTGO"; case csv = "CSV" }
|
||||||
|
|
||||||
|
// MARK: - EXPORT ENGINE
|
||||||
|
class ExportEngine {
|
||||||
|
static func generateString(cards: [SavedCard], format: ExportFormat) -> String {
|
||||||
|
if format == .csv {
|
||||||
|
func cleanCSV(_ text: String) -> String { return text.replacingOccurrences(of: "\"", with: "\"\"") }
|
||||||
|
var csv = "Count,Name,Set,Number,Condition,Foil,Price,Serialized\n"
|
||||||
|
for card in cards {
|
||||||
|
let ser = (card.isSerialized ?? false) ? "Yes" : "No"
|
||||||
|
let line = "1,\"\(cleanCSV(card.name))\",\(card.setCode),\(card.collectorNumber),\(card.condition),\(card.foilType),\(card.currentValuation ?? 0.0),\(ser)\n"
|
||||||
|
csv.append(line)
|
||||||
|
}
|
||||||
|
return csv
|
||||||
|
} else if format == .mtgo {
|
||||||
|
return cards.map { "1 \($0.name)" }.joined(separator: "\n")
|
||||||
|
} else {
|
||||||
|
// Arena
|
||||||
|
return cards.map { "1 \($0.name) (\($0.setCode)) \($0.collectorNumber)" }.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func generate(cards: [SavedCard], format: ExportFormat) -> URL? {
|
||||||
|
if format == .insurance { return generatePDF(cards: cards) }
|
||||||
|
|
||||||
|
let text = generateString(cards: cards, format: format)
|
||||||
|
let filename = format == .csv ? "Collection.csv" : (format == .mtgo ? "MTGO_List.txt" : "Arena_Decklist.txt")
|
||||||
|
let path = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||||
|
try? text.write(to: path, atomically: true, encoding: .utf8)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generatePDF(cards: [SavedCard]) -> URL? {
|
||||||
|
let fmt = UIGraphicsPDFRendererFormat()
|
||||||
|
fmt.documentInfo = [kCGPDFContextCreator: "IYmtg"] as [String: Any]
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("Insurance.pdf")
|
||||||
|
|
||||||
|
let totalVal = cards.reduce(0) { $0 + ($1.currentValuation ?? 0) }
|
||||||
|
let count = cards.count
|
||||||
|
|
||||||
|
UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792), format: fmt).writePDF(to: url) { ctx in
|
||||||
|
var pageY: CGFloat = 50
|
||||||
|
let headerAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 24)]
|
||||||
|
let subHeaderAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 14)]
|
||||||
|
let textAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)]
|
||||||
|
|
||||||
|
func drawHeader() {
|
||||||
|
"Insurance Schedule - \(Date().formatted(date: .abbreviated, time: .shortened))".draw(at: CGPoint(x: 50, y: 50), withAttributes: headerAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawColumnHeaders(y: CGFloat) {
|
||||||
|
let hAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: UIColor.darkGray]
|
||||||
|
"CARD DETAILS".draw(at: CGPoint(x: 100, y: y), withAttributes: hAttrs)
|
||||||
|
"SET / #".draw(at: CGPoint(x: 310, y: y), withAttributes: hAttrs)
|
||||||
|
"COND".draw(at: CGPoint(x: 420, y: y), withAttributes: hAttrs)
|
||||||
|
"VALUE".draw(at: CGPoint(x: 520, y: y), withAttributes: hAttrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPage()
|
||||||
|
drawHeader()
|
||||||
|
|
||||||
|
// Summary Section
|
||||||
|
"Policy Holder: __________________________".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
|
||||||
|
"Total Items: \(count)".draw(at: CGPoint(x: 50, y: 115), withAttributes: subHeaderAttrs)
|
||||||
|
"Total Value: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 200, y: 115), withAttributes: subHeaderAttrs)
|
||||||
|
drawColumnHeaders(y: 135)
|
||||||
|
|
||||||
|
pageY = 150
|
||||||
|
|
||||||
|
for card in cards {
|
||||||
|
if pageY + 65 > 740 {
|
||||||
|
ctx.beginPage()
|
||||||
|
drawHeader()
|
||||||
|
drawColumnHeaders(y: 75)
|
||||||
|
pageY = 90
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column 0: Image
|
||||||
|
if let img = ImageManager.load(name: card.imageFileName) {
|
||||||
|
img.draw(in: CGRect(x: 50, y: pageY, width: 40, height: 56))
|
||||||
|
}
|
||||||
|
|
||||||
|
let textY = pageY + 20
|
||||||
|
// Column 1: Name (Truncate if long)
|
||||||
|
let name = "\(card.name)\((card.isSerialized ?? false) ? " [S]" : "")"
|
||||||
|
name.draw(in: CGRect(x: 100, y: textY, width: 200, height: 18), withAttributes: textAttrs)
|
||||||
|
// Column 2: Set
|
||||||
|
let setInfo = "\(card.setCode.uppercased()) #\(card.collectorNumber)"
|
||||||
|
setInfo.draw(at: CGPoint(x: 310, y: textY), withAttributes: textAttrs)
|
||||||
|
// Column 3: Condition (Short code)
|
||||||
|
let cond = card.condition.components(separatedBy: "(").last?.replacingOccurrences(of: ")", with: "") ?? card.condition
|
||||||
|
cond.draw(at: CGPoint(x: 420, y: textY), withAttributes: textAttrs)
|
||||||
|
// Column 4: Value
|
||||||
|
let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))"
|
||||||
|
val.draw(at: CGPoint(x: 520, y: textY), withAttributes: textAttrs)
|
||||||
|
|
||||||
|
pageY += 65
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condensed Summary Section
|
||||||
|
ctx.beginPage()
|
||||||
|
drawHeader()
|
||||||
|
"Condensed Manifest".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
|
||||||
|
pageY = 120
|
||||||
|
|
||||||
|
for card in cards {
|
||||||
|
if pageY > 740 {
|
||||||
|
ctx.beginPage()
|
||||||
|
drawHeader()
|
||||||
|
"Condensed Manifest (Cont.)".draw(at: CGPoint(x: 50, y: 90), withAttributes: subHeaderAttrs)
|
||||||
|
pageY = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = "\(card.name) (\(card.setCode) #\(card.collectorNumber))"
|
||||||
|
line.draw(in: CGRect(x: 50, y: pageY, width: 400, height: 18), withAttributes: textAttrs)
|
||||||
|
|
||||||
|
let val = "\(card.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", card.currentValuation ?? 0.0))"
|
||||||
|
val.draw(at: CGPoint(x: 500, y: pageY), withAttributes: textAttrs)
|
||||||
|
pageY += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Ensure Grand Total doesn't fall off the page
|
||||||
|
if pageY + 40 > 792 {
|
||||||
|
ctx.beginPage()
|
||||||
|
drawHeader()
|
||||||
|
pageY = 90
|
||||||
|
}
|
||||||
|
"GRAND TOTAL: \(cards.first?.currencyCode == "EUR" ? "€" : "$")\(String(format: "%.2f", totalVal))".draw(at: CGPoint(x: 400, y: pageY + 20), withAttributes: subHeaderAttrs)
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
17
IYmtg_App_iOS/Services/Utilities/ReviewEngine.swift
Normal file
17
IYmtg_App_iOS/Services/Utilities/ReviewEngine.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import StoreKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - REVIEW ENGINE
|
||||||
|
class ReviewEngine {
|
||||||
|
static func logScan() {
|
||||||
|
let count = UserDefaults.standard.integer(forKey: "scanCount") + 1
|
||||||
|
UserDefaults.standard.set(count, forKey: "scanCount")
|
||||||
|
if count == 20 || count == 100 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||||
|
SKStoreReviewController.requestReview(in: scene)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
IYmtg_App_iOS/Services/Utilities/StoreEngine.swift
Normal file
55
IYmtg_App_iOS/Services/Utilities/StoreEngine.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class StoreEngine: ObservableObject {
|
||||||
|
@Published var products: [Product] = []
|
||||||
|
@Published var showThankYou = false
|
||||||
|
var transactionListener: Task<Void, Never>? = 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<T>(_ result: VerificationResult<T>) throws -> T {
|
||||||
|
switch result {
|
||||||
|
case .unverified: throw StoreError.failedVerification
|
||||||
|
case .verified(let safe): return safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enum StoreError: Error { case failedVerification }
|
||||||
|
}
|
||||||
131
IYmtg_App_iOS/Services/Vision/CardRecognizer.swift
Normal file
131
IYmtg_App_iOS/Services/Vision/CardRecognizer.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreML
|
||||||
|
|
||||||
|
// MARK: - ANALYSIS ACTOR (Core Card Recognition)
|
||||||
|
actor AnalysisActor {
|
||||||
|
private var database: [CardMetadata] = []
|
||||||
|
private var fingerprintCache: [UUID: VNFeaturePrintObservation] = [:]
|
||||||
|
|
||||||
|
func loadDatabase(from url: URL) throws {
|
||||||
|
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||||
|
let loaded = try JSONDecoder().decode([CardFingerprint].self, from: data)
|
||||||
|
self.fingerprintCache.removeAll()
|
||||||
|
self.database.removeAll()
|
||||||
|
|
||||||
|
for card in loaded {
|
||||||
|
if let obs = try? NSKeyedUnarchiver.unarchivedObject(ofClass: VNFeaturePrintObservation.self, from: card.featureData) {
|
||||||
|
self.fingerprintCache[card.id] = obs
|
||||||
|
}
|
||||||
|
self.database.append(CardMetadata(id: card.id, name: card.name, setCode: card.setCode, collectorNumber: card.collectorNumber, hasFoilPrinting: card.hasFoilPrinting, hasSerializedPrinting: card.hasSerializedPrinting ?? false, priceScanned: card.priceScanned))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyze(croppedImage: CGImage, orientation: CGImagePropertyOrientation) async -> (CardMetadata?, Bool) {
|
||||||
|
guard let print = try? await FeatureMatcher.generateFingerprint(from: croppedImage, orientation: orientation) else { return (nil, false) }
|
||||||
|
let result = FeatureMatcher.identify(scan: print, database: self.database, cache: self.fingerprintCache)
|
||||||
|
|
||||||
|
var resolvedCard: CardMetadata?
|
||||||
|
var detectedSerialized = false
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .exact(let card):
|
||||||
|
resolvedCard = card
|
||||||
|
case .unknown:
|
||||||
|
return (nil, false)
|
||||||
|
case .ambiguous(_, let candidates):
|
||||||
|
let (ocrSet, ocrNum, ocrYear, isSerialized) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation)
|
||||||
|
detectedSerialized = isSerialized
|
||||||
|
|
||||||
|
// Run Heuristics to resolve specific ambiguities
|
||||||
|
let isAlpha = CornerDetector.isAlphaCorner(image: croppedImage, orientation: orientation)
|
||||||
|
let saturation = SaturationDetector.analyze(image: croppedImage, orientation: orientation)
|
||||||
|
let borderColor = BorderDetector.detect(image: croppedImage, orientation: orientation)
|
||||||
|
let hasListSymbol = ListSymbolDetector.hasListSymbol(image: croppedImage, orientation: orientation)
|
||||||
|
let hasStamp = StampDetector.hasStamp(image: croppedImage, orientation: orientation)
|
||||||
|
|
||||||
|
var filtered = candidates
|
||||||
|
|
||||||
|
// 1. Alpha (LEA) vs Beta (LEB)
|
||||||
|
if candidates.contains(where: { $0.setCode == "LEA" }) && candidates.contains(where: { $0.setCode == "LEB" }) {
|
||||||
|
if isAlpha { filtered = filtered.filter { $0.setCode == "LEA" } }
|
||||||
|
else { filtered = filtered.filter { $0.setCode == "LEB" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Unlimited (2ED) vs Revised (3ED)
|
||||||
|
if candidates.contains(where: { $0.setCode == "2ED" }) && candidates.contains(where: { $0.setCode == "3ED" }) {
|
||||||
|
if saturation > 0.25 { filtered = filtered.filter { $0.setCode == "2ED" } }
|
||||||
|
else { filtered = filtered.filter { $0.setCode == "3ED" || $0.setCode == "SUM" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The List / Mystery
|
||||||
|
if hasListSymbol {
|
||||||
|
let listSets = ["PLIST", "MB1", "UPLIST", "H1R"]
|
||||||
|
let listCandidates = filtered.filter { listSets.contains($0.setCode) }
|
||||||
|
if !listCandidates.isEmpty { filtered = listCandidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. World Champ (Gold Border)
|
||||||
|
if borderColor == .gold {
|
||||||
|
let wcCandidates = filtered.filter { $0.setCode.hasPrefix("WC") }
|
||||||
|
if !wcCandidates.isEmpty { filtered = wcCandidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Promo Stamps
|
||||||
|
if hasStamp {
|
||||||
|
let promoCandidates = filtered.filter { $0.setCode.lowercased().hasPrefix("p") }
|
||||||
|
if !promoCandidates.isEmpty { filtered = promoCandidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Chronicles (White Border) vs Originals (Black Border)
|
||||||
|
let chroniclesOriginals = ["ARN", "ATQ", "LEG", "DRK"]
|
||||||
|
if candidates.contains(where: { $0.setCode == "CHR" }) && candidates.contains(where: { chroniclesOriginals.contains($0.setCode) }) {
|
||||||
|
if borderColor == .white { filtered = filtered.filter { $0.setCode == "CHR" } }
|
||||||
|
else if borderColor == .black { filtered = filtered.filter { chroniclesOriginals.contains($0.setCode) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Summer Magic (Edgar) - 1994 Copyright on Revised
|
||||||
|
if let year = ocrYear, year == "1994", candidates.contains(where: { $0.setCode == "3ED" }) {
|
||||||
|
let sumCandidates = filtered.filter { $0.setCode == "SUM" }
|
||||||
|
if !sumCandidates.isEmpty { filtered = sumCandidates }
|
||||||
|
} else if candidates.contains(where: { $0.setCode == "3ED" }) {
|
||||||
|
filtered = filtered.filter { $0.setCode != "SUM" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved: CardMetadata?
|
||||||
|
if let set = ocrSet, let num = ocrNum, let match = filtered.first(where: { $0.setCode.uppercased() == set && $0.collectorNumber == num }) { resolved = match }
|
||||||
|
else if let set = ocrSet, let match = filtered.first(where: { $0.setCode.uppercased() == set }) { resolved = match }
|
||||||
|
else if let set = SetSymbolEngine.recognizeSet(image: croppedImage, orientation: orientation), let match = filtered.first(where: { $0.setCode.caseInsensitiveCompare(set) == .orderedSame }) { resolved = match }
|
||||||
|
else if let set = ClusterEngine.refine(candidates: filtered), let match = filtered.first(where: { $0.setCode == set }) { resolved = match }
|
||||||
|
else { resolved = filtered.first ?? candidates.first }
|
||||||
|
|
||||||
|
resolvedCard = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let card = resolvedCard else { return (nil, false) }
|
||||||
|
|
||||||
|
// DB CHECK: Only run/trust OCR serialization if the card is known to have a serialized printing
|
||||||
|
if card.hasSerializedPrinting {
|
||||||
|
if case .exact = result {
|
||||||
|
let (_, _, _, isSer) = OCREngine.readCardDetails(image: croppedImage, orientation: orientation)
|
||||||
|
detectedSerialized = isSer
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
detectedSerialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (card, detectedSerialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CLUSTER ENGINE (Ambiguity Resolution)
|
||||||
|
class ClusterEngine {
|
||||||
|
static func refine(candidates: [CardMetadata]) -> String? {
|
||||||
|
// Weighted voting: 1st candidate = 3 pts, 2nd = 2 pts, others = 1 pt
|
||||||
|
var scores: [String: Int] = [:]
|
||||||
|
for (index, card) in candidates.prefix(5).enumerated() {
|
||||||
|
let weight = max(1, 3 - index)
|
||||||
|
scores[card.setCode, default: 0] += weight
|
||||||
|
}
|
||||||
|
return scores.sorted { $0.value > $1.value }.first?.key
|
||||||
|
}
|
||||||
|
}
|
||||||
38
IYmtg_App_iOS/Services/Vision/FeatureMatcher.swift
Normal file
38
IYmtg_App_iOS/Services/Vision/FeatureMatcher.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreGraphics
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
class FeatureMatcher {
|
||||||
|
static let revision = VNGenerateImageFeaturePrintRequest.Revision.revision1
|
||||||
|
|
||||||
|
static func generateFingerprint(from image: CGImage, orientation: CGImagePropertyOrientation = .up) async throws -> VNFeaturePrintObservation {
|
||||||
|
let req = VNGenerateImageFeaturePrintRequest()
|
||||||
|
req.revision = revision
|
||||||
|
req.imageCropAndScaleOption = .scaleFill
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
|
||||||
|
try handler.perform([req])
|
||||||
|
|
||||||
|
guard let result = req.results?.first as? VNFeaturePrintObservation else {
|
||||||
|
throw NSError(domain: "FeatureMatcher", code: -1, userInfo: [NSLocalizedDescriptionKey: "No features detected"])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
static func identify(scan: VNFeaturePrintObservation, database: [CardMetadata], cache: [UUID: VNFeaturePrintObservation]) -> MatchResult {
|
||||||
|
var candidates: [(CardMetadata, Float)] = []
|
||||||
|
for card in database {
|
||||||
|
guard let obs = cache[card.id] else { continue }
|
||||||
|
var dist: Float = 0
|
||||||
|
if (try? scan.computeDistance(&dist, to: obs)) != nil && dist < 18.0 {
|
||||||
|
candidates.append((card, dist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sorted = candidates.sorted { $0.1 < $1.1 }
|
||||||
|
guard let best = sorted.first else { return .unknown }
|
||||||
|
|
||||||
|
if best.1 < 6.0 { return .exact(best.0) }
|
||||||
|
let close = sorted.filter { $0.1 < (best.1 + 3.0) }
|
||||||
|
if close.count > 1 { return .ambiguous(name: best.0.name, candidates: close.map { $0.0 }) }
|
||||||
|
return .exact(best.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import CoreImage
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - BORDER DETECTOR
|
||||||
|
class BorderDetector {
|
||||||
|
enum BorderColor { case black, white, gold, other }
|
||||||
|
|
||||||
|
static func detect(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> BorderColor {
|
||||||
|
let context = SharedEngineResources.context
|
||||||
|
let ciImage = CIImage(cgImage: image).oriented(orientation)
|
||||||
|
let width = ciImage.extent.width
|
||||||
|
let height = ciImage.extent.height
|
||||||
|
|
||||||
|
// Crop a small strip from the left edge
|
||||||
|
let cropRect = CGRect(x: CGFloat(width) * 0.02, y: CGFloat(height) * 0.4, width: CGFloat(width) * 0.05, height: CGFloat(height) * 0.2)
|
||||||
|
let vector = CIVector(cgRect: cropRect)
|
||||||
|
let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector])
|
||||||
|
|
||||||
|
guard let output = filter?.outputImage else { return .other }
|
||||||
|
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||||
|
context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||||
|
|
||||||
|
let r = Int(bitmap[0])
|
||||||
|
let g = Int(bitmap[1])
|
||||||
|
let b = Int(bitmap[2])
|
||||||
|
let brightness = (r + g + b) / 3
|
||||||
|
|
||||||
|
// Gold/Yellow detection (World Champ Decks): High Red/Green, Low Blue
|
||||||
|
if r > 140 && g > 120 && b < 100 && r > b + 40 { return .gold }
|
||||||
|
|
||||||
|
if brightness < 60 { return .black }
|
||||||
|
if brightness > 180 { return .white }
|
||||||
|
return .other
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import CoreImage
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - CORNER DETECTOR (Alpha vs Beta)
|
||||||
|
class CornerDetector {
|
||||||
|
static func isAlphaCorner(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool {
|
||||||
|
// Alpha corners are 2mm radius (very round). Beta are 1mm (standard).
|
||||||
|
// We analyze the top-left corner (4% of width).
|
||||||
|
// If significantly more "background" (non-black) pixels exist in the corner square, it's Alpha.
|
||||||
|
|
||||||
|
let context = SharedEngineResources.context
|
||||||
|
let ciImage = CIImage(cgImage: image).oriented(orientation)
|
||||||
|
let width = ciImage.extent.width
|
||||||
|
let height = ciImage.extent.height
|
||||||
|
|
||||||
|
let cornerSize = Int(Double(width) * 0.04)
|
||||||
|
// FIX: Analyze Top-Left corner (y is at top in CIImage coordinates)
|
||||||
|
let cropRect = CGRect(x: 0, y: CGFloat(height) - CGFloat(cornerSize), width: CGFloat(cornerSize), height: CGFloat(cornerSize))
|
||||||
|
|
||||||
|
let cropped = ciImage.cropped(to: cropRect)
|
||||||
|
|
||||||
|
var bitmap = [UInt8](repeating: 0, count: cornerSize * cornerSize * 4)
|
||||||
|
context.render(cropped, toBitmap: &bitmap, rowBytes: cornerSize * 4, bounds: cropRect, format: .RGBA8, colorSpace: nil)
|
||||||
|
|
||||||
|
var backgroundPixelCount = 0
|
||||||
|
let totalPixels = cornerSize * cornerSize
|
||||||
|
|
||||||
|
for i in stride(from: 0, to: bitmap.count, by: 4) {
|
||||||
|
let r = Int(bitmap[i])
|
||||||
|
let g = Int(bitmap[i + 1])
|
||||||
|
let b = Int(bitmap[i + 2])
|
||||||
|
let brightness = (r + g + b) / 3
|
||||||
|
if brightness > 80 { backgroundPixelCount += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpha corners reveal roughly 30-40% background in a tight corner crop. Beta reveals < 20%.
|
||||||
|
return Double(backgroundPixelCount) / Double(totalPixels) > 0.25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import CoreImage
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - LIST SYMBOL DETECTOR
|
||||||
|
class ListSymbolDetector {
|
||||||
|
static func hasListSymbol(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool {
|
||||||
|
// The "List" / "Mystery" symbol is a small white icon in the bottom-left corner.
|
||||||
|
// It sits roughly at x: 3-7%, y: 93-97% of the card frame.
|
||||||
|
|
||||||
|
let context = SharedEngineResources.context
|
||||||
|
let ciImage = CIImage(cgImage: image).oriented(orientation)
|
||||||
|
let width = ciImage.extent.width
|
||||||
|
let height = ciImage.extent.height
|
||||||
|
|
||||||
|
// FIX: CIImage uses Bottom-Left origin. The List symbol is Bottom-Left.
|
||||||
|
let cropRect = CGRect(x: width * 0.03, y: height * 0.03, width: width * 0.05, height: height * 0.04)
|
||||||
|
let vector = CIVector(cgRect: cropRect)
|
||||||
|
|
||||||
|
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]),
|
||||||
|
let output = filter.outputImage else { return false }
|
||||||
|
|
||||||
|
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||||
|
context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||||
|
|
||||||
|
// A white symbol on a black border will significantly raise the average brightness.
|
||||||
|
// Pure black ~ 0-20. With symbol ~ 80-150.
|
||||||
|
let brightness = (Int(bitmap[0]) + Int(bitmap[1]) + Int(bitmap[2])) / 3
|
||||||
|
return brightness > 60
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import CoreImage
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - SATURATION DETECTOR (Unlimited vs Revised)
|
||||||
|
class SaturationDetector {
|
||||||
|
static func analyze(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Double {
|
||||||
|
// Crop to center 50% to analyze artwork saturation, ignoring borders
|
||||||
|
let ciImage = CIImage(cgImage: image).oriented(orientation)
|
||||||
|
let width = ciImage.extent.width
|
||||||
|
let height = ciImage.extent.height
|
||||||
|
let cropRect = CGRect(x: width * 0.25, y: height * 0.25, width: width * 0.5, height: height * 0.5)
|
||||||
|
|
||||||
|
let vector = CIVector(cgRect: cropRect)
|
||||||
|
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: vector]),
|
||||||
|
let output = filter.outputImage else { return 0 }
|
||||||
|
|
||||||
|
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||||
|
let context = SharedEngineResources.context
|
||||||
|
context.render(output, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||||
|
|
||||||
|
// Simple Saturation approximation: (Max - Min) / Max
|
||||||
|
let r = Double(bitmap[0]) / 255.0
|
||||||
|
let g = Double(bitmap[1]) / 255.0
|
||||||
|
let b = Double(bitmap[2]) / 255.0
|
||||||
|
let maxC = max(r, max(g, b))
|
||||||
|
let minC = min(r, min(g, b))
|
||||||
|
|
||||||
|
return maxC == 0 ? 0 : (maxC - minC) / maxC
|
||||||
|
}
|
||||||
|
}
|
||||||
21
IYmtg_App_iOS/Services/Vision/Heuristics/StampDetector.swift
Normal file
21
IYmtg_App_iOS/Services/Vision/Heuristics/StampDetector.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreML
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - STAMP DETECTOR (Promos)
|
||||||
|
class StampDetector {
|
||||||
|
static var model: VNCoreMLModel? = {
|
||||||
|
guard AppConfig.enableStampDetection else { return nil }
|
||||||
|
return ModelManager.shared.getModel(name: "IYmtgStampClassifier")
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func hasStamp(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> Bool {
|
||||||
|
guard let model = model else { return false }
|
||||||
|
let request = VNCoreMLRequest(model: model)
|
||||||
|
request.imageCropAndScaleOption = .scaleFill
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation, options: [:])
|
||||||
|
try? handler.perform([request])
|
||||||
|
guard let results = request.results as? [VNClassificationObservation], let top = results.first else { return false }
|
||||||
|
return top.identifier == "Stamped" && top.confidence > 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
69
IYmtg_App_iOS/Services/Vision/OCREngine.swift
Normal file
69
IYmtg_App_iOS/Services/Vision/OCREngine.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Vision
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
// MARK: - OCR ENGINE
|
||||||
|
class OCREngine {
|
||||||
|
static func readCardDetails(image: CGImage, orientation: CGImagePropertyOrientation = .up) -> (setCode: String?, number: String?, year: String?, isSerialized: Bool) {
|
||||||
|
let request = VNRecognizeTextRequest()
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.usesLanguageCorrection = false
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, orientation: orientation)
|
||||||
|
try? handler.perform([request])
|
||||||
|
guard let obs = request.results as? [VNRecognizedTextObservation] else { return (nil, nil, nil, false) }
|
||||||
|
|
||||||
|
var possibleSetCode: String?
|
||||||
|
var possibleNumber: String?
|
||||||
|
var possibleYear: String?
|
||||||
|
var isSerialized = false
|
||||||
|
|
||||||
|
for observation in obs {
|
||||||
|
guard let candidate = observation.topCandidates(1).first else { continue }
|
||||||
|
let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Set Code: 3-5 chars, uppercase
|
||||||
|
// FIX: Ensure it's in the bottom half to avoid reading Card Name (e.g. "FOG") as Set Code
|
||||||
|
// FIX: Must contain at least one letter to avoid reading years or numbers as Set Codes
|
||||||
|
if text.count >= 3 && text.count <= 5 && text == text.uppercased() && possibleSetCode == nil && text.rangeOfCharacter(from: .letters) != nil {
|
||||||
|
// Vision coordinates: (0,0) is Bottom-Left. y < 0.5 is Bottom Half.
|
||||||
|
// FIX: Tighten to y < 0.2 to match Collector Number and fully exclude Text Box (e.g. "FLY")
|
||||||
|
if observation.boundingBox.origin.y < 0.2 { possibleSetCode = text }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collector Number & Serialized
|
||||||
|
if text.contains("/") && text.count <= 10 {
|
||||||
|
// FILTER: Ignore Power/Toughness in bottom-right corner (x > 0.75, y < 0.2)
|
||||||
|
if observation.boundingBox.origin.x > 0.75 && observation.boundingBox.origin.y < 0.2 { continue }
|
||||||
|
|
||||||
|
// FIX: Tighten standard location to bottom 20% to avoid text box stats (e.g. "10/10" token)
|
||||||
|
let isStandardLocation = observation.boundingBox.origin.y < 0.2
|
||||||
|
|
||||||
|
let parts = text.split(separator: "/")
|
||||||
|
if parts.count == 2 {
|
||||||
|
let numStr = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let denomStr = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let num = Int(numStr), let denom = Int(denomStr) {
|
||||||
|
if isStandardLocation {
|
||||||
|
if denom < 10 { continue }
|
||||||
|
if possibleNumber == nil { possibleNumber = numStr }
|
||||||
|
} else if observation.boundingBox.origin.y > 0.5 {
|
||||||
|
// FIX: Only consider Top Half (Art) as Serialized to avoid Text Box false positives
|
||||||
|
isSerialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if text.count >= 1 && text.count <= 5, let first = text.first, first.isNumber, possibleNumber == nil {
|
||||||
|
// FIX: Only accept simple numbers if they are at the very bottom (Collector Number location)
|
||||||
|
// AND not in the bottom-right corner (Power/Toughness zone)
|
||||||
|
if observation.boundingBox.origin.y < 0.2 && observation.boundingBox.origin.x < 0.75 { possibleNumber = text }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copyright Year (for Summer Magic detection)
|
||||||
|
if let range = text.range(of: #"(19|20)\d{2}"#, options: .regularExpression) {
|
||||||
|
if observation.boundingBox.origin.y < 0.15 {
|
||||||
|
possibleYear = String(text[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (possibleSetCode, possibleNumber, possibleYear, isSerialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
# INSTALL: pip install requests pillow
|
# INSTALL: pip install requests pillow
|
||||||
|
#
|
||||||
|
# SETUP: Replace the email below with your real contact email.
|
||||||
|
# Scryfall requires an accurate User-Agent per their API policy.
|
||||||
|
# See: https://scryfall.com/docs/api
|
||||||
|
CONTACT_EMAIL = "support@iymtg.com" # <-- UPDATE THIS
|
||||||
|
|
||||||
import os, requests, time, concurrent.futures
|
import os, requests, time, concurrent.futures
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
HEADERS = { 'User-Agent': 'IYmtg/1.0 (contact@yourdomain.com)', 'Accept': 'application/json' }
|
HEADERS = { 'User-Agent': f'IYmtg/1.0 ({CONTACT_EMAIL})', 'Accept': 'application/json' }
|
||||||
OUTPUT_DIR = "Set_Symbol_Training"
|
OUTPUT_DIR = "Set_Symbol_Training"
|
||||||
|
|
||||||
|
# Layouts where the set symbol is NOT at the standard M15 position.
|
||||||
|
# Excluding these prevents corrupt/misaligned crops in the training set.
|
||||||
|
EXCLUDED_LAYOUTS = {'transform', 'modal_dfc', 'reversible_card', 'planeswalker', 'saga', 'battle', 'split', 'flip'}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
if CONTACT_EMAIL == "support@iymtg.com":
|
||||||
|
print("⚠️ WARNING: Using default contact email in User-Agent. Update CONTACT_EMAIL at the top of this script.")
|
||||||
|
|
||||||
if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)
|
if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)
|
||||||
print("--- IYmtg Symbol Harvester ---")
|
print("--- IYmtg Symbol Harvester ---")
|
||||||
|
|
||||||
@@ -26,7 +39,8 @@ def main():
|
|||||||
|
|
||||||
print(f"Found {len(target_sets)} valid sets.")
|
print(f"Found {len(target_sets)} valid sets.")
|
||||||
|
|
||||||
# OPTIMIZATION: Process sets in parallel to speed up dataset creation
|
# OPTIMIZATION: Process sets in parallel to speed up dataset creation.
|
||||||
|
# max_workers=5 keeps concurrent requests well within Scryfall's 10 req/s limit.
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
executor.map(lambda s: process_set(s, session), target_sets)
|
executor.map(lambda s: process_set(s, session), target_sets)
|
||||||
|
|
||||||
@@ -40,6 +54,12 @@ def process_set(set_obj, session):
|
|||||||
os.makedirs(set_dir, exist_ok=True)
|
os.makedirs(set_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Filter to standard single-faced cards to guarantee reliable symbol crop coordinates.
|
||||||
|
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints+layout:normal&per_page=5"
|
||||||
|
resp = session.get(url, timeout=10)
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
# No normal-layout cards found; fall back to any card type
|
||||||
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5"
|
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5"
|
||||||
resp = session.get(url, timeout=10)
|
resp = session.get(url, timeout=10)
|
||||||
|
|
||||||
@@ -48,8 +68,13 @@ def process_set(set_obj, session):
|
|||||||
return
|
return
|
||||||
|
|
||||||
cards_resp = resp.json()
|
cards_resp = resp.json()
|
||||||
|
saved = 0
|
||||||
|
|
||||||
for i, card in enumerate(cards_resp.get('data', [])):
|
for i, card in enumerate(cards_resp.get('data', [])):
|
||||||
|
# Skip layouts where the symbol position differs from the standard M15 crop area
|
||||||
|
if card.get('layout', '') in EXCLUDED_LAYOUTS:
|
||||||
|
continue
|
||||||
|
|
||||||
image_url = None
|
image_url = None
|
||||||
uris = {}
|
uris = {}
|
||||||
|
|
||||||
@@ -63,6 +88,8 @@ def process_set(set_obj, session):
|
|||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
try:
|
try:
|
||||||
|
# Brief sleep between image downloads to respect Scryfall rate limits
|
||||||
|
time.sleep(0.05)
|
||||||
img_resp = session.get(image_url, timeout=10)
|
img_resp = session.get(image_url, timeout=10)
|
||||||
if img_resp.status_code != 200: continue
|
if img_resp.status_code != 200: continue
|
||||||
|
|
||||||
@@ -75,16 +102,21 @@ def process_set(set_obj, session):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
# WARNING: This crop area is tuned for modern card frames (M15+).
|
# WARNING: This crop area is tuned for standard M15+ single-faced cards.
|
||||||
# Older sets or special frames (Planeswalkers, Sagas) may require different coordinates.
|
# Excluded layouts (DFC, Planeswalker, Saga, etc.) are filtered above.
|
||||||
crop_area = (width * 0.85, height * 0.58, width * 0.95, height * 0.65)
|
crop_area = (width * 0.85, height * 0.58, width * 0.95, height * 0.65)
|
||||||
|
|
||||||
symbol = img.crop(crop_area)
|
symbol = img.crop(crop_area)
|
||||||
symbol = symbol.convert("RGB")
|
symbol = symbol.convert("RGB")
|
||||||
symbol.save(os.path.join(set_dir, f"sample_{i}.jpg"))
|
symbol.save(os.path.join(set_dir, f"sample_{i}.jpg"))
|
||||||
|
saved += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error downloading image for {set_code}: {e}")
|
print(f"Error downloading image for {set_code}: {e}")
|
||||||
|
|
||||||
|
if saved == 0:
|
||||||
|
print(f"⚠️ No usable images saved for {set_code}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error searching cards for {set_code}: {e}")
|
print(f"Error searching cards for {set_code}: {e}")
|
||||||
|
|
||||||
|
|||||||
168
IYmtg_Automation/generate_images.py
Normal file
168
IYmtg_Automation/generate_images.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Dependency Check
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Error: requests library not found.")
|
||||||
|
print("👉 Please run: pip install requests pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
print("❌ Error: Pillow library not found.")
|
||||||
|
print("👉 Please run: pip install requests pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CONFIGURATION — Set your Gemini API key here
|
||||||
|
# ============================================================
|
||||||
|
GEMINI_API_KEY = "YOUR_GEMINI_API_KEY_HERE"
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
BASE_DIR = os.path.dirname(SCRIPT_DIR)
|
||||||
|
PROMPTS_FILE = os.path.join(SCRIPT_DIR, "image_generation_prompts.json")
|
||||||
|
RAW_ASSETS_DIR = os.path.join(BASE_DIR, "Raw_Assets")
|
||||||
|
RESIZE_SCRIPT = os.path.join(SCRIPT_DIR, "resize_assets.py")
|
||||||
|
|
||||||
|
# Gemini Imagen API endpoint
|
||||||
|
IMAGEN_MODEL = "imagen-3.0-generate-001"
|
||||||
|
IMAGEN_URL = (
|
||||||
|
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||||
|
f"{IMAGEN_MODEL}:predict?key={GEMINI_API_KEY}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompts():
|
||||||
|
"""Load image prompts from the JSON config file."""
|
||||||
|
if not os.path.exists(PROMPTS_FILE):
|
||||||
|
print(f"❌ Error: Prompts file not found: {PROMPTS_FILE}")
|
||||||
|
sys.exit(1)
|
||||||
|
with open(PROMPTS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("images", [])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(name, prompt):
|
||||||
|
"""Call the Gemini Imagen API and return raw PNG bytes, or None on failure."""
|
||||||
|
payload = {
|
||||||
|
"instances": [{"prompt": prompt}],
|
||||||
|
"parameters": {"sampleCount": 1},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.post(IMAGEN_URL, json=payload, timeout=120)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f" ❌ HTTP error for '{name}': {e}")
|
||||||
|
print(f" Response: {response.text[:300]}")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f" ❌ Request failed for '{name}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
predictions = result.get("predictions", [])
|
||||||
|
if not predictions:
|
||||||
|
print(f" ❌ No predictions returned for '{name}'.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
b64_data = predictions[0].get("bytesBase64Encoded")
|
||||||
|
if not b64_data:
|
||||||
|
print(f" ❌ No image data in response for '{name}'.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return base64.b64decode(b64_data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(name, image_bytes):
|
||||||
|
"""Save raw bytes as a PNG file in Raw_Assets/."""
|
||||||
|
output_path = os.path.join(RAW_ASSETS_DIR, f"{name}.png")
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_resize_script():
|
||||||
|
"""Run resize_assets.py as a subprocess."""
|
||||||
|
print("\n🔧 Running resize_assets.py...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, RESIZE_SCRIPT],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
print(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
print(result.stderr)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"❌ resize_assets.py failed: {e}")
|
||||||
|
if e.stdout:
|
||||||
|
print(e.stdout)
|
||||||
|
if e.stderr:
|
||||||
|
print(e.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("🚀 IYmtg Image Generation Pipeline")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
if GEMINI_API_KEY == "YOUR_GEMINI_API_KEY_HERE":
|
||||||
|
print("❌ Error: Gemini API key not set.")
|
||||||
|
print("👉 Open this script and set GEMINI_API_KEY at the top.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Ensure Raw_Assets directory exists
|
||||||
|
os.makedirs(RAW_ASSETS_DIR, exist_ok=True)
|
||||||
|
print(f"📂 Output directory: {RAW_ASSETS_DIR}\n")
|
||||||
|
|
||||||
|
prompts = load_prompts()
|
||||||
|
print(f"📋 Loaded {len(prompts)} image prompt(s) from {os.path.basename(PROMPTS_FILE)}\n")
|
||||||
|
|
||||||
|
generated = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for item in prompts:
|
||||||
|
name = item.get("name")
|
||||||
|
prompt = item.get("prompt")
|
||||||
|
|
||||||
|
if not name or not prompt:
|
||||||
|
print(f"⚠️ Skipping entry with missing 'name' or 'prompt': {item}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"🎨 Generating: {name}")
|
||||||
|
print(f" Prompt: {prompt[:80]}{'...' if len(prompt) > 80 else ''}")
|
||||||
|
|
||||||
|
image_bytes = generate_image(name, prompt)
|
||||||
|
if image_bytes is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = save_image(name, image_bytes)
|
||||||
|
print(f" ✅ Saved: {os.path.basename(path)}")
|
||||||
|
generated += 1
|
||||||
|
except OSError as e:
|
||||||
|
print(f" ❌ Failed to save '{name}': {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{'=' * 40}")
|
||||||
|
print(f"✅ Generated: {generated} | ❌ Failed: {failed}")
|
||||||
|
|
||||||
|
if generated > 0:
|
||||||
|
run_resize_script()
|
||||||
|
else:
|
||||||
|
print("\n⚠️ No images were generated. Skipping resize step.")
|
||||||
|
|
||||||
|
print("\n✅ Done.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import os
|
import os, sys, argparse
|
||||||
import sys
|
|
||||||
|
|
||||||
# Dependency Check
|
# Dependency Check
|
||||||
try:
|
try:
|
||||||
@@ -22,10 +21,27 @@ ASSETS = {
|
|||||||
"scanner_frame": (600, 800),
|
"scanner_frame": (600, 800),
|
||||||
"empty_library": (800, 800),
|
"empty_library": (800, 800),
|
||||||
"share_watermark": (400, 100),
|
"share_watermark": (400, 100),
|
||||||
"card_placeholder": (600, 840)
|
"card_placeholder": (600, 840),
|
||||||
}
|
}
|
||||||
|
|
||||||
def process_assets():
|
# Assets whose target aspect ratio differs significantly from a typical AI square output.
|
||||||
|
# These use thumbnail+pad (letterbox) instead of center-crop to preserve full image content.
|
||||||
|
PAD_ASSETS = {"logo_header", "share_watermark"}
|
||||||
|
|
||||||
|
def resize_fit(img, size):
|
||||||
|
"""Center-crop and scale to exact size. Best when source matches target aspect ratio."""
|
||||||
|
return ImageOps.fit(img, size, method=Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
def resize_pad(img, size, bg_color=(0, 0, 0, 0)):
|
||||||
|
"""Scale to fit within size, then pad with bg_color to reach exact dimensions.
|
||||||
|
Preserves the full image — nothing is cropped. Ideal for logos and banners."""
|
||||||
|
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||||
|
canvas = Image.new("RGBA", size, bg_color)
|
||||||
|
offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2)
|
||||||
|
canvas.paste(img, offset)
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
def process_assets(force_pad=False, force_crop=False):
|
||||||
print(f"📂 Working Directory: {BASE_DIR}")
|
print(f"📂 Working Directory: {BASE_DIR}")
|
||||||
|
|
||||||
# Create directories if they don't exist
|
# Create directories if they don't exist
|
||||||
@@ -51,12 +67,20 @@ def process_assets():
|
|||||||
img = Image.open(source_path)
|
img = Image.open(source_path)
|
||||||
if img.mode != 'RGBA': img = img.convert('RGBA')
|
if img.mode != 'RGBA': img = img.convert('RGBA')
|
||||||
|
|
||||||
# Smart Crop & Resize (Center focus)
|
# Choose resize strategy:
|
||||||
processed_img = ImageOps.fit(img, size, method=Image.Resampling.LANCZOS)
|
# - PAD_ASSETS (and --pad flag) use thumbnail+letterbox to avoid cropping banner images.
|
||||||
|
# - Everything else uses center-crop (ImageOps.fit) for a clean full-bleed fill.
|
||||||
|
use_pad = (name in PAD_ASSETS or force_pad) and not force_crop
|
||||||
|
if use_pad:
|
||||||
|
processed_img = resize_pad(img, size)
|
||||||
|
mode_label = "pad"
|
||||||
|
else:
|
||||||
|
processed_img = resize_fit(img, size)
|
||||||
|
mode_label = "crop"
|
||||||
|
|
||||||
output_path = os.path.join(OUTPUT_DIR, name + ".png")
|
output_path = os.path.join(OUTPUT_DIR, name + ".png")
|
||||||
processed_img.save(output_path, "PNG")
|
processed_img.save(output_path, "PNG")
|
||||||
print(f"✅ Generated: {name}.png ({size[0]}x{size[1]})")
|
print(f"✅ Generated: {name}.png ({size[0]}x{size[1]}, {mode_label})")
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -66,4 +90,9 @@ def process_assets():
|
|||||||
print(f"⚠️ Skipped: {name} (File not found in Raw_Assets)")
|
print(f"⚠️ Skipped: {name} (File not found in Raw_Assets)")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
process_assets()
|
parser = argparse.ArgumentParser(description="Resize raw AI assets to Xcode-ready dimensions.")
|
||||||
|
group = parser.add_mutually_exclusive_group()
|
||||||
|
group.add_argument("--pad", action="store_true", help="Force thumbnail+pad for ALL assets (no cropping).")
|
||||||
|
group.add_argument("--crop", action="store_true", help="Force center-crop for ALL assets (overrides default pad assets).")
|
||||||
|
args = parser.parse_args()
|
||||||
|
process_assets(force_pad=args.pad, force_crop=args.crop)
|
||||||
|
|||||||
660
README.md
660
README.md
@@ -1,4 +1,4 @@
|
|||||||
# IYmtg Platinum Prime (Version 1.0.0)
|
# IYmtg Platinum Prime (Version 1.1.0)
|
||||||
**SYSTEM CONTEXT FOR AI (STRICT PRESERVATION)**
|
**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.
|
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.
|
||||||
|
|
||||||
@@ -6,6 +6,35 @@ CRITICAL INSTRUCTION: This document is the single, authoritative Source of Truth
|
|||||||
* **Architecture Mandate:** Any future updates must strictly adhere to the defined pipeline: Vector Fingerprinting (Identity) -> OCR (Validation) -> ML Analysis (Condition/Foil).
|
* **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.
|
* **Preservation Protocol:** Do not summarize, truncate, or remove sections of this manual during review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Roadmap
|
||||||
|
|
||||||
|
This is the complete sequence of steps to go from source code to a working app. Complete them in order.
|
||||||
|
|
||||||
|
| Step | Task | Platform | Status |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| 1 | Workspace setup & visual assets | Any | — |
|
||||||
|
| 2 | **Build `IYmtg_Builder_Mac`** | Mac only | ⚠️ Not written yet |
|
||||||
|
| 3 | **Generate `cards.json`** database | Mac only | ⚠️ Depends on Step 2 |
|
||||||
|
| 4 | **Create Xcode project** from source | Mac only | — |
|
||||||
|
| 5 | Collect ML training images | Any (physical cards) | — |
|
||||||
|
| 6 | Train ML models in Create ML | Mac only | — |
|
||||||
|
| 7 | Configure Firebase (optional) | Any | — |
|
||||||
|
| 8 | Final configuration & testing | Mac only | — |
|
||||||
|
| 9 | App Store submission | Mac only | — |
|
||||||
|
|
||||||
|
### What You Can Do Without a Mac
|
||||||
|
* Edit source code
|
||||||
|
* Run Python automation scripts (`fetch_set_symbols.py`, `generate_images.py`)
|
||||||
|
* Collect and sort ML training images into `IYmtg_Training/`
|
||||||
|
* Acquire physical cards from the shopping lists
|
||||||
|
|
||||||
|
### What Requires a Mac
|
||||||
|
Everything else. Apple's `Vision` framework (used to generate card fingerprints) and `Create ML` (used to train models) are macOS-only. The Xcode project also lives on your Mac.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Part 1: App Store Listing
|
## Part 1: App Store Listing
|
||||||
|
|
||||||
### 1. Metadata
|
### 1. Metadata
|
||||||
@@ -44,22 +73,24 @@ Help us make IYmtg smarter! If you find a card that scans incorrectly, you can c
|
|||||||
* **Market Data:** Switch between major pricing sources (TCGPlayer & Cardmarket).
|
* **Market Data:** Switch between major pricing sources (TCGPlayer & Cardmarket).
|
||||||
* **Export Options:** Also supports CSV and digital deck formats for other uses.
|
* **Export Options:** Also supports CSV and digital deck formats for other uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Part 2: Workspace & Assets
|
## Part 2: Workspace & Assets
|
||||||
|
|
||||||
### Step 1: Workspace Setup
|
### Step 1: Workspace Setup
|
||||||
1. Create the master folder on your Desktop.
|
1. Create the master folder in your preferred location (Desktop, OneDrive, or any synced drive).
|
||||||
2. Right-click -> Sync with Google Drive (Critical for backups).
|
2. Ensure the folder is synced with a cloud backup service (OneDrive, Google Drive, iCloud Drive, etc.).
|
||||||
3. Organize your sub-folders exactly as shown below:
|
3. Organize your sub-folders exactly as shown below:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IYmtg_Master/
|
IYmtg_Master/
|
||||||
├── IYmtg_App_iOS/ (The Xcode Project)
|
├── IYmtg_App_iOS/ (The iOS App Source Code)
|
||||||
├── IYmtg_Builder_Mac/ (The Database Builder)
|
├── IYmtg_Builder_Mac/ (The Card Database Builder — Mac app)
|
||||||
├── IYmtg_Training/ (ML Image Data)
|
├── IYmtg_Training/ (ML Image Data)
|
||||||
└── IYmtg_Automation/ (Python/Shell Scripts)
|
└── IYmtg_Automation/ (Python/Shell Scripts)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Visual Assets
|
### Step 2: Visual Assets
|
||||||
|
|
||||||
Place the following assets in `Assets.xcassets` in the Xcode project.
|
Place the following assets in `Assets.xcassets` in the Xcode project.
|
||||||
|
|
||||||
@@ -74,25 +105,253 @@ Place the following assets in `Assets.xcassets` in the Xcode project.
|
|||||||
| **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." |
|
| **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." |
|
| **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
|
#### Automated Generation (Recommended)
|
||||||
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`.
|
**Setup:**
|
||||||
2. **Folders:** Run the script once to generate the `Raw_Assets` and `Ready_Assets` folders in `IYmtg_Master`.
|
1. **Get a Gemini API Key:** You will need an API key from Google AI Studio.
|
||||||
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.
|
2. **Set the API Key:** Open `IYmtg_Automation/generate_images.py` and set your API key in the configuration section.
|
||||||
|
3. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install requests pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python3 IYmtg_Automation/generate_images.py
|
||||||
|
```
|
||||||
|
The generated images will be saved in `Raw_Assets` and resized images in `Ready_Assets`.
|
||||||
|
|
||||||
|
#### Manual Resizing (If You Already Have Images)
|
||||||
|
|
||||||
|
1. **Setup:** Ensure Python is installed and run `pip install Pillow`.
|
||||||
|
2. **Generate Placeholders (Optional):**
|
||||||
```bash
|
```bash
|
||||||
python3 IYmtg_Automation/generate_placeholders.py
|
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`).
|
3. **Place Images:** Save your real AI results into `Raw_Assets`, named exactly as listed above (e.g., `AppIcon.png`).
|
||||||
5. **Run:** Execute the resize script:
|
4. **Run:**
|
||||||
```bash
|
```bash
|
||||||
python3 IYmtg_Automation/resize_assets.py
|
python3 IYmtg_Automation/resize_assets.py
|
||||||
```
|
```
|
||||||
6. **Result:** Your Xcode-ready images will be in `Ready_Assets`. Drag them into `Assets.xcassets`.
|
5. **Result:** Xcode-ready images will be in `Ready_Assets`. Drag them into `Assets.xcassets`.
|
||||||
|
|
||||||
## 3. Machine Learning Training
|
---
|
||||||
|
|
||||||
### **General Data Collection Protocol (CRITICAL)**
|
## Part 3: Card Database (`cards.json`) — Mac Required
|
||||||
|
|
||||||
|
**This is the most critical file in the project.** The app cannot identify any cards without it. It is a JSON file bundled inside the app containing a fingerprint (a mathematical representation) of every Magic card, generated from card images using Apple's Vision framework.
|
||||||
|
|
||||||
|
### What `cards.json` Contains
|
||||||
|
Each entry in the file represents one unique card printing and contains:
|
||||||
|
- Card name, set code, and collector number
|
||||||
|
- Whether the card has a foil or serialized printing
|
||||||
|
- Pricing data
|
||||||
|
- A `VNFeaturePrintObservation` (binary blob) — the visual fingerprint used for identification
|
||||||
|
|
||||||
|
### Step 1: Write `IYmtg_Builder_Mac`
|
||||||
|
|
||||||
|
`IYmtg_Builder_Mac/` is currently **empty**. This Mac command-line tool needs to be built before `cards.json` can be generated. It must:
|
||||||
|
|
||||||
|
1. Fetch the complete card list from the Scryfall API (`https://api.scryfall.com/bulk-data` → `default_cards` dataset)
|
||||||
|
2. Download a card image for each unique printing
|
||||||
|
3. Run `VNGenerateImageFeaturePrintRequest` (Apple Vision) on each image to produce a fingerprint
|
||||||
|
4. Archive the fingerprint using `NSKeyedArchiver` into a `Data` blob
|
||||||
|
5. Write all entries to `cards.json` using the `CardFingerprint` model defined in `IYmtg_App_iOS/Data/Models/Card.swift`
|
||||||
|
6. Place the output at `IYmtg_App_iOS/cards.json`
|
||||||
|
|
||||||
|
**Data model reference** (`CardFingerprint` in `IYmtg_App_iOS/Data/Models/Card.swift`):
|
||||||
|
```swift
|
||||||
|
struct CardFingerprint: Codable {
|
||||||
|
let id: UUID
|
||||||
|
let name: String
|
||||||
|
let setCode: String
|
||||||
|
let collectorNumber: String
|
||||||
|
let hasFoilPrinting: Bool
|
||||||
|
let hasSerializedPrinting: Bool?
|
||||||
|
let priceScanned: Double?
|
||||||
|
let featureData: Data // NSKeyedArchiver-encoded VNFeaturePrintObservation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**To build `IYmtg_Builder_Mac`:** Give this README section to an AI (Claude or Gemini) along with the `CardFingerprint` struct and ask it to write a Swift command-line tool for macOS. The tool is straightforward — it is a single-purpose script that runs once and can take several hours to complete due to the volume of Scryfall image downloads.
|
||||||
|
|
||||||
|
### Step 2: Run the Builder
|
||||||
|
|
||||||
|
On your Mac, build and run `IYmtg_Builder_Mac`. The `weekly_update.sh` script automates this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x IYmtg_Automation/weekly_update.sh
|
||||||
|
./IYmtg_Automation/weekly_update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Builds the builder app using `xcodebuild`
|
||||||
|
2. Runs it (this takes time — Scryfall has ~100,000+ card printings)
|
||||||
|
3. Moves the output `cards.json` into `IYmtg_App_iOS/` ready to be bundled
|
||||||
|
|
||||||
|
**Run this script periodically** (e.g., weekly) to pick up newly released sets.
|
||||||
|
|
||||||
|
### Step 3: Add `cards.json` to Xcode
|
||||||
|
|
||||||
|
After the builder runs, `cards.json` will be at `IYmtg_App_iOS/cards.json`.
|
||||||
|
|
||||||
|
In Xcode:
|
||||||
|
1. Drag `cards.json` into the project navigator under `IYmtg_App_iOS/`
|
||||||
|
2. Ensure **"Add to target: IYmtg"** is checked so it is bundled inside the app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4: Xcode Project Setup — Mac Required
|
||||||
|
|
||||||
|
The source code in `IYmtg_App_iOS/` is complete. Follow these steps to create the Xcode project on your Mac.
|
||||||
|
|
||||||
|
### Step 1: Create the Project
|
||||||
|
|
||||||
|
1. Open Xcode → **File → New → Project**
|
||||||
|
2. Choose **iOS → App**
|
||||||
|
3. Set:
|
||||||
|
- **Product Name:** IYmtg
|
||||||
|
- **Bundle Identifier:** `com.<yourname>.iymtg` (you choose this — note it down, you'll need it for iCloud)
|
||||||
|
- **Interface:** SwiftUI
|
||||||
|
- **Language:** Swift
|
||||||
|
- **Storage:** None (we use SwiftData manually)
|
||||||
|
4. Save the project **inside** `IYmtg_App_iOS/` — this places the `.xcodeproj` alongside the source files.
|
||||||
|
|
||||||
|
### Step 2: Add Source Files
|
||||||
|
|
||||||
|
1. In the Xcode project navigator, right-click the `IYmtg` group → **Add Files to "IYmtg"**
|
||||||
|
2. Select all folders inside `IYmtg_App_iOS/`:
|
||||||
|
- `Application/`
|
||||||
|
- `Data/`
|
||||||
|
- `Features/`
|
||||||
|
- `Services/`
|
||||||
|
- `Firebase/`
|
||||||
|
- `AppConfig.swift`
|
||||||
|
- `ContentView.swift`
|
||||||
|
- `IYmtgApp.swift`
|
||||||
|
- `IYmtgTests.swift`
|
||||||
|
- `cards.json` (once generated)
|
||||||
|
3. Ensure **"Copy items if needed"** is **unchecked** (files are already in the right place) and **"Create groups"** is selected.
|
||||||
|
|
||||||
|
### Step 3: Add Dependencies (Swift Package Manager)
|
||||||
|
|
||||||
|
1. **File → Add Package Dependencies**
|
||||||
|
2. Add the Firebase iOS SDK:
|
||||||
|
- URL: `https://github.com/firebase/firebase-ios-sdk`
|
||||||
|
- Add these libraries to your target: `FirebaseCore`, `FirebaseFirestore`, `FirebaseAuth`, `FirebaseStorage`
|
||||||
|
|
||||||
|
### Step 4: Configure Signing & Capabilities
|
||||||
|
|
||||||
|
1. Select the project in the navigator → **Signing & Capabilities** tab
|
||||||
|
2. Set your **Team** and ensure **Automatically manage signing** is on
|
||||||
|
3. Set **Minimum Deployments** to **iOS 17.0**
|
||||||
|
4. Click **+ Capability** and add:
|
||||||
|
- **iCloud** → enable **CloudKit** → add container `iCloud.<your-bundle-id>`
|
||||||
|
- **Background Modes** → enable **Remote notifications**
|
||||||
|
5. Lock orientation: **General** tab → **Deployment Info** → uncheck Landscape Left and Landscape Right
|
||||||
|
|
||||||
|
### Step 5: Add Privacy Descriptions
|
||||||
|
|
||||||
|
In `Info.plist`, add these keys (Xcode will prompt on first run, but adding them manually avoids rejection):
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `NSCameraUsageDescription` | `IYmtg uses the camera to scan and identify Magic: The Gathering cards.` |
|
||||||
|
| `NSPhotoLibraryUsageDescription` | `IYmtg can save card images to your photo library.` |
|
||||||
|
|
||||||
|
Also add `PrivacyInfo.xcprivacy` to the app target to satisfy Apple's privacy manifest requirements (required for App Store submission as of 2024).
|
||||||
|
|
||||||
|
### Step 6: Build and Run
|
||||||
|
|
||||||
|
1. Select a connected physical iPhone as the build target (camera features do not work in the simulator)
|
||||||
|
2. Press **Cmd+R** to build and run
|
||||||
|
3. On first launch the app will show **"Database Missing"** until `cards.json` is bundled (see Part 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5: Machine Learning Training — Mac Required for Final Step
|
||||||
|
|
||||||
|
> **In-App Training Guide (v1.1.0+):** The Library tab now includes a "?" button that opens a color-coded Training Guide. It shows every ML category with recommended image counts for three accuracy levels (Functional / Solid / High-Accuracy), so you can track collection progress directly from the app.
|
||||||
|
|
||||||
|
**You do not need the app, Xcode, or a Mac to collect training images.** All you need is physical cards and a phone camera. The only Mac-required step is the final model training in Create ML (Step 6).
|
||||||
|
|
||||||
|
> **The app ships and works without any trained models.** Foil detection defaults to "None" and condition defaults to "NM". You can release a working app first and add models later via OTA update. Do not let missing training data block your first build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 0: How to Create Training Images (No App Required)
|
||||||
|
|
||||||
|
This is the complete workflow for preparing training data on any computer.
|
||||||
|
|
||||||
|
#### What You Need
|
||||||
|
- Physical Magic cards (see shopping lists in Steps 1–3)
|
||||||
|
- A phone with a decent camera (iPhone, Android — anything works)
|
||||||
|
- A plain background: white card stock or black felt works best
|
||||||
|
- A free photo cropping tool:
|
||||||
|
- **Windows:** Photos app (built-in crop) or Paint
|
||||||
|
- **Any platform:** [Squoosh](https://squoosh.app) (browser-based, free, no install)
|
||||||
|
- **Bulk cropping:** [IrfanView](https://www.irfanview.com) (Windows, free) or [XnConvert](https://www.xnview.com/en/xnconvert/) (cross-platform, free)
|
||||||
|
|
||||||
|
#### Photography Setup
|
||||||
|
|
||||||
|
**For Foil Cards:**
|
||||||
|
1. Place the card on a **black background** (black felt or black paper).
|
||||||
|
2. Use a single directional light source — a desk lamp or window at 45°.
|
||||||
|
3. Take **5 photos of the same card** rotating it slightly between each shot so the light catches the foil at different angles. The AI must learn how the foil *moves*, not just how it looks flat.
|
||||||
|
4. Example angles: flat-on, 15° left tilt, 15° right tilt, 15° top tilt, 15° bottom tilt.
|
||||||
|
|
||||||
|
**For Non-Foil Cards:**
|
||||||
|
1. Place on **white or grey background**.
|
||||||
|
2. Even, diffused lighting (avoid strong reflections on the surface).
|
||||||
|
3. 1–3 photos per card is sufficient.
|
||||||
|
|
||||||
|
**For Condition/Damage:**
|
||||||
|
1. Use **raking light** (light source almost parallel to the card surface) — this casts shadows that highlight scratches, dents, and bends far more clearly than direct light.
|
||||||
|
2. For edge whitening: photograph against a **black background**.
|
||||||
|
3. For chipping: photograph against a **white background**.
|
||||||
|
4. Take a close-up — fill the frame with the card.
|
||||||
|
|
||||||
|
#### Cropping Rule — Critical
|
||||||
|
The app scans **cropped card images only** (no table, no background, no hand visible). Your training images must match this exactly or the model will learn the wrong thing.
|
||||||
|
|
||||||
|
After photographing:
|
||||||
|
1. Open the photo in your cropping tool.
|
||||||
|
2. Crop tightly to the card border — include the full card frame but nothing outside it.
|
||||||
|
3. It does not need to be pixel-perfect. Within 5–10px of the edge is fine.
|
||||||
|
4. Save as `.jpg` at any reasonable resolution (at least 400×560px).
|
||||||
|
|
||||||
|
#### Naming and Sorting
|
||||||
|
File names do not matter — only the **folder** they are in matters. Save cropped images directly into the appropriate `IYmtg_Training/` subfolder:
|
||||||
|
|
||||||
|
```
|
||||||
|
IYmtg_Training/Foil_Data/Etched/ ← drop etched foil photos here
|
||||||
|
IYmtg_Training/Foil_Data/Traditional/ ← drop traditional foil photos here
|
||||||
|
IYmtg_Training/Condition_Data/Edges/Whitening/ ← drop edge whitening photos here
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How Many Images Do You Need?
|
||||||
|
| Goal | Minimum | Recommended |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| Test that training works | 10 per class | — |
|
||||||
|
| Functional model, limited accuracy | 20 per class | — |
|
||||||
|
| Solid production model | 30–50 per class | 50+ per class |
|
||||||
|
| High-accuracy model | — | 100+ per class |
|
||||||
|
|
||||||
|
More is always better. Variety matters more than quantity — different cards, different lighting, different tilt angles.
|
||||||
|
|
||||||
|
#### Using Scryfall Images as a Supplement
|
||||||
|
For **NonFoil** training data you can download card images directly from Scryfall instead of photographing them. This is automated — run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests pillow
|
||||||
|
python3 IYmtg_Automation/fetch_set_symbols.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads and crops set symbol images automatically. For general NonFoil card images, you can query the Scryfall API directly (`https://api.scryfall.com/cards/random`) and download the `normal` image URI. Downloaded Scryfall images are already cropped to the card frame and work well as NonFoil training data. Do not use Scryfall images for foil or damage training — they are flat renders with no foil or physical damage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### General Data Collection Protocol (Critical)
|
||||||
The app sends **cropped** images (just the card, no background) to the AI. Your training data must match this.
|
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.
|
1. **Capture:** Take photos of the card on a contrasting background.
|
||||||
@@ -111,15 +370,16 @@ Acquire one of each (~$50 total) to train the Foil Classifier. This ensures the
|
|||||||
| **Etched** | Harmonize (Strixhaven Archive) | Metallic, grainy texture, matte finish, no rainbow. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Galaxy** | Command Performance (Unfinity) | Embedded "stars" or sparkles in the foil pattern. |
|
||||||
| **Surge** | Explore (Warhammer 40k) | Rippling "wave" pattern across the entire card. |
|
| **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. |
|
| **Oil Slick** | Basic Land (Phyrexia: ONE - Compleat) | Raised, slick black-on-black texture, high contrast. |
|
||||||
|
| **Step and Compleat** | Elesh Norn (Phyrexia: ONE Showcase) | Phyrexian oil-slick effect on the card frame; black-silver high contrast. |
|
||||||
| **Confetti** | Negate (Wilds of Eldraine - Confetti) | Glittering "confetti" sparkles scattered on art. |
|
| **Confetti** | Negate (Wilds of Eldraine - Confetti) | Glittering "confetti" sparkles scattered on art. |
|
||||||
| **Halo** | Uncommon Legend (MOM: Multiverse) | Swirling circular pattern around the frame. |
|
| **Halo** | Uncommon Legend (MOM: Multiverse) | Swirling circular pattern around the frame. |
|
||||||
| **Neon Ink** | Hidetsugu (Neon Yellow) | Bright, fluorescent ink layer on top of foil. |
|
| **Neon Ink** | Hidetsugu (Neon Yellow) | Bright, fluorescent ink layer on top of foil. |
|
||||||
| **Fracture** | Enduring Vitality (Duskmourn Japan) | Shattered glass pattern, highly reflective. |
|
| **Fracture** | Enduring Vitality (Duskmourn Japan) | Shattered glass pattern, highly reflective. |
|
||||||
|
| **Gilded** *(low priority)* | Riveteers Charm (New Capenna) | Embossed gold frame elements, glossy raised texture. Training folder not yet created. |
|
||||||
|
| **Silver Screen** *(low priority)* | Otherworldly Gaze (Double Feature) | Grayscale art with silver metallic highlights. Single-set type — deprioritized. |
|
||||||
|
|
||||||
### Step 2: The Stamp Classifier Shopping List
|
### 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".
|
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".
|
||||||
@@ -130,23 +390,95 @@ Acquire pairs of cards to train the `StampDetector` (Promo/Date Stamped vs. Regu
|
|||||||
* **Action:** Place cropped images of promos in `Stamp_Data/Stamped` and regular versions in `Stamp_Data/Clean`.
|
* **Action:** Place cropped images of promos in `Stamp_Data/Stamped` and regular versions in `Stamp_Data/Clean`.
|
||||||
|
|
||||||
### Step 3: The "Damage Simulation Lab"
|
### 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) |
|
#### Important: This Model Uses Object Detection, Not Image Classification
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **Surface** | Light Scratches | Rub foil surface gently with 0000 Steel Wool. | Use flash or moving light source to catch glint. |
|
The `Condition_Data` model is trained as an **Object Detection** model, not an Image Classification model. This is a critical difference:
|
||||||
| **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. |
|
- **Image Classification** (used for Foil and Stamp): drop images in a folder, Create ML labels them by folder name. Simple.
|
||||||
| **Surface** | Dents | Press a ballpoint pen cap firmly into the surface. | **Raking Light:** Light from side to cast shadows. |
|
- **Object Detection** (used for Condition): you must **draw bounding boxes** around each defect in Create ML. The model learns *where* damage is on the card, not just that damage exists.
|
||||||
| **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**. |
|
When training in Create ML, you will annotate each training image by drawing a rectangle around the damaged area and labeling it (e.g., "LightScratches", "Whitening"). Create ML has a built-in annotation tool — click an image, draw a box, type the label.
|
||||||
| **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. |
|
**Folder naming maps directly to label names.** The labels must match the `Condition_Data` subfolder names exactly:
|
||||||
| **Structure** | Shuffle Bend | Riffle shuffle aggressively to create an arch. | Profile view (side view) to show curvature. |
|
`LightScratches`, `Clouding`, `Dirt`, `Dents`, `Whitening`, `Chipping`, `CornerWear`, `Creases`, `ShuffleBend`, `WaterDamage`, `Inking`, `Rips`, `BindersDents`
|
||||||
| **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. |
|
#### How the Grading Formula Works
|
||||||
| **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. |
|
Understanding this helps you know what training data matters most. The app grades cards as follows (from `ConditionEngine.swift`):
|
||||||
|
|
||||||
|
| Detected Damage | Grade Assigned |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Any `Inking`, `Rips`, or `WaterDamage` detected | **Damaged** — immediately, regardless of anything else |
|
||||||
|
| 0 damage detections | **Near Mint (NM)** |
|
||||||
|
| 1–2 minor damage detections | **Excellent (EX)** |
|
||||||
|
| 3 or more minor damage detections | **Played (PL)** |
|
||||||
|
|
||||||
|
**Critical damage types** (`Inking`, `Rips`, `WaterDamage`) are the highest training priority — a single false positive will incorrectly grade a NM card as Damaged.
|
||||||
|
|
||||||
|
#### Materials List
|
||||||
|
|
||||||
|
| Item | Used For |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 0000 (ultra-fine) steel wool | Surface scratches on foil cards |
|
||||||
|
| White vinyl eraser | Clouding/surface haze |
|
||||||
|
| Potting soil or cocoa powder | Dirt simulation |
|
||||||
|
| Ballpoint pen cap (rounded end) | Dents |
|
||||||
|
| Black Sharpie marker | Inking simulation |
|
||||||
|
| Spray bottle with water | Water damage |
|
||||||
|
| 3-ring binder | Binder dents |
|
||||||
|
| Rough mousepad or sandpaper | Corner wear |
|
||||||
|
| 50–100 bulk "draft chaff" cards | Cards to damage |
|
||||||
|
| Black felt or black paper | Background for edge photos |
|
||||||
|
| White card stock | Background for chipping photos |
|
||||||
|
| Desk lamp | Raking light source |
|
||||||
|
|
||||||
|
#### Sourcing Cards to Damage
|
||||||
|
|
||||||
|
Buy bulk worthless cards — do not damage your own collection.
|
||||||
|
- **eBay:** Search "MTG bulk commons lot" — 1000 cards for ~$10
|
||||||
|
- **TCGPlayer:** "Bulk commons" listings, often $0.01/card
|
||||||
|
- **Local game store:** Ask for "draft chaff" — often given away free
|
||||||
|
|
||||||
|
Also buy **pre-damaged cards** — natural damage looks more authentic to the model than simulated:
|
||||||
|
- **eBay:** Search "MTG damaged cards lot" or "heavily played bulk"
|
||||||
|
|
||||||
|
Aim for **50 cards per damage type** minimum. One card can be used for multiple damage types since each photo annotates only one damage area.
|
||||||
|
|
||||||
|
#### Raking Light Setup (Required for Surface and Structure Damage)
|
||||||
|
|
||||||
|
Most damage is invisible under flat overhead light. Raking light reveals it.
|
||||||
|
|
||||||
|
1. Place the card flat on a dark surface.
|
||||||
|
2. Position your desk lamp so light hits the card at a near-horizontal angle (5–15° above the surface) from one side.
|
||||||
|
3. The damage will cast visible shadows or catch the light clearly.
|
||||||
|
4. For scratches: slowly rotate the card until the scratches "light up" — photograph at that angle.
|
||||||
|
|
||||||
|
#### Damage Simulation Techniques
|
||||||
|
|
||||||
|
| Category | Damage Type | Folder Name | Simulation Technique | Photography Tip |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Surface** | Light Scratches | `LightScratches` | Rub foil surface gently with 0000 Steel Wool in one direction. | Raking light from the scratched direction. Rotate until scratches catch light. |
|
||||||
|
| **Surface** | Clouding | `Clouding` | Rub white vinyl eraser vigorously over foil surface in circles. | Diffused light. Compare side-by-side with a clean card for reference. |
|
||||||
|
| **Surface** | Dirt | `Dirt` | Press a damp fingertip into potting soil, then onto card surface. | Even lighting. Ensure dirt contrasts against the card art. |
|
||||||
|
| **Surface** | Dents | `Dents` | Press rounded end of a ballpoint pen cap firmly straight down. | Raking light at 10° to cast shadow inside the dent. |
|
||||||
|
| **Edges** | Whitening | `Whitening` | Rub card edges rapidly back and forth against denim jeans. | Black background. Macro close-up of the edge. |
|
||||||
|
| **Edges** | Chipping | `Chipping` | Use fingernail to carefully flake small pieces off the black border. | White background. Macro close-up. |
|
||||||
|
| **Edges** | Corner Wear | `CornerWear` | Rub corners against a rough mousepad with a circular motion. | Macro focus on the corner. Black background. |
|
||||||
|
| **Structure** | Creases | `Creases` | Fold corner sharply until a hard crease forms, then unfold. | Raking light to catch reflection off the crease ridge. |
|
||||||
|
| **Structure** | Shuffle Bend | `ShuffleBend` | Riffle shuffle the card aggressively 10+ times to create an arch. | Profile/side view to show curvature clearly. |
|
||||||
|
| **Structure** | Water Damage | `WaterDamage` | Mist card lightly with spray bottle, wait 60 seconds, air dry flat. | Raking light to show rippled surface texture. |
|
||||||
|
| **Critical** | Inking | `Inking` | Draw along whitened edges with black Sharpie to simulate edge touch-up. | UV/blacklight if available; otherwise strong white light at angle. |
|
||||||
|
| **Critical** | Rips | `Rips` | Tear edge slightly (~5mm). | High contrast background opposite to card border color. |
|
||||||
|
| **Critical** | Binder Dents | `BindersDents` | Press a 3-ring binder ring firmly into the card surface. | Raking light to show the circular crimp. |
|
||||||
|
|
||||||
|
#### What to Photograph per Damage Type
|
||||||
|
|
||||||
|
For each damage type, capture:
|
||||||
|
1. **30–50 cards showing that damage clearly** — positive training examples
|
||||||
|
2. **10–20 completely clean (undamaged) cards** — include these in every subfolder so the model learns the baseline
|
||||||
|
|
||||||
|
When annotating in Create ML, draw the bounding box **tightly around the damaged area only**. For shuffle bends, annotate the center of the arch. For edge damage, annotate the specific section of edge that is damaged, not the entire edge.
|
||||||
|
|
||||||
### Step 4: The "Edge Case" Validation List
|
### 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.
|
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.
|
||||||
@@ -160,7 +492,7 @@ Acquire these specific cheap cards to verify the logic-based detectors. **Note:*
|
|||||||
| **Saturation** | Unl/Revised Sim | *Revised* Basic Land (Washed out) vs *4th Edition* (Saturated). |
|
| **Saturation** | Unl/Revised Sim | *Revised* Basic Land (Washed out) vs *4th Edition* (Saturated). |
|
||||||
|
|
||||||
### Step 5: Training Folder Structure
|
### Step 5: Training Folder Structure
|
||||||
Create the following directory tree inside `IYmtg_Training` to organize your image data for Create ML.
|
The following directory tree is already created in `IYmtg_Training`. Place your cropped images into the appropriate folders.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IYmtg_Training/
|
IYmtg_Training/
|
||||||
@@ -201,19 +533,158 @@ IYmtg_Training/
|
|||||||
└── BindersDents/
|
└── BindersDents/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Create ML
|
### Step 6: Train Models in Create ML (Mac)
|
||||||
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
|
1. Open **Create ML** (found in Xcode → Open Developer Tool → Create ML)
|
||||||
|
2. **Foil Classifier:** New Project → Image Classification → drag in `Foil_Data/` → Train → Export as `IYmtgFoilClassifier.mlmodel`
|
||||||
|
3. **Stamp Classifier:** New Project → Image Classification → drag in `Stamp_Data/` → Train → Export as `IYmtgStampClassifier.mlmodel`
|
||||||
|
4. **Condition Classifier:** New Project → Object Detection → drag in `Condition_Data/` → Train → Export as `IYmtgConditionClassifier.mlmodel`
|
||||||
|
5. Drag all three `.mlmodel` files into the Xcode Project Navigator (ensure they are added to the app target)
|
||||||
|
|
||||||
### Firebase Configuration (Required for Cloud Sync)
|
### Set Symbol Harvester (Automation)
|
||||||
|
Run this script to automatically collect set symbol training data from Scryfall. Works on any platform.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests pillow
|
||||||
|
python3 IYmtg_Automation/fetch_set_symbols.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `Set_Symbol_Training/`. Drag this folder into Create ML → Image Classification to train `IYmtgSetSymbolClassifier.mlmodel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6: Community Feedback & Model Retraining
|
||||||
|
|
||||||
|
The app has a built-in pipeline that collects user corrections and uses them to improve the ML models over time. This section explains how it works end-to-end.
|
||||||
|
|
||||||
|
### How the Feedback System Works
|
||||||
|
|
||||||
|
There are two data collection paths:
|
||||||
|
|
||||||
|
**Path 1 — User Corrections (Community Data)**
|
||||||
|
When a user corrects a mis-scan (wrong card identity, wrong foil type, or wrong condition), the app automatically uploads the cropped card image to Firebase Storage — but only if the user has opted in.
|
||||||
|
|
||||||
|
The upload destination is determined by what was corrected:
|
||||||
|
|
||||||
|
| What Changed | Firebase Storage Path | Used to Retrain |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| Card name or set | `training/Identity_<SETCODE>_<CollectorNum>/` | `cards.json` (re-fingerprint) |
|
||||||
|
| Foil type | `training/Foil_<FoilType>/` | `IYmtgFoilClassifier.mlmodel` |
|
||||||
|
| Condition grade | `training/Condition_<Grade>/` | `IYmtgConditionClassifier.mlmodel` |
|
||||||
|
|
||||||
|
Example: A user corrects a card that was identified as "Traditional" foil to "Etched". The image is uploaded to `training/Foil_Etched/<UUID>.jpg`.
|
||||||
|
|
||||||
|
**Path 2 — Dev Mode (Your Own Device)**
|
||||||
|
When the `ENABLE_DEV_MODE` build flag is active and you tap the logo header 5 times, every raw scan frame is saved locally to `Documents/RawTrainingData/` on the device. Sync this folder to your Mac via Xcode's Devices window or Files app to retrieve images.
|
||||||
|
|
||||||
|
### User Opt-In
|
||||||
|
|
||||||
|
Users must explicitly opt in before any images are uploaded. The opt-in state is stored in `AppConfig.isTrainingOptIn` (backed by `UserDefaults`).
|
||||||
|
|
||||||
|
You must expose a toggle in your app's Settings/Library UI that sets `AppConfig.isTrainingOptIn = true/false`. The app description mentions this as the "Community Data Initiative" — users are told their corrections improve the AI for everyone.
|
||||||
|
|
||||||
|
**Firebase Authentication note:** `TrainingUploader` only uploads when `FirebaseApp.app() != nil` — meaning Firebase must be configured (`GoogleService-Info.plist` present) for community uploads to work. The app functions without Firebase, but no feedback is collected in that mode.
|
||||||
|
|
||||||
|
### Firebase Storage Rules
|
||||||
|
|
||||||
|
The rules in `IYmtg_App_iOS/Firebase/storage.rules` enforce:
|
||||||
|
- `training/` — authenticated users can **write only** (upload corrections). No user can read others' images.
|
||||||
|
- `models/` — anyone can **read** (required for OTA model downloads). Write access is developer-only via the Firebase Console.
|
||||||
|
|
||||||
|
### Downloading Collected Training Data
|
||||||
|
|
||||||
|
1. Go to the **Firebase Console → Storage → training/**
|
||||||
|
2. You will see folders named by label (e.g., `Foil_Etched/`, `Condition_Near Mint (NM)/`)
|
||||||
|
3. Download all images in each folder — use the Firebase CLI for bulk downloads:
|
||||||
|
```bash
|
||||||
|
# Install Firebase CLI if needed
|
||||||
|
npm install -g firebase-tools
|
||||||
|
firebase login
|
||||||
|
|
||||||
|
# Download all training data
|
||||||
|
firebase storage:cp gs://<your-bucket>/training ./downloaded_training --recursive
|
||||||
|
```
|
||||||
|
4. You now have a folder of user-contributed cropped card images, organized by label.
|
||||||
|
|
||||||
|
### Reviewing and Sorting Downloaded Images
|
||||||
|
|
||||||
|
**Do not skip this step.** User uploads can include blurry photos, wrong cards, or bad crops. Review each image before adding it to your training set.
|
||||||
|
|
||||||
|
1. Open each label folder from the download.
|
||||||
|
2. Delete any images that are: blurry, poorly cropped, show background, or are clearly wrong.
|
||||||
|
3. Move the accepted images into the corresponding `IYmtg_Training/` subfolder:
|
||||||
|
|
||||||
|
| Downloaded Folder | Move to Training Folder |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `Foil_Traditional/` | `IYmtg_Training/Foil_Data/Traditional/` |
|
||||||
|
| `Foil_Etched/` | `IYmtg_Training/Foil_Data/Etched/` |
|
||||||
|
| `Foil_<Type>/` | `IYmtg_Training/Foil_Data/<Type>/` |
|
||||||
|
| `Condition_<Grade>/` | Inspect condition grade — map to `Condition_Data/` subfolder by damage type visible |
|
||||||
|
|
||||||
|
> **Identity corrections** (`training/Identity_*/`) are not used to retrain ML models. They indicate that the visual fingerprint for that card may be wrong or ambiguous. Review these separately and consider re-running the Builder for those specific cards.
|
||||||
|
|
||||||
|
### Retraining the Models
|
||||||
|
|
||||||
|
Once you have added new images to `IYmtg_Training/`:
|
||||||
|
|
||||||
|
1. Open **Create ML** on your Mac.
|
||||||
|
2. Open your existing project for the model you want to update (e.g., `IYmtgFoilClassifier`).
|
||||||
|
3. The new images in the training folders will be picked up automatically.
|
||||||
|
4. Click **Train**. Create ML will train incrementally on the expanded dataset.
|
||||||
|
5. Evaluate the results — check accuracy on the **Validation** tab. Aim for >90% accuracy before shipping.
|
||||||
|
6. Export the updated `.mlmodel` file.
|
||||||
|
|
||||||
|
### Pushing the Updated Model via OTA
|
||||||
|
|
||||||
|
You do not need an App Store update to ship a new model version. Use Firebase Storage:
|
||||||
|
|
||||||
|
1. In the **Firebase Console → Storage**, navigate to the `models/` folder.
|
||||||
|
2. Upload your new `.mlmodel` file with the **exact same filename** (e.g., `IYmtgFoilClassifier.mlmodel`).
|
||||||
|
3. On the next app launch, `ModelManager` detects the newer version, downloads and compiles it, and swaps it in automatically.
|
||||||
|
|
||||||
|
> **Important:** The new model takes effect on the **next app launch after download**, not immediately. Users may need to relaunch once.
|
||||||
|
|
||||||
|
### Recommended Retraining Schedule
|
||||||
|
|
||||||
|
| Trigger | Action |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 50+ new correction images accumulated | Review, sort, retrain affected model, push OTA |
|
||||||
|
| New MTG set released with new foil type | Add training folder, acquire cards, retrain FoilClassifier |
|
||||||
|
| New MTG set released | Rebuild `cards.json` via `weekly_update.sh` |
|
||||||
|
| Significant accuracy complaints from users | Download corrections, review, retrain |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7: Backend & Security
|
||||||
|
|
||||||
|
### Cloud Storage Architecture
|
||||||
|
The app uses a two-tier cloud strategy:
|
||||||
|
|
||||||
|
| Tier | Technology | What it stores | Cost |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Primary** | iCloud + CloudKit (SwiftData) | All card metadata, synced automatically across devices | Free (user's iCloud) |
|
||||||
|
| **Secondary** | Firebase Firestore | Metadata only — no images — optional manual backup | Free (Firestore free tier) |
|
||||||
|
|
||||||
|
Card images are stored in the user's iCloud Drive under `Documents/UserContent/` and are **never** uploaded to Firebase.
|
||||||
|
|
||||||
|
### iCloud / CloudKit Setup (Required for Primary Sync)
|
||||||
|
1. In Xcode, open **Signing & Capabilities**.
|
||||||
|
2. Add the **iCloud** capability. Enable **CloudKit**.
|
||||||
|
3. Add a CloudKit container named `iCloud.<your-bundle-id>`.
|
||||||
|
4. Add the **Background Modes** capability. Enable **Remote notifications**.
|
||||||
|
5. Set the minimum deployment target to **iOS 17** (required by SwiftData).
|
||||||
|
|
||||||
|
Without this setup the app falls back to local-only storage automatically.
|
||||||
|
|
||||||
|
### Firebase Configuration (Optional Secondary Backup)
|
||||||
|
Firebase is no longer the primary sync mechanism. It serves as a user-triggered metadata backup.
|
||||||
1. **Create Project:** Go to the Firebase Console and create a new project.
|
1. **Create Project:** Go to the Firebase Console and create a new project.
|
||||||
2. **Authentication:** Enable "Anonymous" sign-in in the Authentication tab.
|
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`.
|
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`.
|
4. **Setup:** Download `GoogleService-Info.plist` from Project Settings and drag it into the `IYmtg_App_iOS` folder in Xcode (ensure "Copy items if needed" is checked).
|
||||||
5. **Setup:** Download `GoogleService-Info.plist` from Project Settings and drag it into the `IYmtg_App_iOS` folder in Xcode (ensure "Copy items if needed" is checked).
|
5. Users trigger backup manually via **Library → Cloud Backup → Backup Metadata to Firebase Now**.
|
||||||
|
|
||||||
|
The app runs fully without `GoogleService-Info.plist` (Local Mode — iCloud sync still works).
|
||||||
|
|
||||||
### Over-the-Air (OTA) Model Updates
|
### Over-the-Air (OTA) Model Updates
|
||||||
To update ML models without an App Store release:
|
To update ML models without an App Store release:
|
||||||
@@ -221,44 +692,35 @@ To update ML models without an App Store release:
|
|||||||
2. Upload the `.mlmodel` file to Firebase Storage in the `models/` folder.
|
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.
|
3. The app will automatically detect the newer file, download, compile, and hot-swap it on the next launch.
|
||||||
|
|
||||||
|
> **Note:** OTA model updates take effect on the next app launch — not immediately. An app restart is required after a new model is downloaded.
|
||||||
|
|
||||||
### Privacy Manifest
|
### Privacy Manifest
|
||||||
Ensure `PrivacyInfo.xcprivacy` is included in the app target to satisfy Apple's privacy requirements regarding file timestamps and user defaults.
|
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/`.
|
## Part 8: App Configuration
|
||||||
|
|
||||||
### 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:
|
**CRITICAL:** Edit `IYmtg_App_iOS/AppConfig.swift` before building to ensure payments and support work correctly:
|
||||||
1. Set `contactEmail` to your real email address.
|
1. Set `contactEmail` to your real email address (required by Scryfall API policy).
|
||||||
2. Set `tipJarProductIDs` to your actual In-App Purchase IDs.
|
2. Set `tipJarProductIDs` to your actual In-App Purchase IDs from App Store Connect.
|
||||||
|
3. `isFirebaseBackupEnabled` defaults to `false`. Users opt-in from Library settings.
|
||||||
|
|
||||||
## 7. Development Mode
|
---
|
||||||
|
|
||||||
|
## Part 9: Development Mode
|
||||||
|
|
||||||
To enable saving raw training images during scanning:
|
To enable saving raw training images during scanning:
|
||||||
1. Add the compilation flag `ENABLE_DEV_MODE` in Xcode Build Settings.
|
1. Add the compilation flag `ENABLE_DEV_MODE` in Xcode Build Settings → Swift Compiler → Active Compilation Conditions.
|
||||||
2. Tap the "IYmtg" logo header 5 times in the app to activate.
|
2. Tap the "IYmtg" logo header 5 times in the app to activate.
|
||||||
|
|
||||||
## 8. Testing
|
Saved images appear in `Documents/DevImages/` and can be used to supplement your ML training data.
|
||||||
|
|
||||||
The project includes a comprehensive unit test suite located in `IYmtgTests.swift`.
|
---
|
||||||
|
|
||||||
|
## Part 10: Testing
|
||||||
|
|
||||||
|
The project includes a unit test suite in `IYmtgTests.swift`.
|
||||||
|
|
||||||
**How to Run:**
|
**How to Run:**
|
||||||
* Press `Cmd+U` in Xcode to execute the test suite.
|
* Press `Cmd+U` in Xcode to execute the test suite.
|
||||||
@@ -270,25 +732,61 @@ The project includes a comprehensive unit test suite located in `IYmtgTests.swif
|
|||||||
|
|
||||||
**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.
|
**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
|
---
|
||||||
|
|
||||||
|
## Part 11: Release Checklist
|
||||||
|
|
||||||
Perform these steps before submitting to the App Store.
|
Perform these steps before submitting to the App Store.
|
||||||
|
|
||||||
1. **Configuration Check:**
|
1. **Database:**
|
||||||
|
* [ ] `cards.json` is present in `IYmtg_App_iOS/` and added to the Xcode target.
|
||||||
|
* [ ] Builder was run recently enough to include current sets.
|
||||||
|
2. **Configuration Check:**
|
||||||
* [ ] Open `AppConfig.swift`.
|
* [ ] Open `AppConfig.swift`.
|
||||||
* [ ] Verify `contactEmail` is valid.
|
* [ ] Verify `contactEmail` is your real email (not a placeholder).
|
||||||
* [ ] Verify `tipJarProductIDs` match App Store Connect.
|
* [ ] Verify `tipJarProductIDs` match App Store Connect.
|
||||||
* [ ] Ensure `enableFoilDetection` and other flags are `true`.
|
* [ ] Ensure `enableFoilDetection` and other feature flags are `true`.
|
||||||
2. **Assets:**
|
* [ ] Update `appVersion` (Semantic Versioning: Major.Minor.Patch) and `buildNumber` for this release.
|
||||||
* [ ] Ensure `Assets.xcassets` has the AppIcon filled for all sizes.
|
3. **ML Models:**
|
||||||
3. **Testing:**
|
* [ ] `IYmtgFoilClassifier.mlmodel` added to Xcode target (or acceptable to ship without).
|
||||||
* [ ] Run Unit Tests (`Cmd+U`) - All must pass.
|
* [ ] `IYmtgStampClassifier.mlmodel` added to Xcode target (or acceptable to ship without).
|
||||||
* [ ] Run on Physical Device - Verify Camera permissions prompt appears.
|
* [ ] `IYmtgConditionClassifier.mlmodel` added to Xcode target (or acceptable to ship without).
|
||||||
4. **Build:**
|
4. **iCloud / CloudKit:**
|
||||||
|
* [ ] Signing & Capabilities → iCloud → CloudKit enabled.
|
||||||
|
* [ ] CloudKit container added: `iCloud.<bundle-id>`.
|
||||||
|
* [ ] Background Modes → Remote notifications enabled.
|
||||||
|
* [ ] Minimum deployment target set to **iOS 17**.
|
||||||
|
5. **Assets:**
|
||||||
|
* [ ] `Assets.xcassets` has the AppIcon filled for all sizes.
|
||||||
|
* [ ] `PrivacyInfo.xcprivacy` is in the app target.
|
||||||
|
6. **Testing:**
|
||||||
|
* [ ] Run Unit Tests (`Cmd+U`) — all must pass.
|
||||||
|
* [ ] Run on Physical Device — verify Camera permissions prompt appears.
|
||||||
|
* [ ] Run on Physical Device — verify a card scans and saves successfully.
|
||||||
|
7. **Build:**
|
||||||
* [ ] Select "Any iOS Device (arm64)".
|
* [ ] Select "Any iOS Device (arm64)".
|
||||||
* [ ] Product -> Archive.
|
* [ ] Product → Archive.
|
||||||
* [ ] Validate App in Organizer.
|
* [ ] Validate App in Organizer.
|
||||||
* [ ] Distribute App -> App Store Connect.
|
* [ ] Distribute App → App Store Connect.
|
||||||
|
|
||||||
---
|
---
|
||||||
**Version Authority:** 1.0.0
|
**Version Authority:** 1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Audit
|
||||||
|
|
||||||
|
**Audit Date:** 2026-03-05 | **Auditor:** Claude (Sonnet 4.6)
|
||||||
|
|
||||||
|
A full compilation-readiness audit was performed against all 33 Swift source files in `IYmtg_App_iOS/`. See [`claude_review_summary.md`](claude_review_summary.md) for the complete report.
|
||||||
|
|
||||||
|
**Key findings:**
|
||||||
|
|
||||||
|
| Severity | Count | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Blocker | 2 | `IYmtgTests.swift` — test target will not compile (`ScannerViewModel()` no-arg init removed; test accesses non-existent VM properties) |
|
||||||
|
| Critical | 1 | `IYmtg_Builder_Mac/` is empty — `cards.json` cannot be generated; scanner is non-functional at runtime |
|
||||||
|
| Major | 4 | Deprecated `.onChange(of:)` API (iOS 17); missing `import FirebaseCore` in `ModelManager.swift`; Firebase delete data leak; dead `batchUpdatePrices()` function |
|
||||||
|
| Minor | 4 | Empty `Features/CardDetail/` directory; `PersistenceActor.swift` placeholder; production `AppConfig` values not set; OTA model restart not documented |
|
||||||
|
|
||||||
|
**Overall:** App source code is architecturally complete. Fix the 2 Blocker issues in `IYmtgTests.swift` and implement `IYmtg_Builder_Mac` before developer handoff.
|
||||||
|
|||||||
45
ai_blueprint.md
Normal file
45
ai_blueprint.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# AI Blueprint: Full Project Readiness Audit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
To perform a comprehensive audit of the `IYmtg_App_iOS` project to ensure it can be compiled without errors and to identify any missing or incomplete features before handing it off to a human developer.
|
||||||
|
|
||||||
|
## Audit Steps for Claude
|
||||||
|
|
||||||
|
1. **Dependency Verification:**
|
||||||
|
* Review the project's dependencies. Since this is a Swift project, check for any package manager configurations (like Swift Package Manager, CocoaPods, or Carthage). If any are found, ensure all required dependencies are listed and correctly configured.
|
||||||
|
* Verify that all required versions of dependencies are compatible with the project's Swift version.
|
||||||
|
|
||||||
|
2. **File and Resource Integrity Check:**
|
||||||
|
* Scan the project for any missing files or broken links. Pay close attention to assets, storyboards, and any other resources referenced in the code.
|
||||||
|
* Ensure all necessary `.swift` files are included in the build targets.
|
||||||
|
|
||||||
|
3. **Static Code Analysis:**
|
||||||
|
* Perform a static analysis of the entire Swift codebase in `IYmtg_App_iOS/`.
|
||||||
|
* Look for common compilation issues such as:
|
||||||
|
* Syntax errors.
|
||||||
|
* Type mismatches.
|
||||||
|
* Unresolved identifiers (e.g., variables or functions that are used but not defined).
|
||||||
|
* Incorrect use of optionals.
|
||||||
|
* API deprecation warnings.
|
||||||
|
|
||||||
|
4. **Feature Completeness Review:**
|
||||||
|
* Examine the code for any features that are stubbed out, marked with `// TODO:`, `// FIXME:`, or are otherwise incomplete.
|
||||||
|
* Pay special attention to the `Features` directory (`IYmtg_App_iOS/Features/`) to assess the state of each feature (CardDetail, Collection, Help, Scanner).
|
||||||
|
* Check for any disabled or commented-out code that might indicate an incomplete feature.
|
||||||
|
|
||||||
|
5. **Audit Summary Report:**
|
||||||
|
* Create a summary of all findings in a new file named `claude_review_summary.md`.
|
||||||
|
* For each issue, provide a description, the file path, and the relevant line number(s).
|
||||||
|
* Categorize the issues by severity (e.g., Blocker, Critical, Major, Minor).
|
||||||
|
|
||||||
|
6. **Update README.md:**
|
||||||
|
* Add a new section to the `README.md` file titled "Project Audit".
|
||||||
|
* Under this section, add a brief summary of the audit's outcome and a link to the `claude_review_summary.md` file.
|
||||||
|
|
||||||
|
7. **Create Git Commit:**
|
||||||
|
* Stage all the changes (the new `claude_review_summary.md` and the updated `README.md`).
|
||||||
|
* Create a Git commit with the message: "feat: Complete project readiness audit".
|
||||||
|
|
||||||
|
8. **Increment Project Version:**
|
||||||
|
* If the audit reveals significant issues that require code changes, increment the project's build number. If no significant issues are found, this step can be skipped. The location of the version number is likely in the project's settings or an `Info.plist` file.
|
||||||
23
architect_instructions.md
Normal file
23
architect_instructions.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Architect AI Instructions
|
||||||
|
|
||||||
|
This document contains the instructions for the Architect AI (Roo).
|
||||||
|
|
||||||
|
## My Role
|
||||||
|
|
||||||
|
My primary role is to act as a technical planner and architect. I do not write implementation code. My goal is to understand the user's request, analyze the existing codebase, and produce a clear, step-by-step plan for another AI (Claude) to execute.
|
||||||
|
|
||||||
|
## My Process
|
||||||
|
|
||||||
|
1. **Information Gathering:** I will use the available tools to explore the codebase and gather context relevant to the user's request. This includes reading files, searching for code, and listing files.
|
||||||
|
2. **Clarification:** If the user's request is ambiguous, I will ask clarifying questions to ensure I have all the necessary details before creating a plan.
|
||||||
|
3. **Planning:** I will create a detailed, step-by-step plan. This plan will be written to the `ai_blueprint.md` file, and will overwrite the file if it already exists.
|
||||||
|
4. **Plan Contents:** Each plan I create in `ai_blueprint.md` must include:
|
||||||
|
* A high-level overview of the goal.
|
||||||
|
* A sequential list of tasks for the implementation AI.
|
||||||
|
* Specific file paths that need to be created or modified.
|
||||||
|
* High-level descriptions of the changes required.
|
||||||
|
* A mandatory step to update the `README.md` if necessary.
|
||||||
|
* A mandatory step to create a Git commit with a descriptive message.
|
||||||
|
* A mandatory step to increment the project version number.
|
||||||
|
5. **No Coding:** I will not write or modify any code files (e.g., `.swift`, `.py`, `.js`). My modifications are restricted to markdown files (`.md`). Providing code snippets within markdown files is acceptable for planning purposes, but I will not directly execute or create code files.
|
||||||
|
6. **Handoff:** Once the plan is written to `ai_blueprint.md`, my task is complete. I will inform the user that the plan is ready for the implementation phase.
|
||||||
223
claude_review_summary.md
Normal file
223
claude_review_summary.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# Claude Review Summary
|
||||||
|
**Review Date:** 2026-03-05 (Revision 2 — Full Compilation Readiness Audit)
|
||||||
|
**Blueprint:** `ai_blueprint.md` — Full Project Readiness Audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The source code is architecturally complete and well-structured. Two **Blocker** issues will prevent the test target from compiling. One **Critical** issue (missing `cards.json` generator) means the scanner cannot function at all at runtime. Four **Major** issues represent deprecation warnings and a Firebase data-leak bug. The app target itself will compile once Firebase imports are confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dependency Verification
|
||||||
|
|
||||||
|
### Package Manager
|
||||||
|
No `Package.swift`, `Podfile`, or `Cartfile` exists in the repository. Swift Package Manager dependencies are managed through the Xcode `.xcodeproj` file, which lives on the developer's Mac (not in this repo). Dependency versions cannot be verified from source alone.
|
||||||
|
|
||||||
|
**Required SPM packages (must be added in Xcode):**
|
||||||
|
|
||||||
|
| Package | Required Modules | Min Version |
|
||||||
|
|---|---|---|
|
||||||
|
| `firebase-ios-sdk` | FirebaseCore, FirebaseAuth, FirebaseFirestore, FirebaseStorage | ≥ 10.x |
|
||||||
|
| SwiftData | Built-in (iOS 17+) | iOS 17 SDK |
|
||||||
|
|
||||||
|
**Minimum Deployment Target:** iOS 17 is required by `SwiftData`, `@ModelActor`, and `ModelContainer`. This must be set in Xcode → Build Settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. File & Resource Integrity
|
||||||
|
|
||||||
|
### Missing Runtime Files (excluded by `.gitignore` — must be added before shipping)
|
||||||
|
|
||||||
|
| File | Referenced In | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `cards.json` | `ScannerViewModel.swift:96` | ❌ **CRITICAL** — Generator (`IYmtg_Builder_Mac/`) is empty |
|
||||||
|
| `GoogleService-Info.plist` | `IYmtgApp.swift:9` | ⚠️ Expected missing — handled gracefully with local-mode fallback |
|
||||||
|
| `IYmtgFoilClassifier.mlmodel` | `FoilEngine.swift:12` | ⚠️ Expected missing — app degrades gracefully (foil = "None") |
|
||||||
|
| `IYmtgConditionClassifier.mlmodel` | `ConditionEngine.swift:17` | ⚠️ Expected missing — app degrades gracefully (grade = "Ungraded") |
|
||||||
|
| `IYmtgStampClassifier.mlmodel` | `StampDetector.swift:8` | ⚠️ Expected missing — app degrades gracefully |
|
||||||
|
| `IYmtgSetClassifier.mlmodel` | `SetSymbolEngine.swift:8` | ⚠️ Expected missing — app degrades gracefully |
|
||||||
|
|
||||||
|
### Missing Image Assets (must exist in `Assets.xcassets` inside Xcode project)
|
||||||
|
|
||||||
|
| Asset Name | Referenced In | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `scanner_frame` | `ContentView.swift:114` | Required — scanner overlay frame |
|
||||||
|
| `logo_header` | `ContentView.swift:147` | Required — header logo |
|
||||||
|
| `empty_library` | `ContentView.swift:230` | Required — empty state illustration |
|
||||||
|
| `share_watermark` | `ContentView.swift:334` | Optional — has fallback text if missing |
|
||||||
|
|
||||||
|
### Placeholder / Orphan Files
|
||||||
|
|
||||||
|
| File | Issue |
|
||||||
|
|---|---|
|
||||||
|
| `Data/Persistence/PersistenceActor.swift` | Empty placeholder — must be **removed from Xcode project navigator** (note inside the file confirms this) |
|
||||||
|
| `Features/CardDetail/` | Directory is empty — `CardDetailView` is defined in `ContentView.swift` instead |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Static Code Analysis
|
||||||
|
|
||||||
|
### BLOCKER Issues (prevent compilation)
|
||||||
|
|
||||||
|
#### B1 — `IYmtgTests.swift:113` — `ScannerViewModel()` no-arg init does not exist
|
||||||
|
**Severity:** Blocker | **File:** `IYmtg_App_iOS/IYmtgTests.swift` | **Lines:** 113, 137
|
||||||
|
|
||||||
|
`ScannerViewModel` only exposes `init(collectionVM: CollectionViewModel)`. The test file calls `ScannerViewModel()` with no arguments in two test functions (`testViewModelFiltering` and `testPortfolioCalculation`). The Swift compiler will reject this.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Current (BROKEN):
|
||||||
|
let vm = ScannerViewModel()
|
||||||
|
|
||||||
|
// Fix:
|
||||||
|
let colVM = CollectionViewModel()
|
||||||
|
let vm = ScannerViewModel(collectionVM: colVM)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B2 — `IYmtgTests.swift:118-148` — Accessing non-existent properties on `ScannerViewModel`
|
||||||
|
**Severity:** Blocker | **File:** `IYmtg_App_iOS/IYmtgTests.swift` | **Lines:** 118, 121, 126, 137, 143
|
||||||
|
|
||||||
|
The test directly assigns/reads `vm.scannedList`, `vm.librarySearchText`, `vm.filteredList`, and `vm.portfolioValue` on a `ScannerViewModel` instance. None of these are forwarded computed properties on `ScannerViewModel` — they belong to `CollectionViewModel`. The compiler will produce unresolved identifier errors.
|
||||||
|
|
||||||
|
**Fix:** Obtain these properties from `vm.collectionVM` instead:
|
||||||
|
```swift
|
||||||
|
vm.collectionVM.scannedList = [card1, card2]
|
||||||
|
vm.collectionVM.librarySearchText = "Alpha"
|
||||||
|
// etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CRITICAL Issues (app non-functional at runtime)
|
||||||
|
|
||||||
|
#### C1 — `cards.json` generator is missing (`IYmtg_Builder_Mac/` is empty)
|
||||||
|
**Severity:** Critical | **File:** `IYmtg_Builder_Mac/` (directory) | **Line:** N/A
|
||||||
|
|
||||||
|
`ScannerViewModel.init` attempts to load `cards.json` from the app bundle. If absent, the app immediately shows a "Database Missing" alert and the scanner is permanently non-functional. The `IYmtg_Builder_Mac/` directory — which should contain the Swift CLI tool that generates this file — is completely empty. This is the single most important missing piece before any developer can test the scanner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MAJOR Issues (warnings or significant functional gaps)
|
||||||
|
|
||||||
|
#### M1 — Deprecated `.onChange(of:)` API (iOS 17 deprecation warnings)
|
||||||
|
**Severity:** Major | **File:** `IYmtg_App_iOS/ContentView.swift` | **Lines:** 172, 237
|
||||||
|
|
||||||
|
The single-parameter closure form of `.onChange(of:)` was deprecated in iOS 17. Since the app targets iOS 17+, Xcode will emit deprecation warnings on every build.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Line 172 — DEPRECATED:
|
||||||
|
.onChange(of: scenePhase) { newPhase in ... }
|
||||||
|
|
||||||
|
// Fix:
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in ... }
|
||||||
|
|
||||||
|
// Line 237 — DEPRECATED:
|
||||||
|
.onChange(of: vm.selectedCurrency) { _ in vm.refreshPrices(force: true) }
|
||||||
|
|
||||||
|
// Fix:
|
||||||
|
.onChange(of: vm.selectedCurrency) { _, _ in vm.refreshPrices(force: true) }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### M2 — `ModelManager.swift` missing `import FirebaseCore`
|
||||||
|
**Severity:** Major | **File:** `IYmtg_App_iOS/Services/CoreML/ModelManager.swift` | **Lines:** 1–3, 35
|
||||||
|
|
||||||
|
`ModelManager.swift` uses `FirebaseApp.app()` (a `FirebaseCore` type) but only imports `FirebaseStorage`. In Swift, each file must explicitly import the module it uses. This may compile in some Xcode/SPM configurations where `FirebaseStorage` transitively links `FirebaseCore`, but it is not guaranteed and should be made explicit.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Add to ModelManager.swift imports:
|
||||||
|
import FirebaseCore
|
||||||
|
```
|
||||||
|
|
||||||
|
#### M3 — `CloudEngine.delete(card:)` never called — Firebase data leak on card deletion
|
||||||
|
**Severity:** Major | **File:** `IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift` | **Line:** 271–278
|
||||||
|
|
||||||
|
`CollectionViewModel.deleteCard(_:)` removes the card from SwiftData (via `saveCollectionAsync()`) and deletes the local image, but never calls `CloudEngine.delete(card:)`. Any cards that were previously backed up to Firebase Firestore will remain in the user's Firestore inventory forever after deletion. The method to fix this already exists — it just needs to be called.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// In CollectionViewModel.deleteCard(_:), after removing from scannedList:
|
||||||
|
Task { await CloudEngine.delete(card: card) }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### M4 — `CloudEngine.batchUpdatePrices()` is dead code
|
||||||
|
**Severity:** Major | **File:** `IYmtg_App_iOS/Services/Cloud/CloudEngine.swift` | **Lines:** 49–69
|
||||||
|
|
||||||
|
`CloudEngine.batchUpdatePrices(cards:)` is fully implemented but never called anywhere in the codebase. Price updates are reflected in SwiftData + CloudKit automatically, making this function either redundant or an incomplete feature. It should either be wired into the price-refresh flow or removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### MINOR Issues (low impact, housekeeping)
|
||||||
|
|
||||||
|
#### m1 — `Features/CardDetail/` directory is empty
|
||||||
|
**Severity:** Minor | **File:** `IYmtg_App_iOS/Features/CardDetail/` (directory)
|
||||||
|
|
||||||
|
`CardDetailView`, `DashboardView`, `ScannerView`, `WelcomeView`, and `FeatureRow` are all defined in `ContentView.swift`, making it a 469-line monolith. The `CardDetail/` directory exists but holds no files. This violates the project's modular structure intent but does not affect compilation.
|
||||||
|
|
||||||
|
#### m2 — `PersistenceActor.swift` placeholder in Xcode project
|
||||||
|
**Severity:** Minor | **File:** `IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift`
|
||||||
|
|
||||||
|
This file contains only a migration comment and no executable code. It must be manually removed from the Xcode project navigator to avoid confusion (the file itself documents this).
|
||||||
|
|
||||||
|
#### m3 — `AppConfig.swift` not configured for production
|
||||||
|
**Severity:** Minor | **File:** `IYmtg_App_iOS/AppConfig.swift` | **Lines:** 24, 27
|
||||||
|
|
||||||
|
| Setting | Current Value | Required |
|
||||||
|
|---|---|---|
|
||||||
|
| `contactEmail` | `"support@iymtg.com"` | Real developer email (Scryfall policy) |
|
||||||
|
| `tipJarProductIDs` | `[]` | Real App Store Connect product IDs |
|
||||||
|
| `buildNumber` | `"2"` | Increment with each App Store submission |
|
||||||
|
|
||||||
|
The `validate()` function checks for `"yourdomain.com"` but not for `"iymtg.com"`, so the fatalError guard will not fire in DEBUG with the current placeholder.
|
||||||
|
|
||||||
|
#### m4 — OTA model updates require app restart (undocumented)
|
||||||
|
**Severity:** Minor | **File:** `IYmtg_App_iOS/Services/CoreML/ModelManager.swift` | **Line:** 16
|
||||||
|
|
||||||
|
`FoilEngine.model`, `ConditionEngine.model`, `StampDetector.model`, and `SetSymbolEngine.model` are all `static var` properties evaluated once at class-load time. When `ModelManager.checkForUpdates()` downloads a new `.mlmodelc` to Documents, the running app will not use it until restarted. This is expected behavior but should be documented in the README or in-app release notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Feature Completeness Review
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Card Scanner (camera, frame capture) | ✅ Complete | `ScannerViewModel` — full pipeline |
|
||||||
|
| Card Identification (fingerprint + OCR + heuristics) | ✅ Complete | `AnalysisActor`, `FeatureMatcher`, `OCREngine`, heuristics |
|
||||||
|
| Foil Detection | ✅ Complete | `FoilEngine` — requires trained model |
|
||||||
|
| Condition Grading | ✅ Complete | `ConditionEngine` — requires trained model |
|
||||||
|
| Stamp Detection | ✅ Complete | `StampDetector` — requires trained model |
|
||||||
|
| Set Symbol Detection | ✅ Complete | `SetSymbolEngine` — requires trained model |
|
||||||
|
| Collection Management | ✅ Complete | `CollectionViewModel` |
|
||||||
|
| SwiftData + CloudKit Persistence | ✅ Complete | `PersistenceController`, `BackgroundPersistenceActor` |
|
||||||
|
| JSON → SwiftData Migration | ✅ Complete | `migrateFromJSONIfNeeded()` |
|
||||||
|
| Scryfall Price Refresh | ✅ Complete | `ScryfallAPI.updateTrends()` |
|
||||||
|
| PDF Insurance Export | ✅ Complete | `ExportEngine.generatePDF()` |
|
||||||
|
| CSV / Arena / MTGO Export | ✅ Complete | `ExportEngine` |
|
||||||
|
| Firebase Backup (manual) | ✅ Complete | `CloudEngine` + `CollectionViewModel.backupAllToFirebase()` |
|
||||||
|
| OTA Model Updates | ✅ Complete | `ModelManager.checkForUpdates()` |
|
||||||
|
| Training Image Upload | ✅ Complete | `TrainingUploader` |
|
||||||
|
| In-App Purchase (Tip Jar) | ✅ Complete | `StoreEngine` — IAP IDs not configured |
|
||||||
|
| App Review Prompt | ✅ Complete | `ReviewEngine` |
|
||||||
|
| Training Guide (in-app) | ✅ Complete | `TrainingGuideView` |
|
||||||
|
| Welcome Screen | ✅ Complete | `WelcomeView` |
|
||||||
|
| Dashboard (Portfolio stats) | ✅ Complete | `DashboardView` |
|
||||||
|
| **IYmtg_Builder_Mac (cards.json generator)** | ❌ **MISSING** | Directory exists but is completely empty |
|
||||||
|
| Unit Tests | ⚠️ Present but broken | 2 Blocker compilation errors (see B1, B2) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary Scorecard
|
||||||
|
|
||||||
|
| Category | Status | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| Test target compilation | ❌ 2 Blockers | **Blocker** — fix before any CI |
|
||||||
|
| Runtime: `cards.json` missing | ❌ Scanner non-functional | **Critical** |
|
||||||
|
| Deprecated API warnings | ⚠️ 2 occurrences | **Major** |
|
||||||
|
| Firebase import missing | ⚠️ Potential compile error | **Major** |
|
||||||
|
| Firebase delete data leak | ⚠️ Silent bug | **Major** |
|
||||||
|
| Dead code (`batchUpdatePrices`) | ⚠️ Minor bloat | **Major** |
|
||||||
|
| `CardDetail/` directory empty | ℹ️ Structural inconsistency | Minor |
|
||||||
|
| `PersistenceActor.swift` placeholder | ℹ️ Xcode cleanup needed | Minor |
|
||||||
|
| AppConfig production values | ℹ️ Not release-ready | Minor |
|
||||||
|
| OTA restart undocumented | ℹ️ Developer note needed | Minor |
|
||||||
|
| SPM deps (no manifest in repo) | ℹ️ Expected (Xcode-managed) | Informational |
|
||||||
|
| App source code (all other files) | ✅ Compiles, well-structured | — |
|
||||||
Reference in New Issue
Block a user