// Sources/UI/Pairing/QRScannerView.swift // T-2.2: AVFoundation QR scanner wrapped as UIViewRepresentable import AVFoundation import SwiftUI /// A fullscreen AVFoundation-backed QR code scanner. /// /// Calls `onScan` exactly once with the raw QR string, then stops the /// capture session so the caller can drive the next state transition. struct QRScannerView: UIViewRepresentable { /// Called on the **main actor** with the raw decoded QR string. let onScan: @MainActor (String) -> Void // MARK: UIViewRepresentable func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan) } func makeUIView(context: Context) -> PreviewView { let view = PreviewView() let coordinator = context.coordinator let session = coordinator.session // Input guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) else { return view } guard session.canAddInput(input) else { return view } session.addInput(input) // Output let output = AVCaptureMetadataOutput() guard session.canAddOutput(output) else { return view } session.addOutput(output) output.setMetadataObjectsDelegate(coordinator, queue: DispatchQueue.main) output.metadataObjectTypes = [.qr] // Preview layer let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill view.previewLayer = previewLayer view.layer.addSublayer(previewLayer) coordinator.previewLayer = previewLayer // Start capture on a background thread to avoid blocking the main queue. Task.detached(priority: .userInitiated) { session.startRunning() } return view } func updateUIView(_ uiView: PreviewView, context: Context) { // Layout is handled inside PreviewView.layoutSubviews. } static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) { coordinator.stop() } // MARK: - Coordinator @MainActor final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { let session = AVCaptureSession() var previewLayer: AVCaptureVideoPreviewLayer? private let onScan: @MainActor (String) -> Void private var hasScanned = false init(onScan: @MainActor @escaping (String) -> Void) { self.onScan = onScan } // Called on main queue (set via setMetadataObjectsDelegate). nonisolated func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { MainActor.assumeIsolated { guard !hasScanned else { return } guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, object.type == .qr, let string = object.stringValue else { return } hasScanned = true stop() onScan(string) } } func stop() { guard session.isRunning else { return } Task.detached(priority: .userInitiated) { [session] in session.stopRunning() } } } // MARK: - PreviewView /// UIView subclass that keeps the `AVCaptureVideoPreviewLayer` sized to its bounds. final class PreviewView: UIView { var previewLayer: AVCaptureVideoPreviewLayer? override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds } } }