payfrit-beacon-ios/PayfritBeacon/Views/QRScannerView.swift
Schwifty 8ecd533429 fix: replace generic icon with beacon-specific icon and match Android color scheme
- App icon now uses white bg + PAYFRIT text + bluetooth beacon icon (matches Android)
- AccentColor set to Payfrit Green (#22B24B)
- Added BrandColors.swift with full Android color palette parity
- All views updated: payfritGreen replaces .blue, proper status colors throughout
- Signal strength, beacon type badges, QR scanner corners all use brand colors
- DevBanner uses warningOrange matching Android's #FF9800

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:29:35 +00:00

312 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(.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)
}
}