import AVFoundation import Combine import SwiftUI #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif struct QRScannerSheet: View { let seededPayload: String let title: String let description: String let navigationTitleText: String let onCodeScanned: (String) -> Void @Environment(\.dismiss) private var dismiss @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var manualFallback = "" init( seededPayload: String, title: String = "Scan QR", description: String = "Use the camera to scan an idp.global QR challenge.", navigationTitle: String = "Scan QR", onCodeScanned: @escaping (String) -> Void ) { self.seededPayload = seededPayload self.title = title self.description = description self.navigationTitleText = navigationTitle self.onCodeScanned = onCodeScanned } var body: some View { NavigationStack { AppScrollScreen(compactLayout: compactLayout) { AppSectionCard(title: title, compactLayout: compactLayout) { Text(description) .font(.subheadline) .foregroundStyle(.secondary) LiveQRScannerView(onCodeScanned: onCodeScanned) .frame(minHeight: 340) } AppSectionCard(title: "Manual fallback", compactLayout: compactLayout) { AppTextEditorField(text: $manualFallback, minHeight: 120) if compactLayout { VStack(spacing: 12) { useFallbackButton useSeededButton } } else { HStack(spacing: 12) { useFallbackButton useSeededButton } } } } .navigationTitle(navigationTitleText) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } } .onAppear { manualFallback = seededPayload } } } private var compactLayout: Bool { #if os(iOS) horizontalSizeClass == .compact #else false #endif } private var useFallbackButton: some View { Button { let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines) onCodeScanned(chosen.isEmpty ? seededPayload : chosen) dismiss() } label: { Label("Use payload", systemImage: "arrow.up.forward.square") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } private var useSeededButton: some View { Button { manualFallback = seededPayload } label: { Label("Reset sample", systemImage: "wand.and.rays") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } } private struct LiveQRScannerView: View { let onCodeScanned: (String) -> Void @StateObject private var scanner = QRScannerViewModel() var body: some View { ZStack(alignment: .bottomLeading) { Group { if scanner.isPreviewAvailable { ScannerPreview(session: scanner.captureSession) .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) } else { RoundedRectangle(cornerRadius: 30, style: .continuous) .fill(Color.black.opacity(0.86)) VStack(alignment: .leading, spacing: 12) { Image(systemName: "video.slash.fill") .font(.system(size: 28, weight: .semibold)) .foregroundStyle(.white) Text("Live camera preview unavailable") .font(.title3.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) .foregroundStyle(.white.opacity(0.78)) } .padding(24) } } RoundedRectangle(cornerRadius: 30, style: .continuous) .strokeBorder(.white.opacity(0.22), lineWidth: 1.5) VStack(alignment: .leading, spacing: 8) { Text("Camera Scanner") .font(.headline.weight(.semibold)) .foregroundStyle(.white) Text(scanner.statusMessage) .foregroundStyle(.white.opacity(0.84)) } .padding(22) ScanFrameOverlay() .padding(40) } .task { scanner.onCodeScanned = { payload in onCodeScanned(payload) } await scanner.start() } .onDisappear { scanner.stop() } } } private struct ScanFrameOverlay: View { var body: some View { GeometryReader { geometry in let size = min(geometry.size.width, geometry.size.height) * 0.5 RoundedRectangle(cornerRadius: 28, style: .continuous) .strokeBorder(.white.opacity(0.82), style: StrokeStyle(lineWidth: 3, dash: [10, 8])) .frame(width: size, height: size) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) } .allowsHitTesting(false) } } private final class QRScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate { @Published var isPreviewAvailable = false @Published var statusMessage = "Point the camera at the QR code from the idp.global web portal." let captureSession = AVCaptureSession() var onCodeScanned: ((String) -> Void)? private let queue = DispatchQueue(label: "global.idp.qrscanner") private var isConfigured = false private var hasDeliveredCode = false func start() async { #if os(iOS) && targetEnvironment(simulator) await MainActor.run { isPreviewAvailable = false statusMessage = "The iOS simulator has no live camera feed. Use the seeded payload below." } #else #endif #if !(os(iOS) && targetEnvironment(simulator)) let authorization = AVCaptureDevice.authorizationStatus(for: .video) switch authorization { case .authorized: await configureIfNeeded() startRunning() case .notDetermined: let granted = await requestCameraAccess() await MainActor.run { self.statusMessage = granted ? "Point the camera at the QR code from the idp.global web portal." : "Camera access was denied. Use the fallback payload below." } guard granted else { return } await configureIfNeeded() startRunning() case .denied, .restricted: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access is unavailable. Use the fallback payload below." } @unknown default: await MainActor.run { isPreviewAvailable = false statusMessage = "Camera access could not be initialized on this device." } } #endif } func stop() { queue.async { if self.captureSession.isRunning { self.captureSession.stopRunning() } } } func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { guard !hasDeliveredCode, let readable = metadataObjects.first as? AVMetadataMachineReadableCodeObject, readable.type == .qr, let payload = readable.stringValue else { return } hasDeliveredCode = true stop() #if os(iOS) UINotificationFeedbackGenerator().notificationOccurred(.success) #endif DispatchQueue.main.async { [onCodeScanned] in onCodeScanned?(payload) } } private func requestCameraAccess() async -> Bool { await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .video) { granted in continuation.resume(returning: granted) } } } private func configureIfNeeded() async { guard !isConfigured else { await MainActor.run { self.isPreviewAvailable = true } return } await withCheckedContinuation { (continuation: CheckedContinuation) in queue.async { self.captureSession.beginConfiguration() defer { self.captureSession.commitConfiguration() continuation.resume() } guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), self.captureSession.canAddInput(input) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "No compatible camera was found. Use the fallback payload below." } return } self.captureSession.addInput(input) let output = AVCaptureMetadataOutput() guard self.captureSession.canAddOutput(output) else { DispatchQueue.main.async { self.isPreviewAvailable = false self.statusMessage = "Unable to configure QR metadata scanning on this device." } return } self.captureSession.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) output.metadataObjectTypes = [.qr] self.isConfigured = true DispatchQueue.main.async { self.isPreviewAvailable = true self.statusMessage = "Point the camera at the QR code from the idp.global web portal." } } } } private func startRunning() { queue.async { guard !self.captureSession.isRunning else { return } self.hasDeliveredCode = false self.captureSession.startRunning() } } } extension QRScannerViewModel: @unchecked Sendable {} #if os(iOS) private struct ScannerPreview: UIViewRepresentable { let session: AVCaptureSession func makeUIView(context: Context) -> ScannerPreviewUIView { let view = ScannerPreviewUIView() view.previewLayer.session = session view.previewLayer.videoGravity = .resizeAspectFill return view } func updateUIView(_ uiView: ScannerPreviewUIView, context: Context) { uiView.previewLayer.session = session } } private final class ScannerPreviewUIView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } } #elseif os(macOS) private struct ScannerPreview: NSViewRepresentable { let session: AVCaptureSession func makeNSView(context: Context) -> ScannerPreviewNSView { let view = ScannerPreviewNSView() view.attach(session: session) return view } func updateNSView(_ nsView: ScannerPreviewNSView, context: Context) { nsView.attach(session: session) } } private final class ScannerPreviewNSView: NSView { private var previewLayer: AVCaptureVideoPreviewLayer? override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true } required init?(coder: NSCoder) { super.init(coder: coder) wantsLayer = true } func attach(session: AVCaptureSession) { let layer = previewLayer ?? AVCaptureVideoPreviewLayer(session: session) layer.session = session layer.videoGravity = .resizeAspectFill self.layer = layer previewLayer = layer } } #endif