payfrit-beacon-ios/PayfritBeacon/QrScanView.swift
John Pinkyfloyd 962a767863 Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- Flatten project structure: remove Models/, Services/, ViewModels/, Views/ subdirs
- Replace APIService actor with simpler Api class, IS_DEV flag controls dev vs prod URL
- Rewrite BeaconScanner to use CoreLocation (CLBeaconRegion ranging) instead of
  CoreBluetooth — iOS blocks iBeacon data from CBCentralManager
- Add SVG logo on login page with proper scaling (was showing green square)
- Make login page scrollable, add "enter 6-digit code" OTP instruction
- Fix text input visibility (white on white) with .foregroundColor(.primary)
- Add diagonal orange DEV ribbon banner (lower-left corner), gated on Api.IS_DEV
- Update app icon: logo 10% larger, wifi icon closer
- Add en.lproj/InfoPlist.strings for display name localization
- Fix scan flash: keep isScanning=true until enrichment completes
- Add Podfile with SVGKit, Kingfisher, CocoaLumberjack dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:07:39 -08:00

192 lines
6.3 KiB
Swift

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..<next]))
idx = next
}
return result.joined(separator: ":")
}
private func formatUuid(_ hex: String) -> 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()
}
}