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.
256 lines
9.9 KiB
Swift
256 lines
9.9 KiB
Swift
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))
|
||
}
|
||
}
|