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.