Blockers: - IYmtgTests: replace ScannerViewModel() (no-arg init removed) with CollectionViewModel() in testViewModelFiltering and testPortfolioCalculation - IYmtgTests: fix property access — scannedList, librarySearchText, filteredList, portfolioValue all live on CollectionViewModel, not ScannerViewModel; inject test data after async init settles Major: - ContentView: update .onChange(of:) to two-parameter closure syntax (iOS 17 deprecation) - ModelManager: add missing import FirebaseCore so FirebaseApp.app() resolves explicitly - CollectionViewModel.deleteCard: call CloudEngine.delete(card:) to remove Firebase entry when a card is deleted (prevents data accumulation) - CloudEngine: remove never-called batchUpdatePrices() — full backup already handled by backupAllToFirebase() Minor: - CardDetailView: move to Features/CardDetail/CardDetailView.swift; remove from ContentView.swift - Delete PersistenceActor.swift placeholder (superseded by PersistenceController.swift) - AppConfig.validate(): broaden placeholder-email guard to catch empty strings and common fake domains - ModelManager: document OTA restart requirement in code comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
387 lines
23 KiB
Swift
387 lines
23 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
import AVFoundation
|
|
import StoreKit
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var collectionVM: CollectionViewModel
|
|
@StateObject private var scannerVM: ScannerViewModel
|
|
@StateObject var store = StoreEngine()
|
|
@AppStorage("hasLaunchedBefore") var hasLaunchedBefore = false
|
|
|
|
init() {
|
|
let colVM = CollectionViewModel()
|
|
_collectionVM = StateObject(wrappedValue: colVM)
|
|
_scannerVM = StateObject(wrappedValue: ScannerViewModel(collectionVM: colVM))
|
|
}
|
|
|
|
var body: some View {
|
|
TabView {
|
|
DashboardView(vm: collectionVM)
|
|
.tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") }
|
|
ScannerView(vm: scannerVM)
|
|
.tabItem { Label("Scan", systemImage: "viewfinder") }
|
|
CollectionView(vm: collectionVM, scannerVM: scannerVM, store: store)
|
|
.tabItem { Label("Library", systemImage: "tray.full.fill") }
|
|
}
|
|
.preferredColorScheme(.dark).accentColor(Color(red: 0.6, green: 0.3, blue: 0.9))
|
|
.task { await store.loadProducts() }
|
|
.alert("Thank You!", isPresented: $store.showThankYou) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: { Text("Your support keeps the app ad-free!") }
|
|
.sheet(isPresented: Binding(get: { !hasLaunchedBefore }, set: { _ in })) {
|
|
WelcomeView(hasLaunchedBefore: $hasLaunchedBefore)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - DASHBOARD VIEW
|
|
struct DashboardView: View {
|
|
@ObservedObject var vm: CollectionViewModel
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section(header: Text("Market Overview")) {
|
|
HStack {
|
|
VStack(alignment: .leading) { Text("Total Value").font(.caption).foregroundColor(.gray); Text("\(vm.selectedCurrency.symbol)\(vm.portfolioValue, specifier: "%.2f")").font(.title).bold() }
|
|
Spacer()
|
|
VStack(alignment: .trailing) {
|
|
Text("24h Change").font(.caption).foregroundColor(.gray)
|
|
Text((vm.portfolioDailyChange >= 0 ? "+" : "") + String(format: "%.2f", vm.portfolioDailyChange))
|
|
.foregroundColor(vm.portfolioDailyChange >= 0 ? .green : .red).bold()
|
|
}
|
|
}
|
|
}
|
|
|
|
Section(header: Text("Top Movers")) {
|
|
if vm.topMovers.isEmpty { Text("No price changes detected today.").foregroundColor(.gray) }
|
|
ForEach(vm.topMovers) { card in
|
|
HStack {
|
|
VStack(alignment: .leading) { Text(card.name).font(.headline); Text(card.setCode).font(.caption).foregroundColor(.gray) }
|
|
Spacer()
|
|
let diff = (card.currentValuation ?? 0) - (card.previousValuation ?? 0)
|
|
Text((diff >= 0 ? "+" : "") + String(format: "%.2f", diff)).foregroundColor(diff >= 0 ? .green : .red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Dashboard")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SCANNER VIEW
|
|
struct ScannerView: View {
|
|
@ObservedObject var vm: ScannerViewModel
|
|
@Environment(\.scenePhase) var scenePhase
|
|
@State private var devModeTapCount = 0
|
|
@State private var focusPoint: CGPoint? = nil
|
|
@State private var focusTask: Task<Void, Never>? = nil
|
|
@AppStorage("TrainingOptIn") var isTrainingOptIn = false
|
|
@State private var showManualEdit = false
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
Group {
|
|
if vm.isPermissionDenied {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "camera.fill.badge.ellipsis").font(.system(size: 60)).foregroundColor(.red)
|
|
Text("Camera Access Required").font(.title2).bold()
|
|
Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } }.buttonStyle(.borderedProminent)
|
|
}
|
|
.padding().background(.ultraThinMaterial).cornerRadius(16).frame(maxHeight: .infinity)
|
|
} else if vm.isDatabaseLoading || vm.isCollectionLoading {
|
|
VStack { ProgressView().scaleEffect(2).tint(.white); Text(vm.statusText).padding(.top).font(.caption).foregroundColor(.white) }.frame(maxHeight: .infinity)
|
|
} else {
|
|
ZStack {
|
|
GeometryReader { geo in
|
|
CameraPreview(session: vm.session)
|
|
.onTapGesture { location in
|
|
let x = location.x / geo.size.width
|
|
let y = location.y / geo.size.height
|
|
vm.focusCamera(at: CGPoint(x: y, y: 1 - x))
|
|
focusTask?.cancel()
|
|
focusTask = Task {
|
|
withAnimation { focusPoint = location }
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
withAnimation { focusPoint = nil }
|
|
}
|
|
}
|
|
}
|
|
.edgesIgnoringSafeArea(.all).opacity(0.8)
|
|
if let point = focusPoint { Circle().stroke(Color.yellow, lineWidth: 2).frame(width: 60, height: 60).position(point).transition(.opacity) }
|
|
Image("scanner_frame").resizable().scaledToFit().frame(width: 300)
|
|
.colorMultiply(vm.isFound ? .green : (vm.isProcessing ? .yellow : .white))
|
|
.opacity(0.8).allowsHitTesting(false).animation(.easeInOut(duration: 0.2), value: vm.isFound)
|
|
|
|
VStack {
|
|
Spacer()
|
|
if !vm.isProcessing && !vm.isFound { Text(vm.statusText).padding().background(.ultraThinMaterial).cornerRadius(8).padding(.bottom, 20) }
|
|
if let card = vm.detectedCard {
|
|
VStack(spacing: 12) {
|
|
Text(card.name).font(.title).bold().multilineTextAlignment(.center).minimumScaleFactor(0.5).foregroundColor(.black)
|
|
if card.isSerialized { Text("SERIALIZED").font(.headline).bold().foregroundColor(.purple).padding(4).overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.purple)) }
|
|
Text("Foil: \(vm.currentFoilType)").font(.caption).foregroundColor(.orange)
|
|
Text("Cond: \(ConditionEngine.overallGrade(damages: vm.detectedDamages))").font(.caption).foregroundColor(.gray)
|
|
HStack {
|
|
Button(action: { vm.cancelScan() }) { Image(systemName: "xmark.circle.fill").font(.title).foregroundColor(.gray) }.disabled(vm.isSaving)
|
|
Button(action: { vm.saveCurrentCard() }) { if vm.isSaving { ProgressView().tint(.white) } else { Text("Save") } }.buttonStyle(.borderedProminent).disabled(vm.isSaving)
|
|
Button(action: { showManualEdit = true }) { Image(systemName: "pencil.circle.fill").font(.title).foregroundColor(.blue) }.disabled(vm.isSaving)
|
|
}
|
|
Menu {
|
|
Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") }
|
|
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { isTrainingOptIn.toggle() }
|
|
} label: {
|
|
Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue)
|
|
}
|
|
}.padding().background(Color.white).cornerRadius(15).padding().shadow(radius: 10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
VStack {
|
|
HStack {
|
|
Image("logo_header").resizable().scaledToFit().frame(height: 32)
|
|
.onTapGesture {
|
|
#if ENABLE_DEV_MODE
|
|
devModeTapCount += 1
|
|
if devModeTapCount >= 5 { vm.statusText = "DEV MODE: CAPTURING" }
|
|
#endif
|
|
}
|
|
Spacer()
|
|
Button(action: { vm.isAutoScanEnabled.toggle() }) { Image(systemName: vm.isAutoScanEnabled ? "bolt.badge.a.fill" : "bolt.badge.a").foregroundColor(vm.isAutoScanEnabled ? .green : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
|
|
Button(action: { vm.toggleTorch() }) { Image(systemName: vm.isTorchOn ? "bolt.fill" : "bolt.slash.fill").foregroundColor(vm.isTorchOn ? .yellow : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
|
|
if !vm.isConnected { Image(systemName: "cloud.slash.fill").foregroundColor(.red).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
|
|
Menu {
|
|
ForEach(vm.collections, id: \.self) { name in Button(name) { vm.currentCollection = name } }
|
|
Button("New Collection...") { vm.collectionVM.collections.append("New Binder"); vm.currentCollection = "New Binder" }
|
|
} label: { HStack { Image(systemName: "folder.fill").font(.caption); Text(vm.currentCollection).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) }
|
|
Menu {
|
|
ForEach(vm.boxes, id: \.self) { name in Button(name) { vm.currentBox = name } }
|
|
Button("New Box/Deck...") { vm.collectionVM.boxes.append("New Box"); vm.currentBox = "New Box" }
|
|
} label: { HStack { Image(systemName: "archivebox.fill").font(.caption); Text(vm.currentBox).font(.caption).bold() }.padding(.horizontal, 12).padding(.vertical, 6).background(.ultraThinMaterial).clipShape(Capsule()) }
|
|
}.padding(.top, 60).padding(.horizontal)
|
|
Spacer()
|
|
}
|
|
}
|
|
.onAppear { vm.startSession() }
|
|
.onDisappear { vm.stopSession() }
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active { vm.checkCameraPermissions() }
|
|
else if newPhase == .background { vm.stopSession() }
|
|
}
|
|
.sheet(isPresented: $showManualEdit) {
|
|
if let detected = vm.detectedCard, let cg = vm.currentFrameImage {
|
|
let orientation = UIImage.Orientation.right
|
|
let img = UIImage(cgImage: cg, scale: 1.0, orientation: orientation)
|
|
let tempFileName = "\(UUID().uuidString).jpg"
|
|
let _ = try? ImageManager.save(img, name: tempFileName)
|
|
let tempCard = SavedCard(from: detected, imageName: tempFileName, collection: vm.currentCollection, location: vm.currentBox)
|
|
CardDetailView(card: tempCard, vm: vm.collectionVM, scannerVM: vm, isNewEntry: true)
|
|
}
|
|
}
|
|
.alert(vm.databaseAlertTitle, isPresented: $vm.showDatabaseAlert) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(vm.databaseAlertMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CameraPreview: UIViewRepresentable {
|
|
let session: AVCaptureSession
|
|
func makeUIView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
view.videoPreviewLayer.session = session
|
|
view.videoPreviewLayer.videoGravity = .resizeAspectFill
|
|
return view
|
|
}
|
|
func updateUIView(_ uiView: PreviewView, context: Context) {}
|
|
class PreviewView: UIView {
|
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
|
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
|
override func layoutSubviews() { super.layoutSubviews(); videoPreviewLayer.frame = bounds }
|
|
}
|
|
}
|
|
|
|
// MARK: - COLLECTION VIEW
|
|
struct CollectionView: View {
|
|
@ObservedObject var vm: CollectionViewModel
|
|
var scannerVM: ScannerViewModel // Passed through to CardDetailView for uploadCorrection
|
|
@ObservedObject var store: StoreEngine
|
|
@State private var showShare = false
|
|
@State private var shareItem: Any?
|
|
@State private var editingCard: SavedCard?
|
|
@State private var isSharing = false
|
|
|
|
func getPriceColor(_ card: SavedCard) -> Color {
|
|
if card.isCustomValuation { return .yellow }
|
|
guard let curr = card.currentValuation, let prev = card.previousValuation else { return .white }
|
|
if curr > prev { return .green }; if curr < prev { return .red }
|
|
return .white
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
if vm.isCollectionLoading { ProgressView("Loading Collection...") }
|
|
else if vm.scannedList.isEmpty { VStack(spacing: 20) { Image("empty_library").resizable().scaledToFit().frame(width: 250).opacity(0.8); Text("No Cards Yet").font(.title2).bold(); Text("Start scanning to build your collection.").foregroundColor(.gray) } }
|
|
else if vm.filteredList.isEmpty && !vm.librarySearchText.isEmpty { VStack(spacing: 10) { Image(systemName: "magnifyingglass").font(.largeTitle).foregroundColor(.gray); Text("No results for '\(vm.librarySearchText)'").foregroundColor(.gray) }.frame(maxHeight: .infinity) }
|
|
else if vm.filteredList.isEmpty { VStack(spacing: 20) { Image(systemName: "folder.badge.plus").font(.system(size: 60)).foregroundColor(.gray); Text("No Cards in \(vm.currentCollection)").font(.title3).bold(); Text("Scan cards to add them here.").foregroundColor(.gray) } }
|
|
else {
|
|
List {
|
|
Section { HStack { Text("Total Value").font(.headline); Spacer(); Text("\(vm.selectedCurrency.symbol)\(vm.totalValue, specifier: "%.2f")").bold().foregroundColor(.green) } }
|
|
Section(header: Text("Settings")) {
|
|
Picker("Market Region", selection: $vm.selectedCurrency) { ForEach(CurrencyCode.allCases, id: \.self) { curr in Text(curr.rawValue).tag(curr) } }.onChange(of: vm.selectedCurrency) { _, _ in vm.refreshPrices(force: true) }
|
|
Button(action: {
|
|
let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred()
|
|
vm.refreshPrices(force: true)
|
|
}) {
|
|
HStack {
|
|
Text("Refresh Prices")
|
|
if vm.isRefreshingPrices { ProgressView().tint(.accentColor) }
|
|
}
|
|
}
|
|
.disabled(vm.isRefreshingPrices)
|
|
Toggle("Share Data for Training", isOn: Binding(get: { AppConfig.isTrainingOptIn }, set: { AppConfig.isTrainingOptIn = $0 }))
|
|
Button("Contact Support") { if let url = URL(string: "mailto:\(AppConfig.contactEmail)") { UIApplication.shared.open(url) } }
|
|
}
|
|
Section(header: Text("Cloud Backup")) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Label("Primary: iCloud (automatic, includes images)", systemImage: "icloud.fill")
|
|
.font(.caption).foregroundColor(.green)
|
|
Label("Secondary: Firebase (metadata only — no images)", systemImage: "cylinder.split.1x2")
|
|
.font(.caption).foregroundColor(.secondary)
|
|
}
|
|
.padding(.vertical, 2)
|
|
Button(action: { vm.backupAllToFirebase() }) {
|
|
HStack {
|
|
Text("Backup Metadata to Firebase Now")
|
|
if vm.isBackingUpToFirebase { ProgressView().tint(.accentColor) }
|
|
}
|
|
}
|
|
.disabled(vm.isBackingUpToFirebase)
|
|
}
|
|
Section(header: Text("Support the App")) { ForEach(store.products) { p in Button("Tip \(p.displayPrice)") { Task { await store.purchase(p) } } } }
|
|
ForEach(vm.filteredList) { card in
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
HStack { Text(card.name).font(.headline); if card.isSerialized ?? false { Image(systemName: "number.circle.fill").foregroundColor(.purple).font(.caption) } }
|
|
Text("\(card.condition) • \(card.foilType)" + (card.gradingService != nil ? " • \(card.gradingService ?? "graded") \(card.grade ?? "")" : "")).font(.caption).foregroundColor(.gray)
|
|
}
|
|
Spacer()
|
|
VStack(alignment: .trailing) {
|
|
if vm.isConnected {
|
|
if let val = card.currentValuation, card.currencyCode == vm.selectedCurrency.rawValue {
|
|
Text("\(vm.selectedCurrency.symbol)\(val, specifier: "%.2f")").foregroundColor(getPriceColor(card))
|
|
} else { Text("Updating...").foregroundColor(.gray).font(.caption) }
|
|
} else { Text("Offline").foregroundColor(.red).font(.caption2) }
|
|
Text(card.storageLocation).font(.caption2)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button("Edit Details / Graded") { editingCard = card }
|
|
Button("Move to Binder 1") { vm.moveCard(card, toCollection: "Binder 1", toBox: "Page 1") }
|
|
Button("Share") { Task { isSharing = true; shareItem = await vm.generateShareImage(for: card); isSharing = false; if shareItem != nil { showShare = true } } }
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { vm.deleteCard(card) } label: { Label("Delete", systemImage: "trash") } }
|
|
}
|
|
}
|
|
.searchable(text: $vm.librarySearchText).autocorrectionDisabled().textInputAutocapitalization(.never).scrollDismissesKeyboard(.immediately).navigationTitle("Library")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
NavigationLink(destination: TrainingGuideView()) {
|
|
Image(systemName: "questionmark.circle")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Menu {
|
|
Picker("Sort By", selection: $vm.sortOption) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.rawValue).tag(option) } }
|
|
Divider()
|
|
Button("Export PDF (Insurance)") { Task { shareItem = await vm.exportCollection(format: .insurance); if shareItem != nil { showShare = true } } }
|
|
Button("Export CSV (Moxfield/Archidekt)") { Task { shareItem = await vm.exportCollection(format: .csv); if shareItem != nil { showShare = true } } }
|
|
Divider()
|
|
Menu("Export Arena") {
|
|
Button("Copy to Clipboard") { vm.copyToClipboard(format: .arena) }
|
|
Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .arena); if shareItem != nil { showShare = true } } }
|
|
}
|
|
Menu("Export MTGO") {
|
|
Button("Copy to Clipboard") { vm.copyToClipboard(format: .mtgo) }
|
|
Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .mtgo); if shareItem != nil { showShare = true } } }
|
|
}
|
|
} label: { Label("Options", systemImage: "ellipsis.circle") }
|
|
}
|
|
}
|
|
.overlay { if isSharing { ProgressView().padding().background(.ultraThinMaterial).cornerRadius(10) } }
|
|
.sheet(isPresented: $showShare) { if let i = shareItem { ShareSheet(items: [i]) } }
|
|
.sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm, scannerVM: scannerVM) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SHARE SHEET
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
var items: [Any]
|
|
func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: items, applicationActivities: nil) }
|
|
func updateUIViewController(_ ui: UIActivityViewController, context: Context) {}
|
|
}
|
|
|
|
// MARK: - WELCOME VIEW
|
|
struct WelcomeView: View {
|
|
@Binding var hasLaunchedBefore: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 40) {
|
|
Image(systemName: "viewfinder.circle.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(Color(red: 0.6, green: 0.3, blue: 0.9))
|
|
|
|
Text("Welcome to IYmtg")
|
|
.font(.largeTitle)
|
|
.bold()
|
|
|
|
VStack(alignment: .leading, spacing: 25) {
|
|
FeatureRow(icon: "camera.viewfinder", title: "Smart Scanning", text: "Point at any card. We detect Set, Condition, and Foil automatically.")
|
|
FeatureRow(icon: "bolt.badge.a.fill", title: "Auto-Scan Mode", text: "Tap the lightning icon in the scanner to enable rapid-fire bulk entry.")
|
|
FeatureRow(icon: "tray.full.fill", title: "Organize", text: "Select your Binder and Box from the top menu to sort as you scan.")
|
|
FeatureRow(icon: "lock.shield.fill", title: "Private & Secure", text: "Your collection data is backed up securely to the cloud. Your card photos stay on your device.")
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { hasLaunchedBefore = true }) {
|
|
Text("Get Started")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color(red: 0.6, green: 0.3, blue: 0.9))
|
|
.foregroundColor(.white)
|
|
.cornerRadius(12)
|
|
}
|
|
.padding()
|
|
}
|
|
.padding(.top, 50)
|
|
.interactiveDismissDisabled()
|
|
}
|
|
}
|
|
|
|
struct FeatureRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let text: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 15) {
|
|
Image(systemName: icon).font(.title).foregroundColor(Color(red: 0.6, green: 0.3, blue: 0.9)).frame(width: 40)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title).font(.headline)
|
|
Text(text).font(.subheadline).foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
}
|