Implements a new UI to show recommended image counts for ML training. Uses color-coded indicators (orange/green/blue) for Functional, Solid, and High-Accuracy thresholds across all 28 training categories (Foil, Stamp, and Condition models). Critical damage types (Inking, Rips, Water Damage) carry higher recommended counts to minimise false positives on NM grades. Accessible via a "?" toolbar button in Library. Bumps app version to 1.1.0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
469 lines
28 KiB
Swift
469 lines
28 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: - 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)
|
|
}
|
|
}
|
|
}
|
|
}
|