From 5da5614a10fe62cb21faa4dc1803abfefe32b857 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Thu, 5 Mar 2026 13:21:45 -0500 Subject: [PATCH] feat: Create dynamic in-app training status guide Implements a new UI to show recommended image counts for ML training. Uses color-coded indicators (orange/green/blue) for Functional, Solid, and High-Accuracy thresholds across all 28 training categories (Foil, Stamp, and Condition models). Critical damage types (Inking, Rips, Water Damage) carry higher recommended counts to minimise false positives on NM grades. Accessible via a "?" toolbar button in Library. Bumps app version to 1.1.0. Co-Authored-By: Claude Sonnet 4.6 --- IYmtg_App_iOS/AppConfig.swift | 2 +- IYmtg_App_iOS/ContentView.swift | 5 + .../Features/Help/Models/TrainingStatus.swift | 66 +++++++++++ .../Features/Help/TrainingGuideView.swift | 104 ++++++++++++++++++ README.md | 4 +- 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift create mode 100644 IYmtg_App_iOS/Features/Help/TrainingGuideView.swift diff --git a/IYmtg_App_iOS/AppConfig.swift b/IYmtg_App_iOS/AppConfig.swift index b48a941..18146a9 100644 --- a/IYmtg_App_iOS/AppConfig.swift +++ b/IYmtg_App_iOS/AppConfig.swift @@ -27,7 +27,7 @@ struct AppConfig { static let tipJarProductIDs: [String] = [] // Example: Use your real Product ID // 3. VERSIONING - static let appVersion = "1.0.0" // Follows Semantic Versioning (Major.Minor.Patch) + static let appVersion = "1.1.0" // Follows Semantic Versioning (Major.Minor.Patch) static let buildNumber = "2" // Increments with each build submitted to App Store Connect // Feature Flags diff --git a/IYmtg_App_iOS/ContentView.swift b/IYmtg_App_iOS/ContentView.swift index bb847d3..d48aead 100644 --- a/IYmtg_App_iOS/ContentView.swift +++ b/IYmtg_App_iOS/ContentView.swift @@ -291,6 +291,11 @@ struct CollectionView: View { } .searchable(text: $vm.librarySearchText).autocorrectionDisabled().textInputAutocapitalization(.never).scrollDismissesKeyboard(.immediately).navigationTitle("Library") .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + NavigationLink(destination: TrainingGuideView()) { + Image(systemName: "questionmark.circle") + } + } ToolbarItem(placement: .navigationBarTrailing) { Menu { Picker("Sort By", selection: $vm.sortOption) { ForEach(SortOption.allCases, id: \.self) { option in Text(option.rawValue).tag(option) } } diff --git a/IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift b/IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift new file mode 100644 index 0000000..4eb3b71 --- /dev/null +++ b/IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift @@ -0,0 +1,66 @@ +// IYmtg_App_iOS/Features/Help/Models/TrainingStatus.swift +import SwiftUI + +struct TrainingCategory: Identifiable { + let id = UUID() + let name: String + let group: String + let functionalCount: Int + let solidCount: Int + let highAccuracyCount: Int +} + +class TrainingGuideViewModel: ObservableObject { + @Published var categories: [TrainingCategory] = [ + // MARK: Foil Model (Image Classification) + // 13 classes — false negatives cause wrong foil label; all classes need balanced data. + .init(name: "NonFoil", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Traditional", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Etched", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "PreModern", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Textured", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Galaxy", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Surge", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Oil Slick", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Step & Compleat", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Halo", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Confetti", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Neon Ink", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Fracture", group: "Foil Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + + // MARK: Stamp Model (Image Classification) + // Detects promo stamp presence. Binary classification — easy to train quickly. + .init(name: "Stamped (Promo)", group: "Stamp Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + .init(name: "Clean (No Stamp)", group: "Stamp Model", functionalCount: 15, solidCount: 50, highAccuracyCount: 100), + + // MARK: Condition Model (Object Detection — requires bounding-box annotations in Create ML) + // Higher minimums than image classification because each image needs manual annotation. + // Surface defects + .init(name: "Light Scratches", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Clouding", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Dirt", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Dents", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + // Edge defects + .init(name: "Whitening (Edges)", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Chipping (Edges)", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Corner Wear", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + // Structure defects + .init(name: "Creases", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Shuffle Bend", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + .init(name: "Binder Dents", group: "Condition Model", functionalCount: 20, solidCount: 50, highAccuracyCount: 100), + // Critical damage — a single false positive will downgrade a NM card to Damaged. + // Train these more aggressively to minimise false positives. + .init(name: "Water Damage", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150), + .init(name: "Inking", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150), + .init(name: "Rips", group: "Condition Model", functionalCount: 20, solidCount: 75, highAccuracyCount: 150), + ] + + var groups: [String] { + var seen = Set() + return categories.compactMap { seen.insert($0.group).inserted ? $0.group : nil } + } + + func categories(for group: String) -> [TrainingCategory] { + categories.filter { $0.group == group } + } +} diff --git a/IYmtg_App_iOS/Features/Help/TrainingGuideView.swift b/IYmtg_App_iOS/Features/Help/TrainingGuideView.swift new file mode 100644 index 0000000..a970e73 --- /dev/null +++ b/IYmtg_App_iOS/Features/Help/TrainingGuideView.swift @@ -0,0 +1,104 @@ +// IYmtg_App_iOS/Features/Help/TrainingGuideView.swift +import SwiftUI + +struct TrainingGuideView: View { + @StateObject private var viewModel = TrainingGuideViewModel() + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 6) { + Text("These are the recommended image counts for each ML training category. Collect cropped card photos and drop them into the matching `IYmtg_Training/` subfolder.") + .font(.caption) + .foregroundColor(.secondary) + HStack(spacing: 16) { + LegendChip(color: .orange, label: "Functional") + LegendChip(color: .green, label: "Solid") + LegendChip(color: .blue, label: "High-Accuracy") + } + .padding(.top, 2) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + + ForEach(viewModel.groups, id: \.self) { group in + Section(header: GroupHeader(title: group)) { + ForEach(viewModel.categories(for: group)) { category in + TrainingCategoryRow(category: category) + } + } + } + } + .navigationTitle("Training Guide") + .listStyle(.insetGrouped) + } +} + +// MARK: - Supporting Views + +private struct GroupHeader: View { + let title: String + + private var icon: String { + switch title { + case "Foil Model": return "sparkles" + case "Stamp Model": return "seal.fill" + case "Condition Model": return "exclamationmark.triangle" + default: return "circle.grid.2x2" + } + } + + var body: some View { + Label(title, systemImage: icon) + } +} + +private struct TrainingCategoryRow: View { + let category: TrainingCategory + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(category.name) + .font(.subheadline) + .bold() + HStack(spacing: 0) { + StatusIndicator(level: "Functional", count: category.functionalCount, color: .orange) + Spacer() + StatusIndicator(level: "Solid", count: category.solidCount, color: .green) + Spacer() + StatusIndicator(level: "High-Accuracy", count: category.highAccuracyCount, color: .blue) + } + } + .padding(.vertical, 4) + } +} + +struct StatusIndicator: View { + let level: String + let count: Int + let color: Color + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 9, height: 9) + Text("\(level): \(count)+") + .font(.caption2) + .foregroundColor(.secondary) + } + } +} + +private struct LegendChip: View { + let color: Color + let label: String + + var body: some View { + HStack(spacing: 4) { + Circle().fill(color).frame(width: 8, height: 8) + Text(label).font(.caption2).foregroundColor(.secondary) + } + } +} diff --git a/README.md b/README.md index 71f3d2d..8ca4952 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# IYmtg Platinum Prime (Version 1.0.0) +# IYmtg Platinum Prime (Version 1.1.0) **SYSTEM CONTEXT FOR AI (STRICT PRESERVATION)** CRITICAL INSTRUCTION: This document is the single, authoritative Source of Truth for "IYmtg," an iOS application designed to identify, grade, and insure Magic: The Gathering cards. @@ -271,6 +271,8 @@ Also add `PrivacyInfo.xcprivacy` to the app target to satisfy Apple's privacy ma ## Part 5: Machine Learning Training — Mac Required for Final Step +> **In-App Training Guide (v1.1.0+):** The Library tab now includes a "?" button that opens a color-coded Training Guide. It shows every ML category with recommended image counts for three accuracy levels (Functional / Solid / High-Accuracy), so you can track collection progress directly from the app. + **You do not need the app, Xcode, or a Mac to collect training images.** All you need is physical cards and a phone camera. The only Mac-required step is the final model training in Create ML (Step 6). > **The app ships and works without any trained models.** Foil detection defaults to "None" and condition defaults to "NM". You can release a working app first and add models later via OTA update. Do not let missing training data block your first build.