360 lines
12 KiB
Swift
360 lines
12 KiB
Swift
import AVFoundation
|
||
import Combine
|
||
import SwiftUI
|
||
#if os(iOS)
|
||
import UIKit
|
||
#elseif os(macOS)
|
||
import AppKit
|
||
#endif
|
||
|
||
struct QRScannerSheet: View {
|
||
let seededPayload: String
|
||
let onCodeScanned: (String) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var manualFallback = ""
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
Text("Use the camera to scan the QR code shown by the web portal. If you’re on a simulator or desktop without a camera, the seeded payload works as a mock fallback.")
|
||
.foregroundStyle(.secondary)
|
||
|
||
LiveQRScannerView(onCodeScanned: onCodeScanned)
|
||
.frame(minHeight: 340)
|
||
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Fallback Pairing Payload")
|
||
.font(.headline)
|
||
|
||
TextEditor(text: $manualFallback)
|
||
.font(.body.monospaced())
|
||
.scrollContentBackground(.hidden)
|
||
.padding(14)
|
||
.frame(minHeight: 120)
|
||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||
|
||
HStack(spacing: 12) {
|
||
Button {
|
||
let chosen = manualFallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
onCodeScanned(chosen.isEmpty ? seededPayload : chosen)
|
||
dismiss()
|
||
} label: {
|
||
Label("Use Fallback Payload", systemImage: "arrow.up.forward.square")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Button {
|
||
manualFallback = seededPayload
|
||
} label: {
|
||
Label("Use Seeded Mock", systemImage: "wand.and.rays")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||
}
|
||
.padding(24)
|
||
}
|
||
.navigationTitle("Scan QR Code")
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Close") {
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
manualFallback = seededPayload
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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<Void, Never>) 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
|