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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:21:45 -05:00
parent bb4ad9eb7e
commit 5da5614a10
5 changed files with 179 additions and 2 deletions

View File

@@ -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<String>()
return categories.compactMap { seen.insert($0.group).inserted ? $0.group : nil }
}
func categories(for group: String) -> [TrainingCategory] {
categories.filter { $0.group == group }
}
}

View File

@@ -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)
}
}
}