import SwiftUI import AVFoundation struct QrScanView: View { @Environment(\.dismiss) var dismiss var onResult: (String, String) -> Void // (value, type) static let TYPE_MAC = "mac" static let TYPE_UUID = "uuid" static let TYPE_UNKNOWN = "unknown" var body: some View { ZStack { CameraPreviewView(onQrDetected: { rawValue in let parsed = parseQrData(rawValue) onResult(parsed.value, parsed.type) dismiss() }) .ignoresSafeArea() // Scan frame overlay VStack { Spacer() RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.7), lineWidth: 2) .frame(width: 250, height: 250) Spacer() } // Toolbar overlay VStack { HStack { Button(action: { dismiss() }) { Image(systemName: "xmark") .font(.title2) .foregroundColor(.white) .padding() } Spacer() Text("Scan QR Code") .font(.headline) .foregroundColor(.white) Spacer() // Balance spacer Color.clear.frame(width: 44, height: 44) } .background(Color.black.opacity(0.3)) Spacer() Text("Point camera at beacon sticker QR code") .foregroundColor(.white) .font(.callout) .padding() .background(Color.black.opacity(0.5)) .cornerRadius(8) .padding(.bottom, 60) Button("Cancel") { dismiss() } .foregroundColor(.white) .padding() .padding(.bottom, 20) } } .modifier(DevBanner()) } private func parseQrData(_ raw: String) -> (value: String, type: String) { let trimmed = raw.trimmingCharacters(in: .whitespaces).uppercased() // MAC address with colons: AA:BB:CC:DD:EE:FF if trimmed.range(of: "^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", options: .regularExpression) != nil { return (trimmed, QrScanView.TYPE_MAC) } // MAC address without separators: AABBCCDDEEFF (12 hex chars) if trimmed.range(of: "^[0-9A-F]{12}$", options: .regularExpression) != nil { return (formatMac(trimmed), QrScanView.TYPE_MAC) } // MAC address with dashes: AA-BB-CC-DD-EE-FF if trimmed.range(of: "^[0-9A-F]{2}(-[0-9A-F]{2}){5}$", options: .regularExpression) != nil { return (trimmed.replacingOccurrences(of: "-", with: ":"), QrScanView.TYPE_MAC) } // UUID with dashes if trimmed.range(of: "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", options: .regularExpression) != nil { return (trimmed, QrScanView.TYPE_UUID) } // UUID without dashes (32 hex chars) if trimmed.range(of: "^[0-9A-F]{32}$", options: .regularExpression) != nil { return (formatUuid(trimmed), QrScanView.TYPE_UUID) } return (trimmed, QrScanView.TYPE_UNKNOWN) } private func formatMac(_ hex: String) -> String { var result: [String] = [] var idx = hex.startIndex for _ in 0..<6 { let next = hex.index(idx, offsetBy: 2) result.append(String(hex[idx.. String { return BeaconBanList.formatUuid(hex) } } // MARK: - Camera Preview UIKit wrapper struct CameraPreviewView: UIViewControllerRepresentable { var onQrDetected: (String) -> Void func makeUIViewController(context: Context) -> QrCameraViewController { let vc = QrCameraViewController() vc.onQrDetected = onQrDetected return vc } func updateUIViewController(_ uiViewController: QrCameraViewController, context: Context) {} } class QrCameraViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onQrDetected: ((String) -> Void)? private var captureSession: AVCaptureSession? private var hasScanned = false override func viewDidLoad() { super.viewDidLoad() let session = AVCaptureSession() captureSession = session guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) else { return } if session.canAddInput(input) { session.addInput(input) } let output = AVCaptureMetadataOutput() if session.canAddOutput(output) { session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) output.metadataObjectTypes = [.qr] } let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.frame = view.bounds previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if let layer = view.layer.sublayers?.first as? AVCaptureVideoPreviewLayer { layer.frame = view.bounds } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard !hasScanned else { return } for object in metadataObjects { guard let readableObject = object as? AVMetadataMachineReadableCodeObject, let value = readableObject.stringValue, !value.isEmpty else { continue } hasScanned = true captureSession?.stopRunning() onQrDetected?(value) return } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) captureSession?.stopRunning() } }