Add scan flow MVP and local Axiom skill workspace

This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
Matthias
2026-04-19 21:11:32 +02:00
parent 577214d474
commit a60a76b797
679 changed files with 138964 additions and 73 deletions

View File

@@ -0,0 +1,28 @@
import AVFoundation
import SwiftUI
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
uiView.previewLayer.session = session
}
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
guard let layer = layer as? AVCaptureVideoPreviewLayer else {
fatalError("PreviewView requires AVCaptureVideoPreviewLayer.")
}
return layer
}
}

View File

@@ -0,0 +1,171 @@
import SwiftData
import SwiftUI
import UIKit
struct ResultEditorView: View {
enum Mode {
case recognized
case manual
var title: String {
switch self {
case .recognized:
return "Review Result"
case .manual:
return "Manual Entry"
}
}
}
@Environment(\.modelContext) private var modelContext
@ObservedObject var flowModel: ScanFlowModel
let mode: Mode
var body: some View {
Group {
if let session = flowModel.currentSession {
Form {
if let image = session.thumbnailJPEGData.flatMap(UIImage.init(data:)) {
Section {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
Section("Recognition") {
LabeledContent("Source", value: draftBinding.wrappedValue.source.title)
LabeledContent("Confidence", value: draftBinding.wrappedValue.confidence.title)
Text(draftBinding.wrappedValue.confidence.helperText)
.font(.footnote)
.foregroundStyle(.secondary)
Text(draftBinding.wrappedValue.source.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Card details") {
TextField("Card name", text: draftBinding.cardName)
.textInputAutocapitalization(.words)
TextField("Card number", text: draftBinding.cardNumber)
.textInputAutocapitalization(.never)
TextField("Set", text: draftBinding.setIdentifier)
.textInputAutocapitalization(.words)
Picker("Rarity", selection: draftBinding.rarity) {
ForEach(CardRarity.allCases) { rarity in
Text(rarity.rawValue).tag(rarity.rawValue)
}
}
}
if !draftBinding.wrappedValue.notes.isEmpty {
Section("Hints") {
ForEach(Array(draftBinding.wrappedValue.notes.enumerated()), id: \.offset) { _, note in
Text(note)
.font(.subheadline)
}
}
}
if !draftBinding.wrappedValue.rawText.isEmpty {
Section("OCR fragments") {
Text(draftBinding.wrappedValue.rawText)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
Section {
Button("Confirm and Log") {
confirmDraft()
}
.buttonStyle(.borderedProminent)
Button(mode == .recognized ? "Manual Entry" : "Re-scan") {
if mode == .recognized {
flowModel.openManualEntryFromResult()
} else {
flowModel.returnToScan()
}
}
if mode == .recognized {
Button("Re-scan") {
flowModel.returnToScan()
}
.foregroundStyle(.red)
}
}
}
} else {
ContentUnavailableView("No scan loaded", systemImage: "rectangle.and.text.magnifyingglass", description: Text("Capture or import a card image first."))
}
}
.navigationTitle(mode.title)
.navigationBarTitleDisplayMode(.inline)
}
private var draftBinding: Binding<CardRecognitionDraft> {
Binding(
get: {
flowModel.currentSession?.draft ?? .manualPrefill()
},
set: { flowModel.updateDraft($0) }
)
}
private func confirmDraft() {
let draft = draftBinding.wrappedValue
modelContext.insert(ConfirmedScanRecord(draft: draft))
flowModel.returnToScan(message: "Saved to temporary local log.")
}
}
private extension Binding where Value == CardRecognitionDraft {
var cardName: Binding<String> {
Binding<String>(
get: { wrappedValue.cardName },
set: {
var draft = wrappedValue
draft.cardName = $0
wrappedValue = draft
}
)
}
var cardNumber: Binding<String> {
Binding<String>(
get: { wrappedValue.cardNumber },
set: {
var draft = wrappedValue
draft.cardNumber = $0
wrappedValue = draft
}
)
}
var setIdentifier: Binding<String> {
Binding<String>(
get: { wrappedValue.setIdentifier },
set: {
var draft = wrappedValue
draft.setIdentifier = $0
wrappedValue = draft
}
)
}
var rarity: Binding<String> {
Binding<String>(
get: { wrappedValue.rarity },
set: {
var draft = wrappedValue
draft.rarity = $0
wrappedValue = draft
}
)
}
}

View File

@@ -0,0 +1,255 @@
import Photos
import SwiftData
import SwiftUI
struct ScanView: View {
@ObservedObject var flowModel: ScanFlowModel
@Query(sort: \ConfirmedScanRecord.confirmedAt, order: .reverse) private var confirmedRecords: [ConfirmedScanRecord]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
cameraSection
recentPhotosSection
actionSection
temporaryLogSection
}
.padding(20)
}
.navigationTitle("Scan Card")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if flowModel.isRecognizing {
ProgressView("Recognizing card…")
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
.alert("StackDex", isPresented: messageIsPresented) {
Button("OK", role: .cancel) {
flowModel.transientMessage = nil
}
} message: {
Text(flowModel.transientMessage ?? "")
}
}
private var cameraSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Camera")
.font(.headline)
ZStack {
RoundedRectangle(cornerRadius: 24)
.fill(.black.opacity(0.92))
if flowModel.cameraService.isSessionRunning {
CameraPreviewView(session: flowModel.cameraService.session)
.clipShape(RoundedRectangle(cornerRadius: 24))
} else {
cameraPlaceholder
}
RoundedRectangle(cornerRadius: 20)
.stroke(.white.opacity(0.7), style: StrokeStyle(lineWidth: 2, dash: [10, 8]))
.padding(28)
}
.frame(height: 440)
if flowModel.cameraService.authorizationStatus == .authorized {
HStack {
Spacer()
Button {
Task { await flowModel.capturePhoto() }
} label: {
ZStack {
Circle().fill(.white).frame(width: 76, height: 76)
Circle().stroke(.black.opacity(0.85), lineWidth: 3).frame(width: 62, height: 62)
}
}
.accessibilityLabel("Capture card photo")
Spacer()
}
}
}
}
@ViewBuilder
private var cameraPlaceholder: some View {
VStack(spacing: 14) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.white)
Text(cameraPlaceholderText)
.font(.headline)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Text("Scan starts after an explicit shutter tap. No image is stored after recognition.")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.75))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
if flowModel.cameraService.authorizationStatus != .authorized {
Button(cameraButtonTitle) {
if flowModel.cameraService.authorizationStatus == .denied {
flowModel.photoLibraryService.openSettings()
} else {
Task { await flowModel.enableCamera() }
}
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
}
private var recentPhotosSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Recent photos")
.font(.headline)
Spacer()
if flowModel.photoLibraryService.authorizationStatus == .limited {
Button("Manage") {
flowModel.photoLibraryService.presentLimitedLibraryPicker()
}
}
}
switch flowModel.photoLibraryService.authorizationStatus {
case .authorized, .limited:
if flowModel.photoLibraryService.isLoading {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 72)
} else if flowModel.photoLibraryService.recentPhotos.isEmpty {
secondaryPanel("No recent photos available.")
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(flowModel.photoLibraryService.recentPhotos) { item in
Button {
Task { await flowModel.importRecentPhoto(item) }
} label: {
Image(uiImage: item.thumbnail)
.resizable()
.scaledToFill()
.frame(width: 88, height: 88)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
}
}
}
}
case .denied, .restricted:
secondaryPanel("Photo access is unavailable. You can keep scanning with the camera or open Settings to allow recent-photo import.") {
Button("Open Settings") {
flowModel.photoLibraryService.openSettings()
}
.buttonStyle(.bordered)
}
case .notDetermined:
secondaryPanel("Recent photos stay optional and are requested only when you ask for them.") {
Button("Show Recents") {
Task { await flowModel.photoLibraryService.requestAccessAndLoad() }
}
.buttonStyle(.borderedProminent)
}
@unknown default:
secondaryPanel("Recent photos are unavailable on this device.")
}
}
}
private var actionSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Fallback")
.font(.headline)
secondaryPanel(flowModel.networkMonitor.isOnline ? "Cloud OCR is prepared as an injectable boundary, but the default client is intentionally stubbed for this local MVP. Vision OCR remains fully functional." : "Youre offline, so StackDex will use the on-device Vision pipeline.") {
Button("Enter Manually") {
flowModel.startManualEntry()
}
.buttonStyle(.bordered)
}
}
}
private var temporaryLogSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Temporary confirmations")
.font(.headline)
if confirmedRecords.isEmpty {
secondaryPanel("Confirmed cards are logged locally in SwiftData so the MVP can prove the end-to-end flow without storing source images.")
} else {
VStack(spacing: 10) {
ForEach(Array(confirmedRecords.prefix(3))) { record in
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(record.cardName.isEmpty ? "Unnamed card" : record.cardName)
.font(.subheadline.weight(.semibold))
Text([record.cardNumber, record.setIdentifier].filter { !$0.isEmpty }.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(record.confirmedAt, style: .time)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}
}
}
}
private var messageIsPresented: Binding<Bool> {
Binding(
get: { flowModel.transientMessage != nil },
set: { if !$0 { flowModel.transientMessage = nil } }
)
}
private var cameraPlaceholderText: String {
switch flowModel.cameraService.authorizationStatus {
case .authorized:
return "Starting camera preview…"
case .denied:
return "Camera access is denied"
case .restricted:
return "Camera access is restricted"
case .notDetermined:
return "Enable the camera to scan a card"
@unknown default:
return "Camera unavailable"
}
}
private var cameraButtonTitle: String {
switch flowModel.cameraService.authorizationStatus {
case .denied, .restricted:
return "Open Settings"
default:
return "Enable Camera"
}
}
private func secondaryPanel(_ text: String, @ViewBuilder actions: () -> some View = { EmptyView() }) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(text)
.font(.subheadline)
.foregroundStyle(.secondary)
actions()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}