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