diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index c931024..cc5c909 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.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 */; }; 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 */; }; @@ -46,6 +47,7 @@ D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = ""; }; D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -110,6 +112,7 @@ D02000000006 /* LoginView.swift */, D02000000007 /* BusinessListView.swift */, D02000000008 /* ScanView.swift */, + D02000000009 /* QRScannerView.swift */, D02000000010 /* Info.plist */, D02000000060 /* Assets.xcassets */, D02000000070 /* payfrit-favicon-light-outlines.svg */, @@ -254,6 +257,7 @@ D01000000006 /* LoginView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */, D01000000008 /* ScanView.swift in Sources */, + D01000000009 /* QRScannerView.swift in Sources */, D0100000000A /* RootView.swift in Sources */, D010000000B1 /* BLEBeaconScanner.swift in Sources */, D010000000B2 /* BeaconProvisioner.swift in Sources */, diff --git a/PayfritBeacon/Services/APIClient.swift b/PayfritBeacon/Services/APIClient.swift index f2303e2..1642b29 100644 --- a/PayfritBeacon/Services/APIClient.swift +++ b/PayfritBeacon/Services/APIClient.swift @@ -194,6 +194,102 @@ actor APIClient { 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.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.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.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.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.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.self, from: data) + guard resp.success, let profile = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to load profile") + } + return profile + } + // MARK: - Internal private struct EmptyData: Codable {} diff --git a/PayfritBeacon/Views/QRScannerView.swift b/PayfritBeacon/Views/QRScannerView.swift new file mode 100644 index 0000000..b82db83 --- /dev/null +++ b/PayfritBeacon/Views/QRScannerView.swift @@ -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) + } +} diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift index b1ba176..f5e690d 100644 --- a/PayfritBeacon/Views/ScanView.swift +++ b/PayfritBeacon/Views/ScanView.swift @@ -25,6 +25,8 @@ struct ScanView: View { @State private var provisioningState: ProvisioningState = .idle @State private var statusMessage = "" @State private var errorMessage: String? + @State private var showQRScanner = false + @State private var scannedMAC: String? enum ProvisioningState { case idle @@ -68,6 +70,11 @@ struct ScanView: View { .sheet(isPresented: $showCreateServicePoint) { createServicePointSheet } + .sheet(isPresented: $showQRScanner) { + QRScannerView { code in + handleQRScan(code) + } + } } .task { await loadServicePoints() @@ -210,18 +217,31 @@ struct ScanView: View { private var beaconList: some View { VStack(spacing: 0) { - // Scan button - Button { - Task { await startScan() } - } label: { - HStack { - Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass") - Text(bleManager.isScanning ? "Scanning…" : "Scan for Beacons") + // Scan buttons + HStack(spacing: 12) { + Button { + Task { await startScan() } + } label: { + HStack { + 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() if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning { @@ -613,6 +633,45 @@ struct ScanView: View { 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() { provisioningState = .idle statusMessage = "" @@ -620,6 +679,7 @@ struct ScanView: View { selectedBeacon = nil pendingConfig = nil pendingProvisioner = nil + scannedMAC = nil } private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner {