import SwiftUI import AVFoundation import Vision /// QR/Barcode scanner for reading beacon MAC addresses and UUIDs /// Matches Android QRScannerActivity — camera preview + barcode detection struct QRScannerView: View { @Environment(\.dismiss) private var dismiss /// Called with the scanned string (MAC address, UUID, or other barcode) let onScan: (String) -> Void @State private var scannedCode: String? @State private var isFlashOn = false @State private var cameraPermission: CameraPermission = .undetermined enum CameraPermission { case undetermined, granted, denied } var body: some View { NavigationStack { ZStack { if cameraPermission == .granted { CameraPreview(onCodeDetected: handleDetection, isFlashOn: $isFlashOn) .ignoresSafeArea() // Scan overlay scanOverlay } else if cameraPermission == .denied { cameraPermissionDenied } else { Color.black.ignoresSafeArea() .overlay { ProgressView("Requesting camera…") .tint(.white) .foregroundStyle(.white) } } } .navigationTitle("Scan QR / Barcode") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.ultraThinMaterial, for: .navigationBar) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .foregroundStyle(.white) } ToolbarItem(placement: .topBarTrailing) { Button { isFlashOn.toggle() } label: { Image(systemName: isFlashOn ? "flashlight.on.fill" : "flashlight.off.fill") .foregroundStyle(isFlashOn ? .yellow : .white) } } } .task { await checkCameraPermission() } .alert("Code Detected", isPresented: .init( get: { scannedCode != nil }, set: { if !$0 { scannedCode = nil } } )) { Button("Use This") { if let code = scannedCode { onScan(code) dismiss() } } Button("Scan Again", role: .cancel) { scannedCode = nil } } message: { Text(scannedCode ?? "") } } } // MARK: - Scan Overlay private var scanOverlay: some View { VStack { Spacer() // Viewfinder frame RoundedRectangle(cornerRadius: 16) .stroke(.white.opacity(0.8), lineWidth: 2) .frame(width: 280, height: 280) .overlay { // Corner accents GeometryReader { geo in let w = geo.size.width let h = geo.size.height let corner: CGFloat = 30 let thickness: CGFloat = 4 // Top-left Path { p in p.move(to: CGPoint(x: 0, y: corner)) p.addLine(to: CGPoint(x: 0, y: 0)) p.addLine(to: CGPoint(x: corner, y: 0)) } .stroke(.payfritGreen, lineWidth: thickness) // Top-right Path { p in p.move(to: CGPoint(x: w - corner, y: 0)) p.addLine(to: CGPoint(x: w, y: 0)) p.addLine(to: CGPoint(x: w, y: corner)) } .stroke(.payfritGreen, lineWidth: thickness) // Bottom-left Path { p in p.move(to: CGPoint(x: 0, y: h - corner)) p.addLine(to: CGPoint(x: 0, y: h)) p.addLine(to: CGPoint(x: corner, y: h)) } .stroke(.payfritGreen, lineWidth: thickness) // Bottom-right Path { p in p.move(to: CGPoint(x: w - corner, y: h)) p.addLine(to: CGPoint(x: w, y: h)) p.addLine(to: CGPoint(x: w, y: h - corner)) } .stroke(.payfritGreen, lineWidth: thickness) } } Spacer() // Instructions Text("Point camera at beacon QR code or barcode") .font(.subheadline) .foregroundStyle(.white) .padding() .background(.ultraThinMaterial, in: Capsule()) .padding(.bottom, 40) } } @ViewBuilder private var cameraPermissionDenied: some View { if #available(iOS 17.0, *) { ContentUnavailableView { Label("Camera Access Required", systemImage: "camera.fill") } description: { Text("Open Settings and enable camera access for Payfrit Beacon.") } actions: { Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) } } else { VStack(spacing: 16) { Image(systemName: "camera.fill") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("Camera Access Required") .font(.headline) Text("Open Settings and enable camera access for Payfrit Beacon.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } .buttonStyle(.borderedProminent) } .padding() } } // MARK: - Actions private func checkCameraPermission() async { switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: cameraPermission = .granted case .notDetermined: let granted = await AVCaptureDevice.requestAccess(for: .video) cameraPermission = granted ? .granted : .denied default: cameraPermission = .denied } } private func handleDetection(_ code: String) { guard scannedCode == nil else { return } // Debounce // Haptic feedback let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() scannedCode = code } } // MARK: - Camera Preview (UIViewRepresentable) /// AVFoundation camera preview with barcode/QR detection via Vision framework struct CameraPreview: UIViewRepresentable { let onCodeDetected: (String) -> Void @Binding var isFlashOn: Bool func makeUIView(context: Context) -> CameraPreviewUIView { let view = CameraPreviewUIView(onCodeDetected: onCodeDetected) return view } func updateUIView(_ uiView: CameraPreviewUIView, context: Context) { uiView.setFlash(isFlashOn) } } final class CameraPreviewUIView: UIView { private var captureSession: AVCaptureSession? private var previewLayer: AVCaptureVideoPreviewLayer? private let onCodeDetected: (String) -> Void private var lastDetectionTime: Date = .distantPast private let debounceInterval: TimeInterval = 1.5 init(onCodeDetected: @escaping (String) -> Void) { self.onCodeDetected = onCodeDetected super.init(frame: .zero) setupCamera() } required init?(coder: NSCoder) { fatalError() } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds } func setFlash(_ on: Bool) { guard let device = AVCaptureDevice.default(for: .video), device.hasTorch else { return } try? device.lockForConfiguration() device.torchMode = on ? .on : .off device.unlockForConfiguration() } private func setupCamera() { let session = AVCaptureSession() session.sessionPreset = .high guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let input = try? AVCaptureDeviceInput(device: device) else { return } if session.canAddInput(input) { session.addInput(input) } // Use AVCaptureMetadataOutput for barcode detection (more reliable than Vision for barcodes) let metadataOutput = AVCaptureMetadataOutput() if session.canAddOutput(metadataOutput) { session.addOutput(metadataOutput) metadataOutput.setMetadataObjectsDelegate(self, queue: .main) metadataOutput.metadataObjectTypes = [ .qr, .ean8, .ean13, .code128, .code39, .code93, .dataMatrix, .pdf417, .aztec ] } let previewLayer = AVCaptureVideoPreviewLayer(session: session) previewLayer.videoGravity = .resizeAspectFill layer.addSublayer(previewLayer) self.previewLayer = previewLayer self.captureSession = session DispatchQueue.global(qos: .userInitiated).async { session.startRunning() } } deinit { captureSession?.stopRunning() setFlash(false) } } // MARK: - AVCaptureMetadataOutputObjectsDelegate extension CameraPreviewUIView: AVCaptureMetadataOutputObjectsDelegate { func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection ) { guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let value = metadata.stringValue, !value.isEmpty else { return } // Debounce rapid detections let now = Date() guard now.timeIntervalSince(lastDetectionTime) > debounceInterval else { return } lastDetectionTime = now onCodeDetected(value) } }