Implement storage architecture from ai_blueprint.md
Primary sync: replace PersistenceActor JSON file with SwiftData + CloudKit - Add SavedCardModel (@Model class) and PersistenceController (ModelContainer with .automatic CloudKit, fallback to local). BackgroundPersistenceActor (@ModelActor) handles all DB I/O off the main thread. - One-time migration imports user_collection.json into SwiftData and renames the original file to prevent re-import. - Inject modelContainer into SwiftUI environment in IYmtgApp. Image storage: Documents/UserContent/ subfolder (blueprint requirement) - ImageManager.dir now targets iCloud Documents/UserContent/ (or local equiv). - migrateImagesToUserContent() moves existing JPGs to the new subfolder on first launch; called during the SwiftData migration. Firebase: demoted to optional manual backup (metadata only, no images) - Remove all automatic CloudEngine.save/delete/batchUpdatePrices calls from CollectionViewModel mutations. - Add backupAllToFirebase() for user-triggered metadata sync. - Add isFirebaseBackupEnabled to AppConfig (default false). - Add Cloud Backup section in Library settings with iCloud vs Firebase explanation and "Backup Metadata to Firebase Now" button. Also: full modular refactor (Data/, Features/, Services/ directories) and README updated with CloudKit setup steps and revised release checklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,15 +4,25 @@ import AVFoundation
|
||||
import StoreKit
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject var vm = ScannerViewModel()
|
||||
@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: vm).tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") }
|
||||
ScannerView(vm: vm).tabItem { Label("Scan", systemImage: "viewfinder") }
|
||||
CollectionView(vm: vm, store: store).tabItem { Label("Library", systemImage: "tray.full.fill") }
|
||||
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() }
|
||||
@@ -25,10 +35,10 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// DASHBOARD VIEW
|
||||
// MARK: - DASHBOARD VIEW
|
||||
struct DashboardView: View {
|
||||
@ObservedObject var vm: ScannerViewModel
|
||||
|
||||
@ObservedObject var vm: CollectionViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
@@ -43,7 +53,7 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section(header: Text("Top Movers")) {
|
||||
if vm.topMovers.isEmpty { Text("No price changes detected today.").foregroundColor(.gray) }
|
||||
ForEach(vm.topMovers) { card in
|
||||
@@ -61,7 +71,7 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// SCANNER VIEW
|
||||
// MARK: - SCANNER VIEW
|
||||
struct ScannerView: View {
|
||||
@ObservedObject var vm: ScannerViewModel
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@@ -70,7 +80,7 @@ struct ScannerView: View {
|
||||
@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 {
|
||||
@@ -101,10 +111,10 @@ struct ScannerView: View {
|
||||
}
|
||||
.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: geo.size.width * 0.75)
|
||||
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) }
|
||||
@@ -121,9 +131,7 @@ struct ScannerView: View {
|
||||
}
|
||||
Menu {
|
||||
Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") }
|
||||
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") {
|
||||
isTrainingOptIn.toggle()
|
||||
}
|
||||
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { isTrainingOptIn.toggle() }
|
||||
} label: {
|
||||
Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue)
|
||||
}
|
||||
@@ -133,7 +141,7 @@ struct ScannerView: View {
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Image("logo_header").resizable().scaledToFit().frame(height: 32)
|
||||
@@ -149,11 +157,11 @@ struct ScannerView: View {
|
||||
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.collections.append("New Binder"); vm.currentCollection = "New Binder" }
|
||||
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.boxes.append("New Box"); vm.currentBox = "New Box" }
|
||||
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()
|
||||
@@ -167,14 +175,19 @@ struct ScannerView: View {
|
||||
}
|
||||
.sheet(isPresented: $showManualEdit) {
|
||||
if let detected = vm.detectedCard, let cg = vm.currentFrameImage {
|
||||
let orientation = UIImage.Orientation.right // Default for simplicity in edit mode
|
||||
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, isNewEntry: true)
|
||||
CardDetailView(card: tempCard, vm: vm.collectionVM, scannerVM: vm, isNewEntry: true)
|
||||
}
|
||||
}
|
||||
.alert(vm.databaseAlertTitle, isPresented: $vm.showDatabaseAlert) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(vm.databaseAlertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,22 +207,23 @@ struct CameraPreview: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// COLLECTION VIEW
|
||||
// MARK: - COLLECTION VIEW
|
||||
struct CollectionView: View {
|
||||
@ObservedObject var vm: ScannerViewModel
|
||||
@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...") }
|
||||
@@ -221,14 +235,35 @@ struct CollectionView: View {
|
||||
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) }
|
||||
// V1.1.0 FIX: Haptic on Press
|
||||
Button("Refresh Prices") {
|
||||
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 {
|
||||
@@ -242,12 +277,15 @@ struct CollectionView: View {
|
||||
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) }
|
||||
} 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 } } } }
|
||||
.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") } }
|
||||
}
|
||||
}
|
||||
@@ -273,16 +311,17 @@ struct CollectionView: View {
|
||||
}
|
||||
.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) }
|
||||
.sheet(item: $editingCard) { card in CardDetailView(card: card, vm: vm, scannerVM: scannerVM) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CARD DETAIL VIEW
|
||||
// MARK: - CARD DETAIL VIEW
|
||||
struct CardDetailView: View {
|
||||
@State var card: SavedCard
|
||||
var vm: ScannerViewModel
|
||||
@ObservedObject var vm: CollectionViewModel
|
||||
var scannerVM: ScannerViewModel
|
||||
var isNewEntry: Bool = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@FocusState private var isInputActive: Bool
|
||||
@@ -291,7 +330,7 @@ struct CardDetailView: View {
|
||||
@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 {
|
||||
@@ -310,17 +349,21 @@ struct CardDetailView: View {
|
||||
}
|
||||
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 })) }
|
||||
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 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) }
|
||||
} else { Text("Market Price: Unavailable (Offline)").foregroundColor(.red) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,56 +378,58 @@ struct CardDetailView: View {
|
||||
if originalCard == nil { originalCard = card }
|
||||
}
|
||||
.alert("Contribute Correction?", isPresented: $showContributionAlert) {
|
||||
Button("Send Image", role: .none) { vm.uploadCorrection(image: displayImage, card: card, original: originalCard); finishSave() }
|
||||
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 { vm.uploadCorrection(image: displayImage, card: card, original: originalCard) }
|
||||
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, but your photos stay private in iCloud.")
|
||||
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)
|
||||
@@ -405,7 +450,7 @@ 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)
|
||||
@@ -415,4 +460,4 @@ struct FeatureRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user