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