- 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>
192 lines
6.3 KiB
Swift
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()
|
|
}
|
|
}
|