127 lines
3.8 KiB
Swift
127 lines
3.8 KiB
Swift
// 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
|
|
}
|
|
}
|
|
}
|