fix: Resolve all audit issues from project readiness review

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>
This commit is contained in:
2026-03-05 20:22:45 -05:00
parent e18a1080de
commit 13753359b3
8 changed files with 125 additions and 145 deletions

View File

@@ -20,8 +20,9 @@ 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
@@ -62,7 +63,8 @@ struct AppConfig {
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 tipJarProductIDs.isEmpty { if tipJarProductIDs.isEmpty {

View File

@@ -169,7 +169,7 @@ 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() }
} }
@@ -234,7 +234,7 @@ 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) }
Button(action: { Button(action: {
let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred() let haptic = UIImpactFeedbackGenerator(style: .medium); haptic.impactOccurred()
vm.refreshPrices(force: true) vm.refreshPrices(force: true)
@@ -322,88 +322,6 @@ struct CollectionView: View {
} }
} }
// 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 // MARK: - SHARE SHEET
struct ShareSheet: UIViewControllerRepresentable { struct ShareSheet: UIViewControllerRepresentable {
var items: [Any] var items: [Any]

View File

@@ -1,12 +0,0 @@
import Foundation
// MARK: - PERSISTENCE ACTOR (Superseded)
// This file is kept as a placeholder. The functionality has been replaced by:
// - BackgroundPersistenceActor (in Data/Persistence/PersistenceController.swift)
// - PersistenceController (in Data/Persistence/PersistenceController.swift)
//
// The new architecture uses SwiftData with automatic iCloud CloudKit synchronization,
// replacing the manual single-file JSON sync. One-time migration from the legacy
// user_collection.json is handled in BackgroundPersistenceActor.migrateFromJSONIfNeeded().
//
// ACTION: Remove this file from the Xcode project navigator once confirmed unused.

View 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()
}
}

View File

@@ -272,6 +272,7 @@ class CollectionViewModel: ObservableObject {
if let idx = scannedList.firstIndex(where: { $0.id == card.id }) { if let idx = scannedList.firstIndex(where: { $0.id == card.id }) {
let fileName = scannedList[idx].imageFileName let fileName = scannedList[idx].imageFileName
Task { await ImageManager.delete(name: fileName) } Task { await ImageManager.delete(name: fileName) }
Task { await CloudEngine.delete(card: card) }
scannedList.remove(at: idx) scannedList.remove(at: idx)
self.saveCollectionAsync() self.saveCollectionAsync()
} }

View File

@@ -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)
} }
} }

View File

@@ -46,25 +46,4 @@ class CloudEngine {
} catch { print("Cloud Delete Error: \(error)") } } catch { print("Cloud Delete Error: \(error)") }
} }
static func batchUpdatePrices(cards: [SavedCard]) async {
guard let db = db, let uid = Auth.auth().currentUser?.uid else { return }
let ref = db.collection("users").document(uid).collection("inventory")
let chunks = stride(from: 0, to: cards.count, by: 400).map { Array(cards[$0..<min($0 + 400, cards.count)]) }
for chunk in chunks {
let batch = db.batch()
for card in chunk {
let data: [String: Any] = [
"val": card.currentValuation ?? 0.0,
"curr": card.currencyCode ?? "USD",
"rarity": card.rarity ?? "",
"colors": card.colorIdentity ?? []
]
batch.setData(data, forDocument: ref.document(card.id.uuidString), merge: true)
}
do {
try await batch.commit()
} catch { print("Batch Update Error: \(error)") }
}
}
} }

View File

@@ -1,5 +1,6 @@
import CoreML import CoreML
import Vision import Vision
import FirebaseCore
import FirebaseStorage import FirebaseStorage
struct SharedEngineResources { struct SharedEngineResources {
@@ -7,6 +8,10 @@ struct SharedEngineResources {
} }
// MARK: - MODEL MANAGER (OTA Updates) // 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 { class ModelManager {
static let shared = ModelManager() static let shared = ModelManager()
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard