Files
stackdex_neu/StackDex/Views/ScanView.swift
Matthias a60a76b797 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.
2026-04-19 21:11:32 +02:00

256 lines
9.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}