The app crashed immediately when tapping QR scan because the Info.plist was missing the required NSCameraUsageDescription key. iOS kills the app with EXC_BAD_INSTRUCTION when camera access is requested without it. Also fixes: - Flash toggle could SIGABRT if lockForConfiguration failed (try? + unconditional unlock) - Camera setup now logs errors instead of silently failing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
Swift
326 lines
11 KiB
Swift
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(Color.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(Color.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(Color.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(Color.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 }
|
|
do {
|
|
try device.lockForConfiguration()
|
|
device.torchMode = on ? .on : .off
|
|
device.unlockForConfiguration()
|
|
} catch {
|
|
NSLog("[QRScanner] Failed to set torch: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func setupCamera() {
|
|
let session = AVCaptureSession()
|
|
session.sessionPreset = .high
|
|
|
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
|
|
NSLog("[QRScanner] ERROR: No back camera available")
|
|
return
|
|
}
|
|
|
|
let input: AVCaptureDeviceInput
|
|
do {
|
|
input = try AVCaptureDeviceInput(device: device)
|
|
} catch {
|
|
NSLog("[QRScanner] ERROR: Failed to create camera input: \(error.localizedDescription)")
|
|
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)
|
|
}
|
|
}
|