From 13753359b32d60cb1c14f26a98f9913a5a66c91d Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Thu, 5 Mar 2026 20:22:45 -0500 Subject: [PATCH] fix: Resolve all audit issues from project readiness review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- IYmtg_App_iOS/AppConfig.swift | 8 +- IYmtg_App_iOS/ContentView.swift | 86 +------------------ .../Data/Persistence/PersistenceActor.swift | 12 --- .../Features/CardDetail/CardDetailView.swift | 84 ++++++++++++++++++ .../Collection/CollectionViewModel.swift | 1 + IYmtg_App_iOS/IYmtgTests.swift | 53 ++++++------ .../Services/Cloud/CloudEngine.swift | 21 ----- .../Services/CoreML/ModelManager.swift | 5 ++ 8 files changed, 125 insertions(+), 145 deletions(-) delete mode 100644 IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift create mode 100644 IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift diff --git a/IYmtg_App_iOS/AppConfig.swift b/IYmtg_App_iOS/AppConfig.swift index 18146a9..4894ab9 100644 --- a/IYmtg_App_iOS/AppConfig.swift +++ b/IYmtg_App_iOS/AppConfig.swift @@ -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 { diff --git a/IYmtg_App_iOS/ContentView.swift b/IYmtg_App_iOS/ContentView.swift index d48aead..9f38ca8 100644 --- a/IYmtg_App_iOS/ContentView.swift +++ b/IYmtg_App_iOS/ContentView.swift @@ -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] diff --git a/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift b/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift deleted file mode 100644 index 7ce18cf..0000000 --- a/IYmtg_App_iOS/Data/Persistence/PersistenceActor.swift +++ /dev/null @@ -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. diff --git a/IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift b/IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift new file mode 100644 index 0000000..2333f8e --- /dev/null +++ b/IYmtg_App_iOS/Features/CardDetail/CardDetailView.swift @@ -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() + } +} diff --git a/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift b/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift index a542e6f..b6585cc 100644 --- a/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift +++ b/IYmtg_App_iOS/Features/Collection/CollectionViewModel.swift @@ -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() } diff --git a/IYmtg_App_iOS/IYmtgTests.swift b/IYmtg_App_iOS/IYmtgTests.swift index 77fa213..dda94a0 100644 --- a/IYmtg_App_iOS/IYmtgTests.swift +++ b/IYmtg_App_iOS/IYmtgTests.swift @@ -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) } } diff --git a/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift b/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift index a1f7e60..919d06f 100644 --- a/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift +++ b/IYmtg_App_iOS/Services/Cloud/CloudEngine.swift @@ -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..