Files
IYmtg/IYmtg_App_iOS/ContentView.swift
Mike Wichers 5da5614a10 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>
2026-03-05 13:21:45 -05:00

469 lines
28 KiB
Swift

import SwiftUI
import UIKit
import AVFoundation
import StoreKit
struct ContentView: View {
@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: 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() }
.alert("Thank You!", isPresented: $store.showThankYou) {
Button("OK", role: .cancel) { }
} message: { Text("Your support keeps the app ad-free!") }
.sheet(isPresented: Binding(get: { !hasLaunchedBefore }, set: { _ in })) {
WelcomeView(hasLaunchedBefore: $hasLaunchedBefore)
}
}
}
// MARK: - DASHBOARD VIEW
struct DashboardView: View {
@ObservedObject var vm: CollectionViewModel
var body: some View {
NavigationStack {
List {
Section(header: Text("Market Overview")) {
HStack {
VStack(alignment: .leading) { Text("Total Value").font(.caption).foregroundColor(.gray); Text("\(vm.selectedCurrency.symbol)\(vm.portfolioValue, specifier: "%.2f")").font(.title).bold() }
Spacer()
VStack(alignment: .trailing) {
Text("24h Change").font(.caption).foregroundColor(.gray)
Text((vm.portfolioDailyChange >= 0 ? "+" : "") + String(format: "%.2f", vm.portfolioDailyChange))
.foregroundColor(vm.portfolioDailyChange >= 0 ? .green : .red).bold()
}
}
}
Section(header: Text("Top Movers")) {
if vm.topMovers.isEmpty { Text("No price changes detected today.").foregroundColor(.gray) }
ForEach(vm.topMovers) { card in
HStack {
VStack(alignment: .leading) { Text(card.name).font(.headline); Text(card.setCode).font(.caption).foregroundColor(.gray) }
Spacer()
let diff = (card.currentValuation ?? 0) - (card.previousValuation ?? 0)
Text((diff >= 0 ? "+" : "") + String(format: "%.2f", diff)).foregroundColor(diff >= 0 ? .green : .red)
}
}
}
}
.navigationTitle("Dashboard")
}
}
}
// MARK: - SCANNER VIEW
struct ScannerView: View {
@ObservedObject var vm: ScannerViewModel
@Environment(\.scenePhase) var scenePhase
@State private var devModeTapCount = 0
@State private var focusPoint: CGPoint? = nil
@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 {
if vm.isPermissionDenied {
VStack(spacing: 20) {
Image(systemName: "camera.fill.badge.ellipsis").font(.system(size: 60)).foregroundColor(.red)
Text("Camera Access Required").font(.title2).bold()
Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } }.buttonStyle(.borderedProminent)
}
.padding().background(.ultraThinMaterial).cornerRadius(16).frame(maxHeight: .infinity)
} else if vm.isDatabaseLoading || vm.isCollectionLoading {
VStack { ProgressView().scaleEffect(2).tint(.white); Text(vm.statusText).padding(.top).font(.caption).foregroundColor(.white) }.frame(maxHeight: .infinity)
} else {
ZStack {
GeometryReader { geo in
CameraPreview(session: vm.session)
.onTapGesture { location in
let x = location.x / geo.size.width
let y = location.y / geo.size.height
vm.focusCamera(at: CGPoint(x: y, y: 1 - x))
focusTask?.cancel()
focusTask = Task {
withAnimation { focusPoint = location }
try? await Task.sleep(nanoseconds: 500_000_000)
withAnimation { focusPoint = nil }
}
}
}
.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: 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) }
if let card = vm.detectedCard {
VStack(spacing: 12) {
Text(card.name).font(.title).bold().multilineTextAlignment(.center).minimumScaleFactor(0.5).foregroundColor(.black)
if card.isSerialized { Text("SERIALIZED").font(.headline).bold().foregroundColor(.purple).padding(4).overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.purple)) }
Text("Foil: \(vm.currentFoilType)").font(.caption).foregroundColor(.orange)
Text("Cond: \(ConditionEngine.overallGrade(damages: vm.detectedDamages))").font(.caption).foregroundColor(.gray)
HStack {
Button(action: { vm.cancelScan() }) { Image(systemName: "xmark.circle.fill").font(.title).foregroundColor(.gray) }.disabled(vm.isSaving)
Button(action: { vm.saveCurrentCard() }) { if vm.isSaving { ProgressView().tint(.white) } else { Text("Save") } }.buttonStyle(.borderedProminent).disabled(vm.isSaving)
Button(action: { showManualEdit = true }) { Image(systemName: "pencil.circle.fill").font(.title).foregroundColor(.blue) }.disabled(vm.isSaving)
}
Menu {
Button("Send This Image") { vm.uploadTrainingImage(label: "Manual_\(card.setCode)") }
Button(isTrainingOptIn ? "Disable Auto-Send" : "Enable Auto-Send (All)") { isTrainingOptIn.toggle() }
} label: {
Label("Improve AI / Report Oddity", systemImage: "ant.circle").font(.caption).foregroundColor(.blue)
}
}.padding().background(Color.white).cornerRadius(15).padding().shadow(radius: 10)
}
}
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack {
HStack {
Image("logo_header").resizable().scaledToFit().frame(height: 32)
.onTapGesture {
#if ENABLE_DEV_MODE
devModeTapCount += 1
if devModeTapCount >= 5 { vm.statusText = "DEV MODE: CAPTURING" }
#endif
}
Spacer()
Button(action: { vm.isAutoScanEnabled.toggle() }) { Image(systemName: vm.isAutoScanEnabled ? "bolt.badge.a.fill" : "bolt.badge.a").foregroundColor(vm.isAutoScanEnabled ? .green : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
Button(action: { vm.toggleTorch() }) { Image(systemName: vm.isTorchOn ? "bolt.fill" : "bolt.slash.fill").foregroundColor(vm.isTorchOn ? .yellow : .white).padding(8).background(.ultraThinMaterial).clipShape(Circle()) }
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.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.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()
}
}
.onAppear { vm.startSession() }
.onDisappear { vm.stopSession() }
.onChange(of: scenePhase) { newPhase in
if newPhase == .active { vm.checkCameraPermissions() }
else if newPhase == .background { vm.stopSession() }
}
.sheet(isPresented: $showManualEdit) {
if let detected = vm.detectedCard, let cg = vm.currentFrameImage {
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.collectionVM, scannerVM: vm, isNewEntry: true)
}
}
.alert(vm.databaseAlertTitle, isPresented: $vm.showDatabaseAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(vm.databaseAlertMessage)
}
}
}
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.videoPreviewLayer.session = session
view.videoPreviewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {}
class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override func layoutSubviews() { super.layoutSubviews(); videoPreviewLayer.frame = bounds }
}
}
// MARK: - COLLECTION VIEW
struct CollectionView: View {
@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...") }
else if vm.scannedList.isEmpty { VStack(spacing: 20) { Image("empty_library").resizable().scaledToFit().frame(width: 250).opacity(0.8); Text("No Cards Yet").font(.title2).bold(); Text("Start scanning to build your collection.").foregroundColor(.gray) } }
else if vm.filteredList.isEmpty && !vm.librarySearchText.isEmpty { VStack(spacing: 10) { Image(systemName: "magnifyingglass").font(.largeTitle).foregroundColor(.gray); Text("No results for '\(vm.librarySearchText)'").foregroundColor(.gray) }.frame(maxHeight: .infinity) }
else if vm.filteredList.isEmpty { VStack(spacing: 20) { Image(systemName: "folder.badge.plus").font(.system(size: 60)).foregroundColor(.gray); Text("No Cards in \(vm.currentCollection)").font(.title3).bold(); Text("Scan cards to add them here.").foregroundColor(.gray) } }
else {
List {
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) }
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 {
VStack(alignment: .leading) {
HStack { Text(card.name).font(.headline); if card.isSerialized ?? false { Image(systemName: "number.circle.fill").foregroundColor(.purple).font(.caption) } }
Text("\(card.condition)\(card.foilType)" + (card.gradingService != nil ? "\(card.gradingService ?? "graded") \(card.grade ?? "")" : "")).font(.caption).foregroundColor(.gray)
}
Spacer()
VStack(alignment: .trailing) {
if vm.isConnected {
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) }
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 } } }
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { vm.deleteCard(card) } label: { Label("Delete", systemImage: "trash") } }
}
}
.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) } }
Divider()
Button("Export PDF (Insurance)") { Task { shareItem = await vm.exportCollection(format: .insurance); if shareItem != nil { showShare = true } } }
Button("Export CSV (Moxfield/Archidekt)") { Task { shareItem = await vm.exportCollection(format: .csv); if shareItem != nil { showShare = true } } }
Divider()
Menu("Export Arena") {
Button("Copy to Clipboard") { vm.copyToClipboard(format: .arena) }
Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .arena); if shareItem != nil { showShare = true } } }
}
Menu("Export MTGO") {
Button("Copy to Clipboard") { vm.copyToClipboard(format: .mtgo) }
Button("Save to File") { Task { shareItem = await vm.exportCollection(format: .mtgo); if shareItem != nil { showShare = true } } }
}
} label: { Label("Options", systemImage: "ellipsis.circle") }
}
}
.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, scannerVM: scannerVM) }
}
}
}
}
// 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
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 securely to the cloud. Your card photos stay on your device.")
}
.padding(.horizontal)
Spacer()
Button(action: { hasLaunchedBefore = true }) {
Text("Get Started")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color(red: 0.6, green: 0.3, blue: 0.9))
.foregroundColor(.white)
.cornerRadius(12)
}
.padding()
}
.padding(.top, 50)
.interactiveDismissDisabled()
}
}
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)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(text).font(.subheadline).foregroundColor(.gray)
}
}
}
}