import SwiftUI @preconcurrency import AVFoundation struct QRScannerSheet: View { var onResult: (String) -> Void @Environment(\.dismiss) private var dismiss @State private var cameraStatus = AVCaptureDevice.authorizationStatus(for: .video) @State private var scannerError: String? var body: some View { NavigationStack { ZStack { Color.black.ignoresSafeArea() switch cameraStatus { case .authorized: QRCameraView( onResult: { result in dismiss() onResult(result) }, onError: { message in scannerError = message } ) .ignoresSafeArea() scannerOverlay case .notDetermined: ProgressView("Requesting camera access...") .tint(.white) .foregroundStyle(.white) case .denied, .restricted: scannerMessage( title: "Camera Access Needed", message: "Allow camera access in Settings to scan PayMV QR codes." ) @unknown default: scannerMessage( title: "Camera Unavailable", message: "This device cannot start the camera scanner." ) } if let scannerError { scannerMessage(title: "Scanner Error", message: scannerError) .padding(24) } } .navigationTitle("Scan QR Code") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } .foregroundStyle(.white) } } .task { await requestCameraAccessIfNeeded() } } } private var scannerOverlay: some View { VStack { Spacer() RoundedRectangle(cornerRadius: 20) .stroke(.white.opacity(0.9), lineWidth: 3) .frame(width: 260, height: 260) .overlay( Text("Align QR code inside the frame") .font(.caption) .foregroundStyle(.white) .padding(.top, 300), alignment: .center ) Spacer() } .allowsHitTesting(false) } private func scannerMessage(title: String, message: String) -> some View { VStack(spacing: 12) { Image(systemName: "camera.viewfinder") .font(.system(size: 42)) Text(title) .font(.headline) Text(message) .font(.subheadline) .multilineTextAlignment(.center) } .foregroundStyle(.white) .padding(24) } private func requestCameraAccessIfNeeded() async { guard cameraStatus == .notDetermined else { return } let granted = await AVCaptureDevice.requestAccess(for: .video) cameraStatus = granted ? .authorized : AVCaptureDevice.authorizationStatus(for: .video) } } private struct QRCameraView: UIViewRepresentable { var onResult: (String) -> Void var onError: (String) -> Void func makeUIView(context: Context) -> PreviewView { let view = PreviewView() view.backgroundColor = .black context.coordinator.configure(previewView: view) return view } func updateUIView(_ uiView: PreviewView, context: Context) {} static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) { coordinator.stop() uiView.previewLayer.session = nil } func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult, onError: onError) } final class PreviewView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } } final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { private let onResult: (String) -> Void private let onError: (String) -> Void private let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "mv.thijooree.qr-scanner.session") private var didCapture = false private var isConfigured = false init(onResult: @escaping (String) -> Void, onError: @escaping (String) -> Void) { self.onResult = onResult self.onError = onError } func configure(previewView: PreviewView) { previewView.previewLayer.videoGravity = .resizeAspectFill previewView.previewLayer.session = session sessionQueue.async { [weak self] in guard let self else { return } guard !self.isConfigured else { self.start() return } guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) ?? AVCaptureDevice.default(for: .video) else { self.reportError("No camera is available on this device.") return } do { let input = try AVCaptureDeviceInput(device: device) let output = AVCaptureMetadataOutput() self.session.beginConfiguration() self.session.sessionPreset = .high guard self.session.canAddInput(input) else { self.session.commitConfiguration() self.reportError("The camera input could not be started.") return } self.session.addInput(input) guard self.session.canAddOutput(output) else { self.session.commitConfiguration() self.reportError("The QR scanner output could not be started.") return } self.session.addOutput(output) output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr) ? [.qr] : [] self.session.commitConfiguration() guard output.metadataObjectTypes.contains(.qr) else { self.reportError("QR scanning is not available for this camera.") return } self.isConfigured = true DispatchQueue.main.async { output.setMetadataObjectsDelegate(self, queue: .main) } self.start() } catch { self.reportError(error.localizedDescription) } } } func stop() { sessionQueue.async { [weak self] in guard let self, self.session.isRunning else { return } self.session.stopRunning() } } private func start() { guard !session.isRunning else { return } session.startRunning() } private func reportError(_ message: String) { DispatchQueue.main.async { self.onError(message) } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !didCapture, let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = object.stringValue else { return } didCapture = true stop() DispatchQueue.main.async { self.onResult(value) } } } }