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? = 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: - 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() } } // 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) } } } }