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:
@@ -20,8 +20,9 @@ enum CurrencyCode: String, CaseIterable, Codable {
|
||||
}
|
||||
|
||||
struct AppConfig {
|
||||
// 1. CONTACT EMAIL (Required by Scryfall)
|
||||
static let contactEmail = "support@iymtg.com" // Example: Use your real email
|
||||
// 1. CONTACT EMAIL (Required by Scryfall API policy)
|
||||
// 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)
|
||||
static let tipJarProductIDs: [String] = [] // Example: Use your real Product ID
|
||||
@@ -62,7 +63,8 @@ struct AppConfig {
|
||||
|
||||
static func validate() {
|
||||
#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.")
|
||||
}
|
||||
if tipJarProductIDs.isEmpty {
|
||||
|
||||
@@ -169,7 +169,7 @@ struct ScannerView: View {
|
||||
}
|
||||
.onAppear { vm.startSession() }
|
||||
.onDisappear { vm.stopSession() }
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .active { vm.checkCameraPermissions() }
|
||||
else if newPhase == .background { vm.stopSession() }
|
||||
}
|
||||
@@ -234,7 +234,7 @@ struct CollectionView: View {
|
||||
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) }
|
||||
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)
|
||||
@@ -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
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
var items: [Any]
|
||||
|
||||
@@ -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.
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,7 @@ class CollectionViewModel: ObservableObject {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -107,43 +107,46 @@ final class IYmtgTests: XCTestCase {
|
||||
}
|
||||
|
||||
// MARK: - ViewModel Logic Tests
|
||||
// CollectionViewModel owns filtering and portfolio logic; tests target it directly.
|
||||
|
||||
@MainActor
|
||||
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 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")
|
||||
|
||||
vm.scannedList = [card1, card2]
|
||||
|
||||
// Test Search
|
||||
vm.librarySearchText = "Alpha"
|
||||
|
||||
// Wait for async recalc
|
||||
// Inject test data after the async SwiftData init load has completed.
|
||||
let expectation = XCTestExpectation(description: "Filter updates")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(vm.filteredList.count, 1)
|
||||
XCTAssertEqual(vm.filteredList.first?.name, "Alpha")
|
||||
expectation.fulfill()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
vm.scannedList = [card1, card2]
|
||||
vm.librarySearchText = "Alpha"
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(vm.filteredList.count, 1)
|
||||
XCTAssertEqual(vm.filteredList.first?.name, "Alpha")
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
wait(for: [expectation], timeout: 2.0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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 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")
|
||||
|
||||
vm.scannedList = [card1, card2]
|
||||
|
||||
let expectation = XCTestExpectation(description: "Stats update")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(vm.portfolioValue, 200.0)
|
||||
expectation.fulfill()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
vm.scannedList = [card1, card2]
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
XCTAssertEqual(vm.portfolioValue, 200.0)
|
||||
expectation.fulfill()
|
||||
}
|
||||
}
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
wait(for: [expectation], timeout: 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,25 +46,4 @@ class CloudEngine {
|
||||
} 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)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import CoreML
|
||||
import Vision
|
||||
import FirebaseCore
|
||||
import FirebaseStorage
|
||||
|
||||
struct SharedEngineResources {
|
||||
@@ -7,6 +8,10 @@ struct SharedEngineResources {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user