feat: add QR scanner view + 7 missing API endpoints
- QRScannerView: AVFoundation camera + barcode/QR detection with flashlight toggle, viewfinder overlay, MAC/UUID pattern recognition - New API endpoints: deleteServicePoint, updateServicePoint, listBeacons, decommissionBeacon, lookupByMac, getBeaconStatus, getProfile - Wire QR scanner into ScanView with BLE Scan + QR Scan side-by-side - MAC address lookup on scan to check if beacon already registered - Updated Xcode project file with new source
This commit is contained in:
parent
cfa78679be
commit
6832a8ad53
4 changed files with 460 additions and 10 deletions
|
|
@ -18,6 +18,7 @@
|
||||||
D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
|
D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
|
||||||
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; };
|
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; };
|
||||||
D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; };
|
D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; };
|
||||||
|
D01000000009 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QRScannerView.swift */; };
|
||||||
D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
|
D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
|
||||||
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; };
|
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; };
|
||||||
D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; };
|
D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; };
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
||||||
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
|
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
|
||||||
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
|
@ -110,6 +112,7 @@
|
||||||
D02000000006 /* LoginView.swift */,
|
D02000000006 /* LoginView.swift */,
|
||||||
D02000000007 /* BusinessListView.swift */,
|
D02000000007 /* BusinessListView.swift */,
|
||||||
D02000000008 /* ScanView.swift */,
|
D02000000008 /* ScanView.swift */,
|
||||||
|
D02000000009 /* QRScannerView.swift */,
|
||||||
D02000000010 /* Info.plist */,
|
D02000000010 /* Info.plist */,
|
||||||
D02000000060 /* Assets.xcassets */,
|
D02000000060 /* Assets.xcassets */,
|
||||||
D02000000070 /* payfrit-favicon-light-outlines.svg */,
|
D02000000070 /* payfrit-favicon-light-outlines.svg */,
|
||||||
|
|
@ -254,6 +257,7 @@
|
||||||
D01000000006 /* LoginView.swift in Sources */,
|
D01000000006 /* LoginView.swift in Sources */,
|
||||||
D01000000007 /* BusinessListView.swift in Sources */,
|
D01000000007 /* BusinessListView.swift in Sources */,
|
||||||
D01000000008 /* ScanView.swift in Sources */,
|
D01000000008 /* ScanView.swift in Sources */,
|
||||||
|
D01000000009 /* QRScannerView.swift in Sources */,
|
||||||
D0100000000A /* RootView.swift in Sources */,
|
D0100000000A /* RootView.swift in Sources */,
|
||||||
D010000000B1 /* BLEBeaconScanner.swift in Sources */,
|
D010000000B1 /* BLEBeaconScanner.swift in Sources */,
|
||||||
D010000000B2 /* BeaconProvisioner.swift in Sources */,
|
D010000000B2 /* BeaconProvisioner.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,102 @@ actor APIClient {
|
||||||
return resp.data?.name ?? "Unknown"
|
return resp.data?.name ?? "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Service Point Management
|
||||||
|
|
||||||
|
func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws {
|
||||||
|
let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId]
|
||||||
|
let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
||||||
|
guard resp.success else {
|
||||||
|
throw APIError.serverError(resp.message ?? "Failed to delete service point")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
|
||||||
|
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
|
||||||
|
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
||||||
|
guard resp.success else {
|
||||||
|
throw APIError.serverError(resp.message ?? "Failed to update service point")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Beacon Management
|
||||||
|
|
||||||
|
struct BeaconListResponse: Codable {
|
||||||
|
let id: String?
|
||||||
|
let ID: String?
|
||||||
|
let uuid: String?
|
||||||
|
let UUID: String?
|
||||||
|
let major: Int?
|
||||||
|
let Major: Int?
|
||||||
|
let minor: Int?
|
||||||
|
let Minor: Int?
|
||||||
|
let macAddress: String?
|
||||||
|
let MacAddress: String?
|
||||||
|
let beaconType: String?
|
||||||
|
let BeaconType: String?
|
||||||
|
let servicePointId: String?
|
||||||
|
let ServicePointID: String?
|
||||||
|
let isVerified: Bool?
|
||||||
|
let IsVerified: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
|
||||||
|
let body: [String: Any] = ["BusinessID": businessId]
|
||||||
|
let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
|
||||||
|
guard resp.success else {
|
||||||
|
throw APIError.serverError(resp.message ?? "Failed to list beacons")
|
||||||
|
}
|
||||||
|
return resp.data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
|
||||||
|
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
|
||||||
|
let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
||||||
|
guard resp.success else {
|
||||||
|
throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
|
||||||
|
let body: [String: Any] = ["MacAddress": macAddress]
|
||||||
|
let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
|
||||||
|
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
||||||
|
let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Profile
|
||||||
|
|
||||||
|
struct UserProfile: Codable {
|
||||||
|
let id: String?
|
||||||
|
let ID: String?
|
||||||
|
let firstName: String?
|
||||||
|
let FirstName: String?
|
||||||
|
let lastName: String?
|
||||||
|
let LastName: String?
|
||||||
|
let contactNumber: String?
|
||||||
|
let ContactNumber: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProfile(token: String) async throws -> UserProfile {
|
||||||
|
let data = try await post(path: "/users/profile.php", body: [:], token: token)
|
||||||
|
let resp = try JSONDecoder().decode(APIResponse<UserProfile>.self, from: data)
|
||||||
|
guard resp.success, let profile = resp.data else {
|
||||||
|
throw APIError.serverError(resp.message ?? "Failed to load profile")
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Internal
|
// MARK: - Internal
|
||||||
|
|
||||||
private struct EmptyData: Codable {}
|
private struct EmptyData: Codable {}
|
||||||
|
|
|
||||||
290
PayfritBeacon/Views/QRScannerView.swift
Normal file
290
PayfritBeacon/Views/QRScannerView.swift
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
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(.blue, 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(.blue, 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(.blue, 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(.blue, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cameraPermissionDenied: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@ struct ScanView: View {
|
||||||
@State private var provisioningState: ProvisioningState = .idle
|
@State private var provisioningState: ProvisioningState = .idle
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var showQRScanner = false
|
||||||
|
@State private var scannedMAC: String?
|
||||||
|
|
||||||
enum ProvisioningState {
|
enum ProvisioningState {
|
||||||
case idle
|
case idle
|
||||||
|
|
@ -68,6 +70,11 @@ struct ScanView: View {
|
||||||
.sheet(isPresented: $showCreateServicePoint) {
|
.sheet(isPresented: $showCreateServicePoint) {
|
||||||
createServicePointSheet
|
createServicePointSheet
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showQRScanner) {
|
||||||
|
QRScannerView { code in
|
||||||
|
handleQRScan(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadServicePoints()
|
await loadServicePoints()
|
||||||
|
|
@ -210,18 +217,31 @@ struct ScanView: View {
|
||||||
|
|
||||||
private var beaconList: some View {
|
private var beaconList: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Scan button
|
// Scan buttons
|
||||||
Button {
|
HStack(spacing: 12) {
|
||||||
Task { await startScan() }
|
Button {
|
||||||
} label: {
|
Task { await startScan() }
|
||||||
HStack {
|
} label: {
|
||||||
Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass")
|
HStack {
|
||||||
Text(bleManager.isScanning ? "Scanning…" : "Scan for Beacons")
|
Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass")
|
||||||
|
Text(bleManager.isScanning ? "Scanning…" : "BLE Scan")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showQRScanner = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "qrcode.viewfinder")
|
||||||
|
Text("QR Scan")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn)
|
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning {
|
if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning {
|
||||||
|
|
@ -613,6 +633,45 @@ struct ScanView: View {
|
||||||
resetProvisioningState()
|
resetProvisioningState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle QR/barcode scan result — could be MAC address, UUID, or other identifier
|
||||||
|
private func handleQRScan(_ code: String) {
|
||||||
|
let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
// Check if it looks like a MAC address (AA:BB:CC:DD:EE:FF or AABBCCDDEEFF)
|
||||||
|
let macPattern = #"^([0-9A-Fa-f]{2}[:-]?){5}[0-9A-Fa-f]{2}$"#
|
||||||
|
if cleaned.range(of: macPattern, options: .regularExpression) != nil {
|
||||||
|
scannedMAC = cleaned.replacingOccurrences(of: "-", with: ":").uppercased()
|
||||||
|
statusMessage = "Scanned MAC: \(scannedMAC ?? cleaned)"
|
||||||
|
// Look up the beacon by MAC
|
||||||
|
Task { await lookupScannedMAC() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it looks like a UUID
|
||||||
|
let uuidPattern = #"^[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}$"#
|
||||||
|
if cleaned.range(of: uuidPattern, options: .regularExpression) != nil {
|
||||||
|
statusMessage = "Scanned UUID: \(cleaned)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic code — just show it
|
||||||
|
statusMessage = "Scanned: \(cleaned)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lookupScannedMAC() async {
|
||||||
|
guard let mac = scannedMAC, let token = appState.token else { return }
|
||||||
|
do {
|
||||||
|
if let existing = try await APIClient.shared.lookupByMac(macAddress: mac, token: token) {
|
||||||
|
let beaconType = existing.beaconType ?? existing.BeaconType ?? "Unknown"
|
||||||
|
statusMessage = "MAC \(mac) — already registered as \(beaconType)"
|
||||||
|
} else {
|
||||||
|
statusMessage = "MAC \(mac) — not yet registered. Scan BLE to find and provision."
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
statusMessage = "MAC \(mac) — lookup failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func resetProvisioningState() {
|
private func resetProvisioningState() {
|
||||||
provisioningState = .idle
|
provisioningState = .idle
|
||||||
statusMessage = ""
|
statusMessage = ""
|
||||||
|
|
@ -620,6 +679,7 @@ struct ScanView: View {
|
||||||
selectedBeacon = nil
|
selectedBeacon = nil
|
||||||
pendingConfig = nil
|
pendingConfig = nil
|
||||||
pendingProvisioner = nil
|
pendingProvisioner = nil
|
||||||
|
scannedMAC = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
|
private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue