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:
28
StackDex/Views/CameraPreviewView.swift
Normal file
28
StackDex/Views/CameraPreviewView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
171
StackDex/Views/ResultEditorView.swift
Normal file
171
StackDex/Views/ResultEditorView.swift
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
255
StackDex/Views/ScanView.swift
Normal file
255
StackDex/Views/ScanView.swift
Normal 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." : "You’re 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user