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 {
|
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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 }) {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
vm.scannedList = [card1, card2]
|
// Inject test data after the async SwiftData init load has completed.
|
||||||
|
|
||||||
// Test Search
|
|
||||||
vm.librarySearchText = "Alpha"
|
|
||||||
|
|
||||||
// Wait for async recalc
|
|
||||||
let expectation = XCTestExpectation(description: "Filter updates")
|
let expectation = XCTestExpectation(description: "Filter updates")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
XCTAssertEqual(vm.filteredList.count, 1)
|
vm.scannedList = [card1, card2]
|
||||||
XCTAssertEqual(vm.filteredList.first?.name, "Alpha")
|
vm.librarySearchText = "Alpha"
|
||||||
expectation.fulfill()
|
|
||||||
|
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
|
@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")
|
||||||
|
|
||||||
vm.scannedList = [card1, card2]
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Stats update")
|
let expectation = XCTestExpectation(description: "Stats update")
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
XCTAssertEqual(vm.portfolioValue, 200.0)
|
vm.scannedList = [card1, card2]
|
||||||
expectation.fulfill()
|
|
||||||
|
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)") }
|
} 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 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
|
||||||
|
|||||||
Reference in New Issue
Block a user