diff --git a/ExportOptions.plist b/ExportOptions.plist
new file mode 100644
index 0000000..c7c819b
--- /dev/null
+++ b/ExportOptions.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ method
+ app-store-connect
+ signingStyle
+ automatic
+ uploadSymbols
+
+ destination
+ upload
+
+
diff --git a/ExportOptionsLocal.plist b/ExportOptionsLocal.plist
new file mode 100644
index 0000000..c7c819b
--- /dev/null
+++ b/ExportOptionsLocal.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ method
+ app-store-connect
+ signingStyle
+ automatic
+ uploadSymbols
+
+ destination
+ upload
+
+
diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj
index 6cbbb10..6063c3c 100644
--- a/PayfritBeacon.xcodeproj/project.pbxproj
+++ b/PayfritBeacon.xcodeproj/project.pbxproj
@@ -7,16 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
+ 281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1775119CBC98A753AE26D84 /* ServicePointListView.swift */; };
7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; };
D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; };
D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.swift */; };
- D01000000003 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000003 /* BeaconBanList.swift */; };
- D01000000004 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000004 /* BeaconScanner.swift */; };
D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; };
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 /* QrScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QrScanView.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 */; };
@@ -25,7 +22,11 @@
/* Begin PBXFileReference section */
7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PayfritBeacon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BLEBeaconScanner.swift; sourceTree = ""; };
+ 964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = ""; };
AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PayfritBeacon.debug.xcconfig"; path = "Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon.debug.xcconfig"; sourceTree = ""; };
+ C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BeaconProvisioner.swift; sourceTree = ""; };
D02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = ""; };
D02000000002 /* Api.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = ""; };
D02000000003 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = ""; };
@@ -40,7 +41,7 @@
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = ""; };
D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; };
- C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ E1775119CBC98A753AE26D84 /* ServicePointListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServicePointListView.swift; sourceTree = ""; };
F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PayfritBeacon.release.xcconfig"; path = "Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon.release.xcconfig"; sourceTree = ""; };
/* End PBXFileReference section */
@@ -62,7 +63,6 @@
AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */,
F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */,
);
- name = Pods;
path = Pods;
sourceTree = "";
};
@@ -76,6 +76,14 @@
);
sourceTree = "";
};
+ C05000000009 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ C03000000001 /* PayfritBeacon.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
D05000000002 /* PayfritBeacon */ = {
isa = PBXGroup;
children = (
@@ -93,18 +101,14 @@
D02000000060 /* Assets.xcassets */,
D02000000070 /* payfrit-favicon-light-outlines.svg */,
D02000000080 /* InfoPlist.strings */,
+ 964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */,
+ 8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */,
+ C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */,
+ E1775119CBC98A753AE26D84 /* ServicePointListView.swift */,
);
path = PayfritBeacon;
sourceTree = "";
};
- C05000000009 /* Products */ = {
- isa = PBXGroup;
- children = (
- C03000000001 /* PayfritBeacon.app */,
- );
- name = Products;
- sourceTree = "";
- };
EEC06FED6BE78CF9357F3158 /* Frameworks */ = {
isa = PBXGroup;
children = (
@@ -143,7 +147,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
- LastUpgradeCheck = 1500;
+ LastUpgradeCheck = 1620;
TargetAttributes = {
C06000000001 = {
CreatedOnToolsVersion = 15.0;
@@ -230,14 +234,11 @@
files = (
D01000000001 /* PayfritBeaconApp.swift in Sources */,
D01000000002 /* Api.swift in Sources */,
- D01000000003 /* BeaconBanList.swift in Sources */,
- D01000000004 /* BeaconScanner.swift in Sources */,
D01000000005 /* DevBanner.swift in Sources */,
D01000000006 /* LoginView.swift in Sources */,
D01000000007 /* BusinessListView.swift in Sources */,
- D01000000008 /* ScanView.swift in Sources */,
- D01000000009 /* QrScanView.swift in Sources */,
D0100000000A /* RootView.swift in Sources */,
+ 281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -379,7 +380,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist;
@@ -412,7 +413,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist;
diff --git a/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme
index 9e91318..a09ab49 100644
--- a/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme
+++ b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme
@@ -1,6 +1,6 @@
BeaconConfigResponse {
+ let json = try await postRequest(
+ endpoint: "/beacon-sharding/get_beacon_config.cfm",
+ body: ["BusinessID": businessId, "ServicePointID": servicePointId],
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ if !parseBool(json["OK"] ?? json["ok"]) {
+ let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to get beacon config"
+ throw ApiException(error)
+ }
+
+ guard let uuid = (json["UUID"] ?? json["uuid"]) as? String,
+ let major = parseIntValue(json["MAJOR"] ?? json["major"]),
+ let minor = parseIntValue(json["MINOR"] ?? json["minor"]) else {
+ throw ApiException("Invalid beacon config response")
+ }
+
+ return BeaconConfigResponse(
+ uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(),
+ major: UInt16(major),
+ minor: UInt16(minor),
+ txPower: parseIntValue(json["TXPOWER"] ?? json["txPower"]) ?? -59,
+ interval: parseIntValue(json["INTERVAL"] ?? json["interval"]) ?? 350
+ )
+ }
+
+ /// Register beacon hardware after provisioning
+ func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, macAddress: String?) async throws -> Bool {
+ var body: [String: Any] = [
+ "BusinessID": businessId,
+ "ServicePointID": servicePointId,
+ "UUID": uuid,
+ "Major": major,
+ "Minor": minor
+ ]
+ if let mac = macAddress, !mac.isEmpty {
+ body["MACAddress"] = mac
+ }
+
+ let json = try await postRequest(
+ endpoint: "/beacon-sharding/register_beacon_hardware.cfm",
+ body: body,
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ if !parseBool(json["OK"] ?? json["ok"]) {
+ let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon"
+ throw ApiException(error)
+ }
+
+ return true
+ }
+
+ /// Verify beacon is broadcasting expected values
+ func verifyBeaconBroadcast(businessId: Int, uuid: String, major: UInt16, minor: UInt16) async throws -> Bool {
+ let json = try await postRequest(
+ endpoint: "/beacon-sharding/verify_beacon_broadcast.cfm",
+ body: [
+ "BusinessID": businessId,
+ "UUID": uuid,
+ "Major": major,
+ "Minor": minor
+ ],
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ return parseBool(json["OK"] ?? json["ok"])
+ }
+
+ /// Allocate beacon namespace for a business (shard + major)
+ func allocateBusinessNamespace(businessId: Int) async throws -> BusinessNamespace {
+ let json = try await postRequest(
+ endpoint: "/beacon-sharding/allocate_business_namespace.cfm",
+ body: ["BusinessID": businessId],
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ // Debug log
+ print("[API] allocateBusinessNamespace response: \(json)")
+
+ if !parseBool(json["OK"] ?? json["ok"]) {
+ let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate namespace"
+ throw ApiException(error)
+ }
+
+ // API returns BeaconShardUUID and BeaconMajor
+ guard let uuid = (json["BeaconShardUUID"] ?? json["BEACONSHARDUUID"] ?? json["UUID"] ?? json["uuid"]) as? String else {
+ throw ApiException("Invalid namespace response - no UUID")
+ }
+
+ guard let major = parseIntValue(json["BeaconMajor"] ?? json["BEACONMAJOR"] ?? json["MAJOR"] ?? json["Major"]) else {
+ throw ApiException("Invalid namespace response - no Major")
+ }
+
+ return BusinessNamespace(
+ shardId: parseIntValue(json["ShardID"] ?? json["SHARDID"]) ?? 0,
+ uuid: uuid.replacingOccurrences(of: "-", with: "").uppercased(),
+ major: UInt16(major),
+ alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"])
+ )
+ }
+
+ /// List service points for a business (for beacon assignment)
+ func listServicePoints(businessId: Int) async throws -> [ServicePoint] {
+ let json = try await postRequest(
+ endpoint: "/servicepoints/list.cfm",
+ body: ["BusinessID": businessId],
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ guard let items = (json["SERVICEPOINTS"] ?? json["servicepoints"] ?? json["ITEMS"] ?? json["items"]) as? [[String: Any]] else {
+ return []
+ }
+
+ return items.compactMap { sp in
+ guard let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"] ?? sp["ID"]) else {
+ return nil
+ }
+ let name = ((sp["Name"] ?? sp["NAME"] ?? sp["ServicePointName"] ?? sp["SERVICEPOINTNAME"]) as? String) ?? "Table \(spId)"
+ let hasBeacon = parseBool(sp["HasBeacon"] ?? sp["HASBEACON"])
+ let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"])
+ return ServicePoint(servicePointId: spId, name: name, hasBeacon: hasBeacon, beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil)
+ }
+ }
+
+ /// Create/save a service point (auto-allocates minor)
+ func saveServicePoint(businessId: Int, name: String, servicePointId: Int? = nil) async throws -> ServicePoint {
+ var body: [String: Any] = ["BusinessID": businessId, "Name": name]
+ if let spId = servicePointId {
+ body["ServicePointID"] = spId
+ }
+
+ let json = try await postRequest(
+ endpoint: "/servicepoints/save.cfm",
+ body: body,
+ extraHeaders: ["X-Business-Id": String(businessId)]
+ )
+
+ if !parseBool(json["OK"] ?? json["ok"]) {
+ let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save service point"
+ throw ApiException(error)
+ }
+
+ // Response has SERVICEPOINT object containing the data
+ let sp = (json["SERVICEPOINT"] ?? json["servicepoint"]) as? [String: Any] ?? json
+
+ let spId = parseIntValue(sp["ServicePointID"] ?? sp["SERVICEPOINTID"]) ?? servicePointId ?? 0
+ let beaconMinor = parseIntValue(sp["BeaconMinor"] ?? sp["BEACONMINOR"])
+
+ return ServicePoint(
+ servicePointId: spId,
+ name: name,
+ hasBeacon: beaconMinor != nil,
+ beaconMinor: beaconMinor != nil ? UInt16(beaconMinor!) : nil
+ )
+ }
+
+ /// Create a new service point (legacy - calls save)
+ func createServicePoint(businessId: Int, name: String) async throws -> ServicePoint {
+ return try await saveServicePoint(businessId: businessId, name: name)
+ }
+
// =========================================================================
// HELPERS
// =========================================================================
@@ -363,3 +531,26 @@ struct MacLookupResult {
let macAddress: String
let servicePointName: String
}
+
+struct BeaconConfigResponse {
+ let uuid: String // 32-char hex, no dashes
+ let major: UInt16
+ let minor: UInt16
+ let txPower: Int
+ let interval: Int
+}
+
+struct ServicePoint: Identifiable {
+ var id: Int { servicePointId }
+ let servicePointId: Int
+ let name: String
+ let hasBeacon: Bool
+ var beaconMinor: UInt16?
+}
+
+struct BusinessNamespace {
+ let shardId: Int
+ let uuid: String // 32-char hex, no dashes
+ let major: UInt16
+ let alreadyAllocated: Bool
+}
diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json
index 3193f63..421ff78 100644
--- a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -5,6 +5,12 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
+ },
+ {
+ "filename" : "appicon.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
}
],
"info" : {
diff --git a/PayfritBeacon/BLEBeaconScanner.swift b/PayfritBeacon/BLEBeaconScanner.swift
new file mode 100644
index 0000000..f072189
--- /dev/null
+++ b/PayfritBeacon/BLEBeaconScanner.swift
@@ -0,0 +1,156 @@
+import Foundation
+import CoreBluetooth
+
+/// Beacon type detected by service UUID
+enum BeaconType: String {
+ case kbeacon = "KBeacon"
+ case bluecharm = "BlueCharm"
+ case unknown = "Unknown"
+}
+
+/// A discovered BLE beacon that can be provisioned
+struct DiscoveredBeacon: Identifiable {
+ let id: UUID // CoreBluetooth peripheral identifier
+ let peripheral: CBPeripheral
+ let name: String
+ let type: BeaconType
+ var rssi: Int
+ var lastSeen: Date
+
+ var displayName: String {
+ if name.isEmpty || name == "Unknown" {
+ return "\(type.rawValue) (\(id.uuidString.prefix(8))...)"
+ }
+ return name
+ }
+}
+
+/// Scans for BLE beacons that can be configured (KBeacon and BlueCharm)
+class BLEBeaconScanner: NSObject, ObservableObject {
+
+ // KBeacon config service
+ static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
+ // BlueCharm config service
+ static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
+
+ @Published var isScanning = false
+ @Published var discoveredBeacons: [DiscoveredBeacon] = []
+ @Published var bluetoothState: CBManagerState = .unknown
+
+ private var centralManager: CBCentralManager!
+ private var scanTimer: Timer?
+
+ override init() {
+ super.init()
+ centralManager = CBCentralManager(delegate: self, queue: .main)
+ }
+
+ /// Start scanning for configurable beacons
+ func startScanning() {
+ guard centralManager.state == .poweredOn else {
+ NSLog("BLEBeaconScanner: Bluetooth not ready, state=\(centralManager.state.rawValue)")
+ return
+ }
+
+ NSLog("BLEBeaconScanner: Starting scan for configurable beacons")
+ discoveredBeacons.removeAll()
+ isScanning = true
+
+ // Scan for devices advertising our config services
+ // Note: We scan for all devices and filter by service after connection
+ // because some beacons don't advertise their config service UUID
+ centralManager.scanForPeripherals(
+ withServices: nil, // Scan all - we'll filter by name/characteristics
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
+ )
+
+ // Auto-stop after 10 seconds
+ scanTimer?.invalidate()
+ scanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in
+ self?.stopScanning()
+ }
+ }
+
+ /// Stop scanning
+ func stopScanning() {
+ NSLog("BLEBeaconScanner: Stopping scan, found \(discoveredBeacons.count) beacons")
+ centralManager.stopScan()
+ isScanning = false
+ scanTimer?.invalidate()
+ scanTimer = nil
+ }
+
+ /// Check if Bluetooth is available
+ var isBluetoothReady: Bool {
+ centralManager.state == .poweredOn
+ }
+}
+
+// MARK: - CBCentralManagerDelegate
+
+extension BLEBeaconScanner: CBCentralManagerDelegate {
+
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ bluetoothState = central.state
+ NSLog("BLEBeaconScanner: Bluetooth state changed to \(central.state.rawValue)")
+
+ if central.state == .poweredOn && isScanning {
+ // Resume scanning if we were trying to scan
+ startScanning()
+ }
+ }
+
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
+ advertisementData: [String: Any], rssi RSSI: NSNumber) {
+
+ let rssiValue = RSSI.intValue
+ guard rssiValue > -90 && rssiValue < 0 else { return } // Filter weak signals
+
+ let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
+
+ // Determine beacon type from name or advertised services
+ var beaconType: BeaconType = .unknown
+
+ // Check advertised service UUIDs
+ if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
+ if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) {
+ beaconType = .kbeacon
+ } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) {
+ beaconType = .bluecharm
+ }
+ }
+
+ // Also check by name patterns
+ if beaconType == .unknown {
+ let lowerName = name.lowercased()
+ if lowerName.contains("kbeacon") || lowerName.contains("kbpro") || lowerName.hasPrefix("kb") {
+ beaconType = .kbeacon
+ } else if lowerName.contains("bluecharm") || lowerName.contains("bc") || lowerName.hasPrefix("bc") {
+ beaconType = .bluecharm
+ }
+ }
+
+ // Only track beacons we can identify
+ guard beaconType != .unknown else { return }
+
+ // Update or add beacon
+ if let index = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) {
+ discoveredBeacons[index].rssi = rssiValue
+ discoveredBeacons[index].lastSeen = Date()
+ } else {
+ let beacon = DiscoveredBeacon(
+ id: peripheral.identifier,
+ peripheral: peripheral,
+ name: name,
+ type: beaconType,
+ rssi: rssiValue,
+ lastSeen: Date()
+ )
+ discoveredBeacons.append(beacon)
+ NSLog("BLEBeaconScanner: Discovered \(beaconType.rawValue) beacon: \(name) RSSI=\(rssiValue)")
+ }
+
+ // Sort by RSSI (strongest first)
+ discoveredBeacons.sort { $0.rssi > $1.rssi }
+ }
+}
diff --git a/PayfritBeacon/BeaconProvisioner.swift b/PayfritBeacon/BeaconProvisioner.swift
new file mode 100644
index 0000000..c26d9e5
--- /dev/null
+++ b/PayfritBeacon/BeaconProvisioner.swift
@@ -0,0 +1,398 @@
+import Foundation
+import CoreBluetooth
+
+/// Result of a provisioning operation
+enum ProvisioningResult {
+ case success
+ case failure(String)
+}
+
+/// Configuration to write to a beacon
+struct BeaconConfig {
+ let uuid: String // 32-char hex, no dashes
+ let major: UInt16
+ let minor: UInt16
+ let txPower: Int8 // Typically -59
+ let interval: UInt16 // Advertising interval in ms, typically 350
+}
+
+/// Handles GATT connection and provisioning of beacons
+class BeaconProvisioner: NSObject, ObservableObject {
+
+ // MARK: - BlueCharm GATT Characteristics
+ private static let BLUECHARM_SERVICE = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB")
+ private static let BLUECHARM_PASSWORD_CHAR = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB")
+ private static let BLUECHARM_UUID_CHAR = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB")
+ private static let BLUECHARM_MAJOR_CHAR = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB")
+ private static let BLUECHARM_MINOR_CHAR = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB")
+ private static let BLUECHARM_TXPOWER_CHAR = CBUUID(string: "0000FFF5-0000-1000-8000-00805F9B34FB")
+
+ // MARK: - KBeacon GATT (basic - for full support use their SDK)
+ private static let KBEACON_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB")
+
+ // BlueCharm default passwords to try
+ private static let BLUECHARM_PASSWORDS = ["000000", "FFFF", "123456"]
+
+ // KBeacon default passwords
+ private static let KBEACON_PASSWORDS = [
+ "0000000000000000", // 16 zeros
+ "31323334353637383930313233343536" // ASCII "1234567890123456"
+ ]
+
+ @Published var state: ProvisioningState = .idle
+ @Published var progress: String = ""
+
+ enum ProvisioningState: Equatable {
+ case idle
+ case connecting
+ case discoveringServices
+ case authenticating
+ case writing
+ case verifying
+ case success
+ case failed(String)
+ }
+
+ private var centralManager: CBCentralManager!
+ private var peripheral: CBPeripheral?
+ private var beaconType: BeaconType = .unknown
+ private var config: BeaconConfig?
+ private var completion: ((ProvisioningResult) -> Void)?
+
+ private var configService: CBService?
+ private var characteristics: [CBUUID: CBCharacteristic] = [:]
+ private var passwordIndex = 0
+ private var writeQueue: [(CBCharacteristic, Data)] = []
+
+ override init() {
+ super.init()
+ centralManager = CBCentralManager(delegate: self, queue: .main)
+ }
+
+ /// Provision a beacon with the given configuration
+ func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) {
+ guard centralManager.state == .poweredOn else {
+ completion(.failure("Bluetooth not available"))
+ return
+ }
+
+ self.peripheral = beacon.peripheral
+ self.beaconType = beacon.type
+ self.config = config
+ self.completion = completion
+ self.passwordIndex = 0
+ self.characteristics.removeAll()
+ self.writeQueue.removeAll()
+
+ state = .connecting
+ progress = "Connecting to \(beacon.displayName)..."
+
+ centralManager.connect(beacon.peripheral, options: nil)
+
+ // Timeout after 30 seconds
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
+ if self?.state != .success && self?.state != .idle {
+ self?.fail("Connection timeout")
+ }
+ }
+ }
+
+ /// Cancel current provisioning
+ func cancel() {
+ if let peripheral = peripheral {
+ centralManager.cancelPeripheralConnection(peripheral)
+ }
+ cleanup()
+ }
+
+ private func cleanup() {
+ peripheral = nil
+ config = nil
+ completion = nil
+ configService = nil
+ characteristics.removeAll()
+ writeQueue.removeAll()
+ state = .idle
+ progress = ""
+ }
+
+ private func fail(_ message: String) {
+ NSLog("BeaconProvisioner: Failed - \(message)")
+ state = .failed(message)
+ if let peripheral = peripheral {
+ centralManager.cancelPeripheralConnection(peripheral)
+ }
+ completion?(.failure(message))
+ cleanup()
+ }
+
+ private func succeed() {
+ NSLog("BeaconProvisioner: Success!")
+ state = .success
+ if let peripheral = peripheral {
+ centralManager.cancelPeripheralConnection(peripheral)
+ }
+ completion?(.success)
+ cleanup()
+ }
+
+ // MARK: - BlueCharm Provisioning
+
+ private func provisionBlueCharm() {
+ guard let service = configService else {
+ fail("Config service not found")
+ return
+ }
+
+ // Discover characteristics
+ peripheral?.discoverCharacteristics([
+ BeaconProvisioner.BLUECHARM_PASSWORD_CHAR,
+ BeaconProvisioner.BLUECHARM_UUID_CHAR,
+ BeaconProvisioner.BLUECHARM_MAJOR_CHAR,
+ BeaconProvisioner.BLUECHARM_MINOR_CHAR,
+ BeaconProvisioner.BLUECHARM_TXPOWER_CHAR
+ ], for: service)
+ }
+
+ private func authenticateBlueCharm() {
+ guard let passwordChar = characteristics[BeaconProvisioner.BLUECHARM_PASSWORD_CHAR] else {
+ fail("Password characteristic not found")
+ return
+ }
+
+ let passwords = BeaconProvisioner.BLUECHARM_PASSWORDS
+ guard passwordIndex < passwords.count else {
+ fail("Authentication failed - tried all passwords")
+ return
+ }
+
+ state = .authenticating
+ progress = "Authenticating..."
+
+ let password = passwords[passwordIndex]
+ if let data = password.data(using: .utf8) {
+ NSLog("BeaconProvisioner: Trying BlueCharm password \(passwordIndex + 1)/\(passwords.count)")
+ peripheral?.writeValue(data, for: passwordChar, type: .withResponse)
+ }
+ }
+
+ private func writeBlueCharmConfig() {
+ guard let config = config else {
+ fail("No config provided")
+ return
+ }
+
+ state = .writing
+ progress = "Writing configuration..."
+
+ // Build write queue
+ writeQueue.removeAll()
+
+ // UUID - 16 bytes, no dashes
+ if let uuidChar = characteristics[BeaconProvisioner.BLUECHARM_UUID_CHAR] {
+ if let uuidData = hexStringToData(config.uuid) {
+ writeQueue.append((uuidChar, uuidData))
+ }
+ }
+
+ // Major - 2 bytes big-endian
+ if let majorChar = characteristics[BeaconProvisioner.BLUECHARM_MAJOR_CHAR] {
+ var major = config.major.bigEndian
+ let majorData = Data(bytes: &major, count: 2)
+ writeQueue.append((majorChar, majorData))
+ }
+
+ // Minor - 2 bytes big-endian
+ if let minorChar = characteristics[BeaconProvisioner.BLUECHARM_MINOR_CHAR] {
+ var minor = config.minor.bigEndian
+ let minorData = Data(bytes: &minor, count: 2)
+ writeQueue.append((minorChar, minorData))
+ }
+
+ // TxPower - 1 byte signed
+ if let txChar = characteristics[BeaconProvisioner.BLUECHARM_TXPOWER_CHAR] {
+ var txPower = config.txPower
+ let txData = Data(bytes: &txPower, count: 1)
+ writeQueue.append((txChar, txData))
+ }
+
+ // Start writing
+ processWriteQueue()
+ }
+
+ private func processWriteQueue() {
+ guard !writeQueue.isEmpty else {
+ // All writes complete
+ progress = "Configuration complete!"
+ succeed()
+ return
+ }
+
+ let (characteristic, data) = writeQueue.removeFirst()
+ NSLog("BeaconProvisioner: Writing \(data.count) bytes to \(characteristic.uuid)")
+ peripheral?.writeValue(data, for: characteristic, type: .withResponse)
+ }
+
+ // MARK: - KBeacon Provisioning
+
+ private func provisionKBeacon() {
+ // KBeacon uses a more complex protocol
+ // For now, we'll just try basic GATT writes
+ // Full support would require their SDK
+
+ state = .writing
+ progress = "KBeacon requires their SDK for full support.\nUse clipboard to copy config."
+
+ // For now, just succeed and let user use clipboard
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
+ self?.fail("KBeacon provisioning requires their SDK. Please use the KBeacon app with the copied config.")
+ }
+ }
+
+ // MARK: - Helpers
+
+ private func hexStringToData(_ hex: String) -> Data? {
+ let clean = hex.replacingOccurrences(of: "-", with: "").uppercased()
+ guard clean.count == 32 else { return nil }
+
+ var data = Data()
+ var index = clean.startIndex
+ while index < clean.endIndex {
+ let nextIndex = clean.index(index, offsetBy: 2)
+ let byteString = String(clean[index.. $1.rssi }
@@ -108,11 +122,14 @@ class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue }
let uuid = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
+ let major = beacon.major.uint16Value
+ let minor = beacon.minor.uint16Value
+ let key = "\(uuid)|\(major)|\(minor)"
- if beaconSamples[uuid] == nil {
- beaconSamples[uuid] = []
+ if beaconSamples[key] == nil {
+ beaconSamples[key] = BeaconSampleData(uuid: uuid, major: major, minor: minor, rssiSamples: [])
}
- beaconSamples[uuid]?.append(rssiValue)
+ beaconSamples[key]?.rssiSamples.append(rssiValue)
}
}
@@ -123,6 +140,21 @@ class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
struct DetectedBeacon {
let uuid: String // 32-char uppercase hex, no dashes
+ let major: UInt16
+ let minor: UInt16
let rssi: Int
let samples: Int
+
+ /// Format for clipboard (for pasting into manufacturer beacon config apps)
+ func copyableConfig() -> String {
+ // Format UUID with dashes for standard display
+ let formattedUuid = formatUuidWithDashes(uuid)
+ return "UUID: \(formattedUuid)\nMajor: \(major)\nMinor: \(minor)"
+ }
+
+ private func formatUuidWithDashes(_ raw: String) -> String {
+ guard raw.count == 32 else { return raw }
+ let chars = Array(raw)
+ return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
+ }
}
diff --git a/PayfritBeacon/BeaconShardPool.swift b/PayfritBeacon/BeaconShardPool.swift
index 2595a3f..dc0cf0a 100644
--- a/PayfritBeacon/BeaconShardPool.swift
+++ b/PayfritBeacon/BeaconShardPool.swift
@@ -7,72 +7,72 @@ import CoreLocation
/// If we need more capacity, ship more UUIDs in a future app version.
enum BeaconShardPool {
- /// All Payfrit shard UUIDs as strings.
+ /// All Payfrit shard UUIDs as strings (from BeaconShards database table).
static let uuidStrings: [String] = [
- "f7826da6-4fa2-4e98-8024-bc5b71e0893e",
- "2f234454-cf6d-4a0f-adf2-f4911ba9ffa6",
- "b9407f30-f5f8-466e-aff9-25556b57fe6d",
- "e2c56db5-dffb-48d2-b060-d0f5a71096e0",
- "d0d3fa86-ca76-45ec-9bd9-6af4fac1e268",
- "a7ae2eb7-1f00-4168-b99b-a749bac36c92",
- "8deefbb9-f738-4297-8040-96668bb44281",
- "5a4bcfce-174e-4bac-a814-092978f50e04",
- "74278bda-b644-4520-8f0c-720eaf059935",
- "e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a",
- "1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a",
- "a1b2c3d4-e5f6-4789-abcd-ef0123456789",
- "98765432-10fe-4cba-9876-543210fedcba",
- "deadbeef-cafe-4bab-dead-beefcafebabe",
- "c0ffee00-dead-4bee-f000-ba5eba11fade",
- "0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d",
- "12345678-90ab-4def-1234-567890abcdef",
- "fedcba98-7654-4210-fedc-ba9876543210",
- "abcd1234-ef56-4789-abcd-1234ef567890",
- "11111111-2222-4333-4444-555566667777",
- "88889999-aaaa-4bbb-cccc-ddddeeeeefff",
- "01234567-89ab-4cde-f012-3456789abcde",
- "a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5",
- "f0e0d0c0-b0a0-4908-0706-050403020100",
- "13579bdf-2468-4ace-1357-9bdf2468ace0",
- "fdb97531-eca8-4642-0fdb-97531eca8642",
- "aabbccdd-eeff-4011-2233-445566778899",
- "99887766-5544-4332-2110-ffeeddccbbaa",
- "a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5",
- "5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f",
- "00112233-4455-4667-7889-9aabbccddeef",
- "feeddccb-baa9-4887-7665-5443322110ff",
- "1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d",
- "d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8",
- "0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f",
- "f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0",
- "12ab34cd-56ef-4789-0abc-def123456789",
- "987654fe-dcba-4098-7654-321fedcba098",
- "abcdef01-2345-4678-9abc-def012345678",
- "876543fe-dcba-4210-9876-543fedcba210",
- "0a0b0c0d-0e0f-4101-1121-314151617181",
- "91a1b1c1-d1e1-4f10-2030-405060708090",
- "a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d",
- "d4c3b2a1-0f9e-48d7-c6b5-a49382716050",
- "50607080-90a0-4b0c-0d0e-0f1011121314",
- "14131211-100f-4e0d-0c0b-0a0908070605",
- "a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90",
- "09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1",
- "11223344-5566-4778-899a-abbccddeeff0",
- "ffeeddc0-bbaa-4988-7766-554433221100",
- "a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8",
- "b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a",
- "12341234-5678-4567-89ab-89abcdefcdef",
- "fedcfedc-ba98-4ba9-8765-87654321d321",
- "0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea",
- "eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af",
- "01020304-0506-4708-090a-0b0c0d0e0f10",
- "100f0e0d-0c0b-4a09-0807-060504030201",
- "aabbccdd-1122-4334-4556-6778899aabbc",
- "cbba9988-7766-4554-4332-2110ddccbbaa",
- "f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef",
- "efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee",
- "a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b",
- "4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff"
+ "34b8cd87-1905-47a9-a7b7-fad8a4c011a1",
+ "5ee62089-8599-46f7-a399-d40c2f398712",
+ "fd3790ac-33eb-4091-b1c7-1e0615e68a87",
+ "bfce1bc4-ad2a-462a-918b-df26752c378d",
+ "845b64c7-0c91-41cd-9c30-ac56b6ae5ca1",
+ "7de0b2fb-69a3-4dbb-9808-8f33e2566661",
+ "54c34c7e-5a9b-4738-a4b4-2e228337ae3c",
+ "70f9fc09-25e6-46ec-8395-72f487877a1a",
+ "f8cef0af-6bef-4ba6-8a5d-599d647b628c",
+ "41868a10-7fd6-41c6-9b14-b5ca33c11471",
+ "25e1044d-446b-4403-9abd-1e15f806dfe9",
+ "cdeefaf0-bf95-4dab-8bd5-f7b261f9935d",
+ "bf3b156a-a0fb-4bad-b3fd-0b408ffc9d6e",
+ "11b7c63e-a61d-4530-a447-2cb8e6a30a45",
+ "d0519f2d-97a1-4484-a2c2-57135affe427",
+ "d2d1caa9-aa89-4c9d-93b1-d6fe1527f622",
+ "6f65071f-e060-44e7-a6f9-5dae49cbf095",
+ "4492bbbb-8584-421a-8f26-cb20c7727ead",
+ "73bc2b23-9cf8-4a93-8bfc-5cf44f03a973",
+ "70129c14-78ed-447e-ab9e-638243f8bdae",
+ "6956f91b-e581-48a5-b364-181662cb2f9f",
+ "39fc9b45-d1b3-4d97-aa82-a52457bf808f",
+ "ef150f40-5e24-4267-a1d0-b5ea5ce66c99",
+ "ac504bbd-fbdb-46d0-83c0-cdf53e82cdf8",
+ "bbecefd2-7317-4fd4-93d9-2661df8c4762",
+ "b252557a-e998-4b28-9d7d-bc9a8c907441",
+ "527b504c-d363-438a-bb65-2db1db6cb487",
+ "ea5eef55-b7e9-4866-a4a1-8b4a1f6ea79d",
+ "40a5d0c8-727a-47db-8ffd-154bfc36e03d",
+ "4d90467e-5f68-41ef-b4ec-2b2c8ec1adce",
+ "1cc513ee-627a-4cfe-b162-7cea3cb1374e",
+ "2913ab6e-ab0d-4666-bff1-7fe3169c4f55",
+ "7371381a-f2aa-4a40-b497-b06e66d51a31",
+ "e890450f-0b8d-4a5a-973e-5af37233c13b",
+ "d190eef0-59ee-44bc-a459-e0d5b767b26f",
+ "76ebe90f-f4b2-45d4-9887-841b1ddd3ca9",
+ "7fbed5b0-a212-4497-9b54-9831e433491b",
+ "3d41e7b0-5d91-4178-81c1-de42ab6b3466",
+ "5befd90a-7967-4fe5-89ba-7a9de617d507",
+ "e033235c-f69d-4018-a197-78e7df59dfa3",
+ "71edc8b9-b120-415a-a1d4-77bdd8e30f14",
+ "521de568-a0e6-4ec9-bf1c-bdb7b9f9cae2",
+ "a28db91b-c18f-4b4b-804f-38664d3456cc",
+ "5738e431-25bc-4cc1-a4e2-da8c562075b3",
+ "f90b7c87-324b-4fd5-b2ff-acf6800f6bd0",
+ "bd4ea89c-f99d-4440-8e27-d295836fd09d",
+ "b5c2d016-1143-4bb2-992b-f08fb073ef2c",
+ "0bb16d1a-f970-4baf-b410-76e5f1ff7c9e",
+ "b4f22e62-4052-4c58-a18b-38e2a5c04b9a",
+ "b8150be6-0fbd-4bb9-9993-a8a2992d5003",
+ "50d2d4c6-1907-4789-afe2-3b28baa3c679",
+ "ee42778d-53c9-42c9-8dfa-87a509799990",
+ "6001ee07-fc35-45f7-8ef6-afc30371bd73",
+ "0761bede-deb6-4b08-bfbb-10675060164a",
+ "c03ac1de-a7ea-490a-b3a9-7cc5e8ab4dd1",
+ "57ecd21d-76b1-4016-8c86-7c5a861aae67",
+ "f119066c-a4e2-4b2e-aef3-6a0bf6b288bc",
+ "e2c2ccff-d651-488f-9d74-4ecf4a0487e0",
+ "7d5ba66c-d8f8-4d54-9900-4c52b5667682",
+ "1b0f57f9-0c02-43a5-9740-63acbc9574a0",
+ "314fdc08-fbfd-4bd8-aaae-e579d9ef567d",
+ "5835398b-95ac-44ba-af78-a5d3dc4fc0ad",
+ "3eb1baca-84bb-4d85-8860-42a9df3b820e",
+ "da73ba99-976c-4e81-894a-d799e05f9186"
]
/// All Payfrit shard UUIDs as UUID objects.
diff --git a/PayfritBeacon/BusinessListView.swift b/PayfritBeacon/BusinessListView.swift
index 75726a0..b88cd4b 100644
--- a/PayfritBeacon/BusinessListView.swift
+++ b/PayfritBeacon/BusinessListView.swift
@@ -62,7 +62,8 @@ struct BusinessListView: View {
private func businessRow(_ business: Business) -> some View {
HStack(spacing: 12) {
if let ext = business.headerImageExtension, !ext.isEmpty {
- let imageUrl = URL(string: "https://dev.payfrit.com/uploads/businesses/\(business.businessId)/header.\(ext)")
+ let baseDomain = Api.IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
+ let imageUrl = URL(string: "\(baseDomain)/uploads/businesses/\(business.businessId)/header.\(ext)")
KFImage(imageUrl)
.resizable()
.placeholder {
@@ -100,7 +101,8 @@ struct BusinessListView: View {
private var addBusinessButton: some View {
Button {
- if let url = URL(string: "https://dev.payfrit.com/portal/index.html") {
+ let baseDomain = Api.IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
+ if let url = URL(string: "\(baseDomain)/portal/index.html") {
UIApplication.shared.open(url)
}
} label: {
diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist
index fa9623d..6296130 100644
--- a/PayfritBeacon/Info.plist
+++ b/PayfritBeacon/Info.plist
@@ -20,20 +20,28 @@
$(MARKETING_VERSION)
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
- NSLocationWhenInUseUsageDescription
- Payfrit Beacon uses your location to detect nearby BLE beacons.
- NSLocationAlwaysAndWhenInUseUsageDescription
- Payfrit Beacon uses your location to detect nearby BLE beacons.
- NSBluetoothAlwaysUsageDescription
- Payfrit Beacon uses Bluetooth to scan for and manage nearby beacons.
NSFaceIDUsageDescription
Payfrit Beacon uses Face ID for quick sign-in.
- NSCameraUsageDescription
- Payfrit Beacon uses the camera to scan QR codes on beacon stickers.
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UILaunchScreen
+
diff --git a/PayfritBeacon/RootView.swift b/PayfritBeacon/RootView.swift
index d49624d..941120f 100644
--- a/PayfritBeacon/RootView.swift
+++ b/PayfritBeacon/RootView.swift
@@ -31,7 +31,7 @@ struct RootView: View {
}
)
.fullScreenCover(item: $selectedBusiness) { business in
- ScanView(
+ ServicePointListView(
businessId: business.businessId,
businessName: business.name,
onBack: { selectedBusiness = nil }
diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift
index a3544fa..f91b3c6 100644
--- a/PayfritBeacon/ScanView.swift
+++ b/PayfritBeacon/ScanView.swift
@@ -1,79 +1,26 @@
import SwiftUI
-// MARK: - Data Types
-
-enum BeaconStatus {
- case new, thisBusiness, otherBusiness, banned
-}
-
-struct EnrichedBeacon: Identifiable {
- var id: String { uuid }
- let uuid: String
- let rssi: Int
- let samples: Int
- let status: BeaconStatus
- let assignedBusinessName: String?
- let assignedBeaconName: String?
- let assignedServicePointName: String?
- let beaconId: Int
- let banReason: String?
-}
-
-// MARK: - ScanView
+// MARK: - ScanView (Beacon Provisioning)
struct ScanView: View {
let businessId: Int
let businessName: String
var onBack: () -> Void
+ @StateObject private var bleScanner = BLEBeaconScanner()
+ @StateObject private var provisioner = BeaconProvisioner()
+
+ @State private var servicePoints: [ServicePoint] = []
@State private var nextTableNumber: Int = 1
- @State private var hasScannedOnce = false
- @State private var savedUuids: Set = []
- @State private var scanResults: [EnrichedBeacon] = []
- @State private var knownAssignments: [String: BeaconLookupResult] = [:]
- @State private var pendingQrMac: String?
- @State private var pendingQrBeacon: EnrichedBeacon?
- @State private var isScanning = false
+ @State private var provisionedCount: Int = 0
+
+ // UI State
@State private var snackMessage: String?
- @State private var showQrScanner = false
- @State private var qrScanForBeacon: EnrichedBeacon?
-
- // Dialog state
@State private var showAssignSheet = false
- @State private var assignBeacon: EnrichedBeacon?
+ @State private var selectedBeacon: DiscoveredBeacon?
@State private var assignName = ""
-
- @State private var showBannedSheet = false
- @State private var bannedBeacon: EnrichedBeacon?
- @State private var generatedUuid: String?
-
- @State private var showInfoAlert = false
- @State private var infoBeacon: EnrichedBeacon?
-
- @State private var showOptionsAlert = false
- @State private var optionsBeacon: EnrichedBeacon?
-
- @State private var showWipeAlert = false
- @State private var wipeBeacon: EnrichedBeacon?
-
- @State private var showMacLookupAlert = false
- @State private var macLookupResult: MacLookupResult?
-
- @State private var showBeaconPickerAlert = false
- @State private var pickerMac: String?
-
- @State private var showNoBeaconsScanAlert = false
- @State private var noBeaconsMac: String?
-
- @StateObject private var beaconScanner = BeaconScanner()
- @State private var targetUUIDs: [UUID] = []
-
- // Common fallback UUIDs for beacons that may not be registered yet
- private static let fallbackUUIDs = [
- "E2C56DB5DFFB48D2B060D0F5A71096E0",
- "B9407F30F5F8466EAFF925556B57FE6D",
- "F7826DA64FA24E988024BC5B71E0893E",
- ]
+ @State private var isProvisioning = false
+ @State private var provisioningProgress = ""
var body: some View {
VStack(spacing: 0) {
@@ -83,9 +30,14 @@ struct ScanView: View {
Image(systemName: "chevron.left")
.font(.title3)
}
- Text("Beacon Scanner")
+ Text("Beacon Setup")
.font(.headline)
Spacer()
+ if provisionedCount > 0 {
+ Text("\(provisionedCount) done")
+ .font(.caption)
+ .foregroundColor(.payfritGreen)
+ }
}
.padding()
.background(Color(.systemBackground))
@@ -105,22 +57,40 @@ struct ScanView: View {
Divider()
+ // Bluetooth status
+ if bleScanner.bluetoothState != .poweredOn {
+ bluetoothWarning
+ }
+
// Beacon list
- if scanResults.isEmpty {
+ if bleScanner.discoveredBeacons.isEmpty {
Spacer()
- if isScanning || !hasScannedOnce {
- ProgressView("Scanning for beacons...")
+ if bleScanner.isScanning {
+ VStack(spacing: 12) {
+ ProgressView()
+ Text("Scanning for beacons...")
+ .foregroundColor(.secondary)
+ }
} else {
- Text("No beacons detected nearby.")
- .foregroundColor(.secondary)
+ VStack(spacing: 12) {
+ Image(systemName: "antenna.radiowaves.left.and.right")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ Text("No beacons found")
+ .foregroundColor(.secondary)
+ Text("Make sure beacons are powered on\nand in configuration mode")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 8) {
- ForEach(scanResults) { beacon in
+ ForEach(bleScanner.discoveredBeacons) { beacon in
beaconRow(beacon)
- .onTapGesture { handleBeaconTap(beacon) }
+ .onTapGesture { selectBeacon(beacon) }
}
}
.padding(.horizontal)
@@ -130,26 +100,22 @@ struct ScanView: View {
// Bottom action bar
VStack(spacing: 8) {
- HStack(spacing: 12) {
- Button(action: requestPermissionsAndScan) {
- Text(hasScannedOnce ? "Scan Next" : "Scan for Beacons")
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(Color.payfritGreen)
- .foregroundColor(.white)
- .cornerRadius(8)
- }
- .disabled(isScanning)
-
- Button(action: { launchQrScan(forBeacon: nil) }) {
- Text("QR")
- .padding(.horizontal, 20)
- .padding(.vertical, 12)
- .background(Color(.systemGray5))
- .foregroundColor(.primary)
- .cornerRadius(8)
+ Button(action: startScan) {
+ HStack {
+ if bleScanner.isScanning {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .scaleEffect(0.8)
+ }
+ Text(bleScanner.isScanning ? "Scanning..." : "Scan for Beacons")
}
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(bleScanner.isScanning ? Color.gray : Color.payfritGreen)
+ .foregroundColor(.white)
+ .cornerRadius(8)
}
+ .disabled(bleScanner.isScanning || bleScanner.bluetoothState != .poweredOn)
Button(action: onBack) {
Text("Done")
@@ -166,84 +132,41 @@ struct ScanView: View {
}
.modifier(DevBanner())
.overlay(snackOverlay, alignment: .bottom)
- .onAppear { loadExistingBeacons() }
.sheet(isPresented: $showAssignSheet) { assignSheet }
- .sheet(isPresented: $showBannedSheet) { bannedSheet }
- .sheet(isPresented: $showQrScanner) {
- QrScanView { value, type in
- showQrScanner = false
- handleQrScanResult(qrResult: value, qrType: type)
- }
+ .onAppear { loadServicePoints() }
+ }
+
+ // MARK: - Bluetooth Warning
+
+ private var bluetoothWarning: some View {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.warningOrange)
+ Text(bluetoothMessage)
+ .font(.caption)
+ Spacer()
}
- .alert("Beacon Info", isPresented: $showInfoAlert, presenting: infoBeacon) { _ in
- Button("OK", role: .cancel) {}
- } message: { beacon in
- Text(infoMessage(beacon))
- }
- .confirmationDialog("Beacon Options", isPresented: $showOptionsAlert, presenting: optionsBeacon) { beacon in
- Button("Reassign") {
- openAssignDialog(beacon)
- }
- Button("Wipe", role: .destructive) {
- wipeBeacon = beacon
- showWipeAlert = true
- }
- Button("Cancel", role: .cancel) {}
- } message: { beacon in
- Text(optionsMessage(beacon))
- }
- .alert("Wipe Beacon?", isPresented: $showWipeAlert, presenting: wipeBeacon) { beacon in
- Button("Wipe", role: .destructive) {
- performWipe(beacon)
- }
- Button("Cancel", role: .cancel) {}
- } message: { beacon in
- let beaconName = beacon.assignedBeaconName ?? "unnamed"
- let spName = beacon.assignedServicePointName ?? beaconName
- let displayName = beaconName == spName ? beaconName : "\(beaconName) (\(spName))"
- Text("This will unlink \"\(displayName)\" from its service point. The beacon will appear as NEW on the next scan.")
- }
- .alert("MAC Lookup Result", isPresented: $showMacLookupAlert, presenting: macLookupResult) { _ in
- Button("OK", role: .cancel) {}
- } message: { result in
- Text("Beacon: \(result.beaconName)\nBusiness: \(result.businessName)\nService Point: \(result.servicePointName)\nMAC: \(result.macAddress)\nUUID: \(result.uuid.isEmpty ? "Not set" : result.uuid)")
- }
- .alert("MAC Scanned", isPresented: $showNoBeaconsScanAlert, presenting: noBeaconsMac) { _ in
- Button("Scan") {
- requestPermissionsAndScan()
- }
- Button("Cancel", role: .cancel) {
- pendingQrMac = nil
- }
- } message: { mac in
- Text("Scanned MAC: \(mac)\n\nNo beacons detected yet. Scan for beacons first to link this MAC address.")
- }
- .confirmationDialog("Link MAC to Beacon", isPresented: $showBeaconPickerAlert, presenting: pickerMac) { _ in
- ForEach(Array(scanResults.enumerated()), id: \.element.uuid) { index, beacon in
- let statusLabel: String = {
- switch beacon.status {
- case .new: return "NEW"
- case .thisBusiness: return beacon.assignedBeaconName ?? "This business"
- case .otherBusiness: return beacon.assignedBusinessName ?? "Other business"
- case .banned: return "BANNED"
- }
- }()
- Button("\(statusLabel) (\(beacon.rssi) dBm)") {
- openAssignDialog(scanResults[index])
- }
- }
- Button("Cancel", role: .cancel) {
- pendingQrMac = nil
- }
- } message: { mac in
- Text("Select a beacon to link with MAC \(mac)")
+ .padding()
+ .background(Color.warningOrange.opacity(0.1))
+ }
+
+ private var bluetoothMessage: String {
+ switch bleScanner.bluetoothState {
+ case .poweredOff:
+ return "Bluetooth is turned off"
+ case .unauthorized:
+ return "Bluetooth permission denied"
+ case .unsupported:
+ return "Bluetooth not supported"
+ default:
+ return "Bluetooth not ready"
}
}
// MARK: - Beacon Row
- private func beaconRow(_ beacon: EnrichedBeacon) -> some View {
- HStack(spacing: 8) {
+ private func beaconRow(_ beacon: DiscoveredBeacon) -> some View {
+ HStack(spacing: 12) {
// Signal strength indicator
Rectangle()
.fill(signalColor(beacon.rssi))
@@ -251,18 +174,28 @@ struct ScanView: View {
.cornerRadius(2)
VStack(alignment: .leading, spacing: 4) {
- Text(BeaconBanList.formatUuid(beacon.uuid))
- .font(.system(.caption, design: .monospaced))
+ Text(beacon.displayName)
+ .font(.system(.body, design: .default))
.lineLimit(1)
- .truncationMode(.middle)
- statusBadge(beacon)
+ HStack(spacing: 8) {
+ Text(beacon.type.rawValue)
+ .font(.caption2.weight(.medium))
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(beacon.type == .kbeacon ? Color.blue : Color.purple)
+ .cornerRadius(4)
+
+ Text("\(beacon.rssi) dBm")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
}
Spacer()
- Text("\(beacon.rssi) dBm")
- .font(.caption)
+ Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding(12)
@@ -277,30 +210,97 @@ struct ScanView: View {
return .signalWeak
}
- private func statusBadge(_ beacon: EnrichedBeacon) -> some View {
- let (text, textColor, bgColor): (String, Color, Color) = {
- switch beacon.status {
- case .new:
- return ("NEW", .successGreen, .newBg)
- case .thisBusiness:
- let name = beacon.assignedBeaconName
- let label = (name != nil && !name!.isEmpty) ? "\(name!) (this business)" : "This business"
- return (label, .infoBlue, .assignedBg)
- case .otherBusiness:
- let label = beacon.assignedBusinessName ?? "Other business"
- return (label, .warningOrange, .assignedBg)
- case .banned:
- return ("BANNED", .errorRed, .bannedBg)
- }
- }()
+ // MARK: - Assignment Sheet
- return Text(text)
- .font(.caption2.weight(.medium))
- .foregroundColor(textColor)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(bgColor)
- .cornerRadius(4)
+ private var assignSheet: some View {
+ NavigationStack {
+ VStack(alignment: .leading, spacing: 16) {
+ if let beacon = selectedBeacon {
+ // Beacon info
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Beacon")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ HStack {
+ Text(beacon.displayName)
+ .font(.headline)
+ Spacer()
+ Text(beacon.type.rawValue)
+ .font(.caption2.weight(.medium))
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(beacon.type == .kbeacon ? Color.blue : Color.purple)
+ .cornerRadius(4)
+ }
+ Text("Signal: \(beacon.rssi) dBm")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ .background(Color(.systemGray6))
+ .cornerRadius(8)
+
+ // Service point name
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Service Point Name")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ TextField("e.g., Table 1", text: $assignName)
+ .textFieldStyle(.roundedBorder)
+ .font(.title3)
+ }
+
+ // KBeacon warning
+ if beacon.type == .kbeacon {
+ HStack {
+ Image(systemName: "info.circle.fill")
+ .foregroundColor(.infoBlue)
+ Text("KBeacon requires their app for provisioning. Config will be copied to clipboard.")
+ .font(.caption)
+ }
+ .padding()
+ .background(Color.infoBlue.opacity(0.1))
+ .cornerRadius(8)
+ }
+
+ // Provisioning progress
+ if isProvisioning {
+ HStack {
+ ProgressView()
+ Text(provisioningProgress)
+ .font(.callout)
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color(.systemGray6))
+ .cornerRadius(8)
+ }
+
+ Spacer()
+ }
+ }
+ .padding()
+ .navigationTitle("Assign Beacon")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ showAssignSheet = false
+ selectedBeacon = nil
+ }
+ .disabled(isProvisioning)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save Beacon") {
+ saveBeacon()
+ }
+ .disabled(assignName.trimmingCharacters(in: .whitespaces).isEmpty || isProvisioning)
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ .interactiveDismissDisabled(isProvisioning)
}
// MARK: - Snack Overlay
@@ -330,450 +330,161 @@ struct ScanView: View {
// MARK: - Actions
- private func handleBeaconTap(_ beacon: EnrichedBeacon) {
- switch beacon.status {
- case .new:
- openAssignDialog(beacon)
- case .thisBusiness:
- optionsBeacon = beacon
- showOptionsAlert = true
- case .otherBusiness:
- infoBeacon = beacon
- showInfoAlert = true
- case .banned:
- bannedBeacon = beacon
- generatedUuid = nil
- showBannedSheet = true
- }
- }
-
- private func loadExistingBeacons() {
+ private func loadServicePoints() {
Task {
do {
- let existing = try await Api.shared.listBeacons(businessId: businessId)
- let maxNumber = existing.compactMap { beacon -> Int? in
- guard let match = beacon.name.range(of: #"Table\s+(\d+)"#,
- options: [.regularExpression, .caseInsensitive]) else {
+ servicePoints = try await Api.shared.listServicePoints(businessId: businessId)
+ // Find next table number
+ let maxNumber = servicePoints.compactMap { sp -> Int? in
+ guard let match = sp.name.range(of: #"Table\s+(\d+)"#,
+ options: [.regularExpression, .caseInsensitive]) else {
return nil
}
- let numberStr = beacon.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
+ let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
return Int(numberStr)
}.max() ?? 0
-
nextTableNumber = maxNumber + 1
} catch {
- // Silently continue — will use default table 1
+ // Silently continue
}
- requestPermissionsAndScan()
+ // Auto-start scan
+ startScan()
}
}
- private func requestPermissionsAndScan() {
- Task {
- // Request permission if needed
- if beaconScanner.authorizationStatus == .notDetermined {
- beaconScanner.requestPermission()
- for _ in 0..<20 {
- try? await Task.sleep(nanoseconds: 500_000_000)
- if beaconScanner.authorizationStatus != .notDetermined { break }
- }
- }
-
- guard beaconScanner.hasPermissions() else {
- showSnack("Location permission denied — required for beacon scanning")
- return
- }
-
- await startScan()
- }
- }
-
- private func startScan() async {
- isScanning = true
- scanResults = []
-
- // Fetch known beacon UUIDs from server if we haven't yet
- if targetUUIDs.isEmpty {
- do {
- let serverBeacons = try await Api.shared.listAllBeacons()
- var uuids: [UUID] = []
- for (rawUuid, _) in serverBeacons {
- let formatted = formatUuidString(rawUuid)
- if let uuid = UUID(uuidString: formatted) {
- uuids.append(uuid)
- }
- }
- // Add fallback UUIDs
- for raw in ScanView.fallbackUUIDs {
- let formatted = formatUuidString(raw)
- if let uuid = UUID(uuidString: formatted), !uuids.contains(uuid) {
- uuids.append(uuid)
- }
- }
- targetUUIDs = uuids
- } catch {
- // Use fallbacks only
- targetUUIDs = ScanView.fallbackUUIDs.compactMap { raw in
- UUID(uuidString: formatUuidString(raw))
- }
- }
- }
-
- guard !targetUUIDs.isEmpty else {
- isScanning = false
- showSnack("No beacon UUIDs to scan for")
+ private func startScan() {
+ guard bleScanner.isBluetoothReady else {
+ showSnack("Bluetooth not available")
return
}
-
- // Range for 3 seconds
- beaconScanner.startRanging(uuids: targetUUIDs)
- try? await Task.sleep(nanoseconds: 3_000_000_000)
-
- let detected = beaconScanner.stopAndCollect()
- onScanComplete(detected)
+ bleScanner.startScanning()
}
- private func formatUuidString(_ raw: String) -> String {
- let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
- guard clean.count == 32 else { return raw }
- if raw.count == 36 { return raw.uppercased() }
- let chars = Array(clean)
- return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
- }
-
- private func onScanComplete(_ detected: [DetectedBeacon]) {
- let filtered = detected.filter { !savedUuids.contains($0.uuid) }
-
- if filtered.isEmpty {
- scanResults = []
- hasScannedOnce = true
- isScanning = false
- return
- }
-
- Task {
- do {
- let lookupResults = try await Api.shared.lookupBeacons(uuids: filtered.map { $0.uuid })
- knownAssignments.removeAll()
- for result in lookupResults {
- knownAssignments[result.uuid] = result
- }
- } catch {
- // Continue without lookup data
- }
-
- scanResults = filtered.map { beacon in
- let lookup = knownAssignments[beacon.uuid]
- let isBanned = BeaconBanList.isBanned(beacon.uuid)
- let banReason = BeaconBanList.getBanReason(beacon.uuid)
-
- let status: BeaconStatus
- if isBanned {
- status = .banned
- } else if let lookup = lookup, lookup.businessId == businessId {
- status = .thisBusiness
- } else if lookup != nil {
- status = .otherBusiness
- } else {
- status = .new
- }
-
- return EnrichedBeacon(
- uuid: beacon.uuid,
- rssi: beacon.rssi,
- samples: beacon.samples,
- status: status,
- assignedBusinessName: lookup?.businessName,
- assignedBeaconName: lookup?.beaconName,
- assignedServicePointName: lookup?.servicePointName,
- beaconId: lookup?.beaconId ?? 0,
- banReason: banReason
- )
- }
-
- hasScannedOnce = true
- isScanning = false
- }
- }
-
- // MARK: - Assign Dialog
-
- private func openAssignDialog(_ beacon: EnrichedBeacon) {
- assignBeacon = beacon
+ private func selectBeacon(_ beacon: DiscoveredBeacon) {
+ selectedBeacon = beacon
assignName = "Table \(nextTableNumber)"
showAssignSheet = true
}
- private var assignSheet: some View {
- NavigationStack {
- if let beacon = assignBeacon {
- VStack(alignment: .leading, spacing: 16) {
- Text("UUID: \(BeaconBanList.formatUuid(beacon.uuid))")
- .font(.system(.caption, design: .monospaced))
- Text("RSSI: \(beacon.rssi) dBm")
- .font(.callout)
+ private func saveBeacon() {
+ guard let beacon = selectedBeacon else { return }
+ let name = assignName.trimmingCharacters(in: .whitespaces)
+ guard !name.isEmpty else { return }
- if let mac = pendingQrMac {
- HStack {
- Text("MAC: \(mac)")
- .font(.callout)
- Image(systemName: "checkmark.circle.fill")
- .foregroundColor(.successGreen)
- }
- }
+ isProvisioning = true
+ provisioningProgress = "Creating service point..."
- if beacon.status == .otherBusiness {
- Text("Warning: This beacon is assigned to \(beacon.assignedBusinessName ?? "another business"). Saving will reassign it.")
- .font(.callout)
- .foregroundColor(.warningOrange)
- }
- if beacon.status == .banned {
- Text("Warning: This UUID is a known factory default.\n(\(beacon.banReason ?? "Banned"))")
- .font(.callout)
- .foregroundColor(.errorRed)
- }
- if beacon.status == .thisBusiness {
- Text("Currently: \(beacon.assignedBeaconName ?? "unnamed")")
- .font(.callout)
- .foregroundColor(.infoBlue)
- }
-
- TextField("Beacon name", text: $assignName)
- .textFieldStyle(.roundedBorder)
- .padding(.top, 8)
-
- Spacer()
- }
- .padding()
- .navigationTitle("Assign Beacon")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- pendingQrMac = nil
- showAssignSheet = false
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("Save") {
- let name = assignName.trimmingCharacters(in: .whitespacesAndNewlines)
- if !name.isEmpty {
- showAssignSheet = false
- saveBeacon(uuid: beacon.uuid, name: name, macAddress: pendingQrMac)
- pendingQrMac = nil
- }
- }
- }
- ToolbarItem(placement: .bottomBar) {
- Button {
- showAssignSheet = false
- launchQrScan(forBeacon: beacon)
- } label: {
- Label("Scan QR", systemImage: "qrcode.viewfinder")
- }
- }
- }
- }
- }
- .presentationDetents([.medium])
- }
-
- private func saveBeacon(uuid: String, name: String, macAddress: String? = nil) {
Task {
do {
- let saved = try await Api.shared.saveBeacon(
- businessId: businessId, name: name, uuid: uuid, macAddress: macAddress
+ // 1. Create or get service point
+ let servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name)
+ provisioningProgress = "Getting beacon config..."
+
+ // 2. Get beacon config from backend
+ let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId)
+ provisioningProgress = "Provisioning beacon..."
+
+ // 3. Provision the beacon
+ let beaconConfig = BeaconConfig(
+ uuid: config.uuid,
+ major: config.major,
+ minor: config.minor,
+ txPower: Int8(config.txPower),
+ interval: UInt16(config.interval)
)
- savedUuids.insert(uuid)
+ if beacon.type == .kbeacon {
+ // Copy config to clipboard for KBeacon app
+ let clipboardText = """
+ UUID: \(formatUuidWithDashes(config.uuid))
+ Major: \(config.major)
+ Minor: \(config.minor)
+ TxPower: \(config.txPower)
+ """
+ UIPasteboard.general.string = clipboardText
+ showSnack("Config copied! Use KBeacon app to program.")
- // Increment table number
- if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
- let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
- if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
- nextTableNumber = savedNumber + 1
- } else {
- nextTableNumber += 1
- }
+ // Register in backend
+ try await Api.shared.registerBeaconHardware(
+ businessId: businessId,
+ servicePointId: servicePoint.servicePointId,
+ uuid: config.uuid,
+ major: config.major,
+ minor: config.minor,
+ macAddress: nil
+ )
+
+ finishProvisioning(name: name)
} else {
- nextTableNumber += 1
- }
-
- scanResults.removeAll { $0.uuid == uuid }
- showSnack("Saved \"\(name)\"")
- } catch {
- showSnack(error.localizedDescription)
- }
- }
- }
-
- // MARK: - Banned Dialog
-
- private var bannedSheet: some View {
- NavigationStack {
- if let beacon = bannedBeacon {
- VStack(alignment: .leading, spacing: 16) {
- Text("Current UUID:")
- .font(.caption)
- .foregroundColor(.secondary)
- Text(BeaconBanList.formatUuid(beacon.uuid))
- .font(.system(.caption, design: .monospaced))
-
- Text(beacon.banReason ?? "This UUID is a known factory default")
- .font(.callout)
- .foregroundColor(.errorRed)
-
- Divider()
-
- Text("Suggested Replacement:")
- .font(.caption)
- .foregroundColor(.secondary)
-
- if let uuid = generatedUuid {
- Text(uuid)
- .font(.system(.caption, design: .monospaced))
- .textSelection(.enabled)
- } else {
- Text("Tap Generate to create a new UUID")
- .font(.callout)
- .foregroundColor(.secondary)
- }
-
- HStack(spacing: 12) {
- Button {
- generatedUuid = UUID().uuidString.uppercased()
- } label: {
- Label("Generate", systemImage: "arrow.triangle.2.circlepath")
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.bordered)
-
- Button {
- if let uuid = generatedUuid {
- UIPasteboard.general.string = uuid
- showSnack("UUID copied to clipboard")
+ // BlueCharm - provision directly via GATT
+ provisioner.provision(beacon: beacon, config: beaconConfig) { result in
+ Task { @MainActor in
+ switch result {
+ case .success:
+ // Register in backend
+ do {
+ try await Api.shared.registerBeaconHardware(
+ businessId: businessId,
+ servicePointId: servicePoint.servicePointId,
+ uuid: config.uuid,
+ major: config.major,
+ minor: config.minor,
+ macAddress: nil
+ )
+ finishProvisioning(name: name)
+ } catch {
+ failProvisioning(error.localizedDescription)
+ }
+ case .failure(let error):
+ failProvisioning(error)
}
- } label: {
- Label("Copy", systemImage: "doc.on.doc")
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.bordered)
- .disabled(generatedUuid == nil)
- }
- .padding(.top, 8)
-
- Spacer()
- }
- .padding()
- .navigationTitle("Banned UUID")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button("Done") { showBannedSheet = false }
}
}
- }
- }
- .presentationDetents([.medium])
- }
-
- // MARK: - Info / Options Messages
-
- private func infoMessage(_ beacon: EnrichedBeacon) -> String {
- let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
- return "UUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm\nName: \(beacon.assignedBeaconName ?? "unnamed")\n\nThis beacon is registered to \(beacon.assignedBusinessName ?? "another") business and cannot be reassigned from here."
- }
-
- private func optionsMessage(_ beacon: EnrichedBeacon) -> String {
- let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
- let beaconName = beacon.assignedBeaconName ?? "unnamed"
- let spName = beacon.assignedServicePointName ?? beaconName
- return "Beacon: \(beaconName)\nService Point: \(spName)\nUUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm"
- }
-
- // MARK: - Wipe
-
- private func performWipe(_ beacon: EnrichedBeacon) {
- guard beacon.beaconId > 0 else {
- showSnack("Cannot wipe: invalid beacon ID")
- return
- }
-
- Task {
- do {
- try await Api.shared.wipeBeacon(businessId: businessId, beaconId: beacon.beaconId)
- savedUuids.remove(beacon.uuid)
- scanResults.removeAll { $0.uuid == beacon.uuid }
- showSnack("Beacon wiped")
} catch {
- showSnack(error.localizedDescription)
+ failProvisioning(error.localizedDescription)
}
}
}
- // MARK: - QR Scan
+ private func finishProvisioning(name: String) {
+ isProvisioning = false
+ provisioningProgress = ""
+ showAssignSheet = false
+ selectedBeacon = nil
- private func launchQrScan(forBeacon: EnrichedBeacon?) {
- pendingQrBeacon = forBeacon
- showQrScanner = true
- }
-
- private func handleQrScanResult(qrResult: String?, qrType: String?) {
- guard let qrResult = qrResult, !qrResult.isEmpty else { return }
-
- switch qrType {
- case QrScanView.TYPE_MAC:
- if let beacon = pendingQrBeacon {
- pendingQrMac = qrResult
- showSnack("MAC scanned: \(qrResult)")
- openAssignDialog(beacon)
+ // Update table number
+ if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
+ let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
+ if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
+ nextTableNumber = savedNumber + 1
} else {
- lookupByMac(qrResult)
+ nextTableNumber += 1
}
-
- case QrScanView.TYPE_UUID:
- let matchingBeacon = scanResults.first {
- $0.uuid.lowercased() == qrResult.replacingOccurrences(of: "-", with: "").lowercased()
- }
- if let beacon = matchingBeacon {
- showSnack("UUID found in scan results")
- openAssignDialog(beacon)
- } else {
- showSnack("UUID not found in nearby beacons")
- }
-
- default:
- showSnack("Unknown QR format: \(qrResult)")
+ } else {
+ nextTableNumber += 1
}
- pendingQrBeacon = nil
- }
+ provisionedCount += 1
+ showSnack("Saved \"\(name)\"")
- private func lookupByMac(_ macAddress: String) {
- Task {
- do {
- if let result = try await Api.shared.lookupByMac(macAddress: macAddress) {
- macLookupResult = result
- showMacLookupAlert = true
- } else {
- pendingQrMac = macAddress
- showBeaconPickerForMac(macAddress)
- }
- } catch {
- showSnack(error.localizedDescription)
- }
+ // Remove beacon from list
+ if let beacon = selectedBeacon {
+ bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id }
}
}
- private func showBeaconPickerForMac(_ macAddress: String) {
- if scanResults.isEmpty {
- noBeaconsMac = macAddress
- showNoBeaconsScanAlert = true
- return
- }
+ private func failProvisioning(_ error: String) {
+ isProvisioning = false
+ provisioningProgress = ""
+ showSnack("Error: \(error)")
+ }
- pickerMac = macAddress
- showBeaconPickerAlert = true
+ private func formatUuidWithDashes(_ raw: String) -> String {
+ let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
+ guard clean.count == 32 else { return raw }
+ let chars = Array(clean)
+ return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
}
}
diff --git a/PayfritBeacon/ServicePointListView.swift b/PayfritBeacon/ServicePointListView.swift
new file mode 100644
index 0000000..1244cbb
--- /dev/null
+++ b/PayfritBeacon/ServicePointListView.swift
@@ -0,0 +1,269 @@
+import SwiftUI
+
+struct ServicePointListView: View {
+ let businessId: Int
+ let businessName: String
+ var onBack: () -> Void
+
+ @State private var namespace: BusinessNamespace?
+ @State private var servicePoints: [ServicePoint] = []
+ @State private var isLoading = true
+ @State private var errorMessage: String?
+
+ // Add service point
+ @State private var showAddSheet = false
+ @State private var newServicePointName = ""
+ @State private var isAdding = false
+
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ VStack {
+ Spacer()
+ ProgressView("Loading...")
+ Spacer()
+ }
+ } else if let error = errorMessage {
+ VStack(spacing: 16) {
+ Spacer()
+ Image(systemName: "exclamationmark.triangle")
+ .font(.largeTitle)
+ .foregroundColor(.orange)
+ Text(error)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Retry") { loadData() }
+ .buttonStyle(.borderedProminent)
+ .tint(.payfritGreen)
+ Spacer()
+ }
+ .padding()
+ } else {
+ List {
+ // Namespace section
+ if let ns = namespace {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("UUID")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(formatUuidWithDashes(ns.uuid))
+ .font(.system(.caption, design: .monospaced))
+ Button {
+ UIPasteboard.general.string = formatUuidWithDashes(ns.uuid)
+ } label: {
+ Image(systemName: "doc.on.doc")
+ .font(.caption)
+ }
+ }
+ HStack {
+ Text("Major")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text("\(ns.major)")
+ .font(.system(.body, design: .monospaced).weight(.semibold))
+ Button {
+ UIPasteboard.general.string = "\(ns.major)"
+ } label: {
+ Image(systemName: "doc.on.doc")
+ .font(.caption)
+ }
+ }
+ }
+ } header: {
+ Label("Beacon Namespace", systemImage: "antenna.radiowaves.left.and.right")
+ }
+ }
+
+ // Service points section
+ Section {
+ if servicePoints.isEmpty {
+ VStack(spacing: 8) {
+ Text("No service points yet")
+ .foregroundColor(.secondary)
+ Text("Tap + to add one")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 20)
+ } else {
+ ForEach(servicePoints) { sp in
+ HStack {
+ Text(sp.name)
+ Spacer()
+ if let minor = sp.beaconMinor {
+ Text("Minor: \(minor)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ }
+ } header: {
+ Text("Service Points")
+ }
+ }
+ }
+ }
+ .navigationTitle(businessName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button("Back", action: onBack)
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .onAppear { loadData() }
+ .sheet(isPresented: $showAddSheet) { addServicePointSheet }
+ }
+
+ // MARK: - Add Sheet
+
+ private var addServicePointSheet: some View {
+ NavigationStack {
+ VStack(spacing: 20) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Service Point Name")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ TextField("e.g., Table 1", text: $newServicePointName)
+ .textFieldStyle(.roundedBorder)
+ .font(.title3)
+ }
+ .padding(.horizontal)
+
+ if let ns = namespace {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Beacon config will be assigned automatically:")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ HStack {
+ VStack(alignment: .leading) {
+ Text("UUID").font(.caption2).foregroundColor(.secondary)
+ Text(formatUuidWithDashes(ns.uuid))
+ .font(.system(.caption2, design: .monospaced))
+ }
+ }
+
+ HStack(spacing: 24) {
+ VStack(alignment: .leading) {
+ Text("Major").font(.caption2).foregroundColor(.secondary)
+ Text("\(ns.major)").font(.system(.caption, design: .monospaced).weight(.semibold))
+ }
+ VStack(alignment: .leading) {
+ Text("Minor").font(.caption2).foregroundColor(.secondary)
+ Text("Auto").font(.caption.italic()).foregroundColor(.secondary)
+ }
+ }
+ }
+ .padding()
+ .background(Color(.systemGray6))
+ .cornerRadius(8)
+ .padding(.horizontal)
+ }
+
+ Spacer()
+ }
+ .padding(.top)
+ .navigationTitle("Add Service Point")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { showAddSheet = false }
+ .disabled(isAdding)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isAdding {
+ ProgressView()
+ } else {
+ Button("Add") { addServicePoint() }
+ .disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ }
+ }
+ }
+ .presentationDetents([.medium])
+ .interactiveDismissDisabled(isAdding)
+ .onAppear {
+ newServicePointName = "Table \(nextTableNumber)"
+ }
+ }
+
+ // MARK: - Actions
+
+ private func loadData() {
+ isLoading = true
+ errorMessage = nil
+
+ Task {
+ do {
+ // Get namespace
+ let ns = try await Api.shared.allocateBusinessNamespace(businessId: businessId)
+ namespace = ns
+
+ // Get service points
+ let sps = try await Api.shared.listServicePoints(businessId: businessId)
+ servicePoints = sps.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
+
+ isLoading = false
+ } catch {
+ errorMessage = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+
+ private func addServicePoint() {
+ let name = newServicePointName.trimmingCharacters(in: .whitespaces)
+ guard !name.isEmpty else { return }
+
+ isAdding = true
+
+ Task {
+ do {
+ let sp = try await Api.shared.saveServicePoint(businessId: businessId, name: name)
+ servicePoints.append(sp)
+ servicePoints.sort { $0.name.localizedCompare($1.name) == .orderedAscending }
+ showAddSheet = false
+ newServicePointName = ""
+ isAdding = false
+ } catch {
+ // Show error somehow
+ isAdding = false
+ }
+ }
+ }
+
+ private var nextTableNumber: Int {
+ let maxNumber = servicePoints.compactMap { sp -> Int? in
+ guard let match = sp.name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) else {
+ return nil
+ }
+ let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
+ return Int(numberStr)
+ }.max() ?? 0
+ return maxNumber + 1
+ }
+
+ private func formatUuidWithDashes(_ raw: String) -> String {
+ let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
+ guard clean.count == 32 else { return raw }
+ let chars = Array(clean)
+ return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
+ }
+}
+
diff --git a/_backup/Models/Beacon.swift b/_backup/Models/Beacon.swift
new file mode 100644
index 0000000..555d978
--- /dev/null
+++ b/_backup/Models/Beacon.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+struct Beacon: Identifiable {
+ let id: Int
+ let businessId: Int
+ let name: String
+ let uuid: String
+ let namespaceId: String
+ let instanceId: String
+ let isActive: Bool
+ let createdAt: Date?
+ let updatedAt: Date?
+
+ init(json: [String: Any]) {
+ id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0
+ businessId = Self.parseInt(json["BusinessID"]) ?? 0
+ name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? ""
+ uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? ""
+ namespaceId = (json["NamespaceId"] as? String) ?? ""
+ instanceId = (json["InstanceId"] as? String) ?? ""
+ isActive = Self.parseBool(json["IsActive"]) ?? true
+ createdAt = Self.parseDate(json["CreatedAt"])
+ updatedAt = Self.parseDate(json["UpdatedAt"])
+ }
+
+ /// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format
+ var formattedUUID: String {
+ let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
+ guard clean.count == 32 else { return uuid }
+ let i = clean.startIndex
+ let p1 = clean[i.. Int? {
+ guard let value = value else { return nil }
+ if let v = value as? Int { return v }
+ if let v = value as? Double { return Int(v) }
+ if let v = value as? NSNumber { return v.intValue }
+ if let v = value as? String, let i = Int(v) { return i }
+ return nil
+ }
+
+ static func parseBool(_ value: Any?) -> Bool? {
+ guard let value = value else { return nil }
+ if let b = value as? Bool { return b }
+ if let i = value as? Int { return i == 1 }
+ if let s = value as? String {
+ let lower = s.lowercased()
+ if lower == "true" || lower == "1" || lower == "yes" { return true }
+ if lower == "false" || lower == "0" || lower == "no" { return false }
+ }
+ return nil
+ }
+
+ static func parseDate(_ value: Any?) -> Date? {
+ guard let value = value else { return nil }
+ if let d = value as? Date { return d }
+ if let s = value as? String { return APIService.parseDate(s) }
+ return nil
+ }
+}
diff --git a/_backup/Models/Employment.swift b/_backup/Models/Employment.swift
new file mode 100644
index 0000000..00b0ded
--- /dev/null
+++ b/_backup/Models/Employment.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+struct Employment: Identifiable {
+ /// Composite ID to avoid collisions when same employee works at multiple businesses
+ var id: String { "\(employeeId)-\(businessId)" }
+ let employeeId: Int
+ let businessId: Int
+ let businessName: String
+ let businessAddress: String
+ let businessCity: String
+ let employeeStatusId: Int
+ let pendingTaskCount: Int
+
+ var statusName: String {
+ switch employeeStatusId {
+ case 1: return "Active"
+ case 2: return "Suspended"
+ case 3: return "Terminated"
+ default: return "Unknown"
+ }
+ }
+
+ init(json: [String: Any]) {
+ employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0
+ businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
+ businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? ""
+ businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? ""
+ businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? ""
+ employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0
+ pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0
+ }
+}
diff --git a/_backup/Models/ServicePoint.swift b/_backup/Models/ServicePoint.swift
new file mode 100644
index 0000000..6229394
--- /dev/null
+++ b/_backup/Models/ServicePoint.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+struct ServicePoint: Identifiable {
+ let id: Int
+ let businessId: Int
+ let name: String
+ let typeId: Int
+ let typeName: String
+ let code: String
+ let description: String
+ let sortOrder: Int
+ let isActive: Bool
+ let isClosedToNewMembers: Bool
+ let beaconId: Int?
+ let assignedByUserId: Int?
+ let createdAt: Date?
+ let updatedAt: Date?
+
+ init(json: [String: Any]) {
+ id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0
+ businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
+ name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? ""
+ typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0
+ typeName = (json["TypeName"] as? String) ?? ""
+ code = (json["Code"] as? String) ?? ""
+ description = (json["Description"] as? String) ?? ""
+ sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
+ isActive = Beacon.parseBool(json["IsActive"]) ?? true
+ isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false
+ beaconId = Beacon.parseInt(json["BeaconID"])
+ assignedByUserId = Beacon.parseInt(json["AssignedByUserID"])
+ createdAt = Beacon.parseDate(json["CreatedAt"])
+ updatedAt = Beacon.parseDate(json["UpdatedAt"])
+ }
+}
+
+struct ServicePointType: Identifiable {
+ let id: Int
+ let name: String
+ let sortOrder: Int
+
+ init(json: [String: Any]) {
+ id = Beacon.parseInt(json["ID"]) ?? 0
+ name = (json["Name"] as? String) ?? ""
+ sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
+ }
+}
diff --git a/_backup/PayfritBeaconApp.swift b/_backup/PayfritBeaconApp.swift
new file mode 100644
index 0000000..4a67203
--- /dev/null
+++ b/_backup/PayfritBeaconApp.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+@main
+struct PayfritBeaconApp: App {
+ @StateObject private var appState = AppState()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(appState)
+ }
+ }
+}
+
+extension Color {
+ static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294)
+}
diff --git a/_backup/Services/APIService.swift b/_backup/Services/APIService.swift
new file mode 100644
index 0000000..b6dfbbb
--- /dev/null
+++ b/_backup/Services/APIService.swift
@@ -0,0 +1,416 @@
+import Foundation
+
+// MARK: - API Errors
+
+enum APIError: LocalizedError, Equatable {
+ case invalidURL
+ case noData
+ case decodingError(String)
+ case serverError(String)
+ case unauthorized
+ case networkError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL: return "Invalid URL"
+ case .noData: return "No data received"
+ case .decodingError(let msg): return "Decoding error: \(msg)"
+ case .serverError(let msg): return msg
+ case .unauthorized: return "Unauthorized"
+ case .networkError(let msg): return msg
+ }
+ }
+}
+
+// MARK: - Login Response
+
+struct LoginResponse {
+ let userId: Int
+ let userFirstName: String
+ let token: String
+ let photoUrl: String
+}
+
+// MARK: - API Service
+
+actor APIService {
+ static let shared = APIService()
+
+ private enum Environment {
+ case development, production
+
+ var baseURL: String {
+ switch self {
+ case .development: return "https://dev.payfrit.com/api"
+ case .production: return "https://biz.payfrit.com/api"
+ }
+ }
+ }
+
+ private let environment: Environment = .development
+ var isDev: Bool { environment == .development }
+ private var userToken: String?
+ private var userId: Int?
+ private var businessId: Int = 0
+
+ var baseURL: String { environment.baseURL }
+
+ // MARK: - Configuration
+
+ func setAuth(token: String?, userId: Int?) {
+ self.userToken = token
+ self.userId = userId
+ }
+
+ func setBusinessId(_ id: Int) {
+ self.businessId = id
+ }
+
+ func getToken() -> String? { userToken }
+ func getUserId() -> Int? { userId }
+ func getBusinessId() -> Int { businessId }
+
+ // MARK: - Core Request
+
+ private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
+ let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)")
+ guard let url = URL(string: urlString) else { throw APIError.invalidURL }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
+
+ if let token = userToken, !token.isEmpty {
+ request.setValue(token, forHTTPHeaderField: "X-User-Token")
+ }
+ if businessId > 0 {
+ request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
+ }
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: payload)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ if let httpResponse = response as? HTTPURLResponse {
+ if httpResponse.statusCode == 401 { throw APIError.unauthorized }
+ guard (200...299).contains(httpResponse.statusCode) else {
+ throw APIError.serverError("HTTP \(httpResponse.statusCode)")
+ }
+ }
+
+ if let json = tryDecodeJSON(data) {
+ return json
+ }
+ throw APIError.decodingError("Non-JSON response")
+ }
+
+ private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ return json
+ }
+ guard let body = String(data: data, encoding: .utf8),
+ let start = body.firstIndex(of: "{"),
+ let end = body.lastIndex(of: "}") else { return nil }
+ let jsonStr = String(body[start...end])
+ guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
+ return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
+ }
+
+ private func ok(_ json: [String: Any]) -> Bool {
+ for key in ["OK", "ok", "Ok"] {
+ if let b = json[key] as? Bool { return b }
+ if let i = json[key] as? Int { return i == 1 }
+ if let s = json[key] as? String {
+ let lower = s.lowercased()
+ return lower == "true" || lower == "1" || lower == "yes"
+ }
+ }
+ return false
+ }
+
+ private func err(_ json: [String: Any]) -> String {
+ let msg = (json["ERROR"] as? String) ?? (json["error"] as? String)
+ ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
+ return msg.isEmpty ? "Unknown error" : msg
+ }
+
+ nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
+ for key in keys {
+ if let arr = json[key] as? [[String: Any]] { return arr }
+ }
+ for (_, value) in json {
+ if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
+ }
+ return nil
+ }
+
+ // MARK: - Auth
+
+ func login(username: String, password: String) async throws -> LoginResponse {
+ let json = try await postJSON("/auth/login.cfm", payload: [
+ "username": username,
+ "password": password
+ ])
+
+ guard ok(json) else {
+ let e = err(json)
+ if e == "bad_credentials" {
+ throw APIError.serverError("Invalid email/phone or password")
+ }
+ throw APIError.serverError("Login failed: \(e)")
+ }
+
+ let uid = (json["UserID"] as? Int)
+ ?? Int(json["UserID"] as? String ?? "")
+ ?? (json["UserId"] as? Int)
+ ?? 0
+ let token = (json["Token"] as? String)
+ ?? (json["token"] as? String)
+ ?? ""
+
+ guard uid > 0 else {
+ throw APIError.serverError("Login failed: no user ID returned")
+ }
+ guard !token.isEmpty else {
+ throw APIError.serverError("Login failed: no token returned")
+ }
+
+ let firstName = (json["UserFirstName"] as? String)
+ ?? (json["FirstName"] as? String)
+ ?? (json["firstName"] as? String)
+ ?? (json["Name"] as? String)
+ ?? (json["name"] as? String)
+ ?? ""
+ let photoUrl = (json["UserPhotoUrl"] as? String)
+ ?? (json["PhotoUrl"] as? String)
+ ?? (json["photoUrl"] as? String)
+ ?? ""
+
+ self.userToken = token
+ self.userId = uid
+
+ return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
+ }
+
+ func logout() {
+ userToken = nil
+ userId = nil
+ businessId = 0
+ }
+
+ // MARK: - Businesses
+
+ func getMyBusinesses() async throws -> [Employment] {
+ guard let uid = userId, uid > 0 else {
+ throw APIError.serverError("User not logged in")
+ }
+
+ let json = try await postJSON("/workers/myBusinesses.cfm", payload: [
+ "UserID": uid
+ ])
+
+ guard ok(json) else {
+ throw APIError.serverError("Failed to load businesses: \(err(json))")
+ }
+
+ guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
+ return []
+ }
+ return arr.map { Employment(json: $0) }
+ }
+
+ // MARK: - Beacons
+
+ func listBeacons() async throws -> [Beacon] {
+ let json = try await postJSON("/beacons/list.cfm", payload: [
+ "BusinessID": businessId
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to load beacons: \(err(json))")
+ }
+ guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] }
+ return arr.map { Beacon(json: $0) }
+ }
+
+ func getBeacon(beaconId: Int) async throws -> Beacon {
+ let json = try await postJSON("/beacons/get.cfm", payload: [
+ "BeaconID": beaconId,
+ "BusinessID": businessId
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to load beacon: \(err(json))")
+ }
+ var beaconJson: [String: Any]?
+ for key in ["BEACON", "Beacon", "beacon"] {
+ if let d = json[key] as? [String: Any] { beaconJson = d; break }
+ }
+ if beaconJson == nil {
+ for (_, value) in json {
+ if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break }
+ }
+ }
+ guard let beaconJson = beaconJson else {
+ throw APIError.serverError("Invalid beacon response")
+ }
+ return Beacon(json: beaconJson)
+ }
+
+ func createBeacon(name: String, uuid: String) async throws -> Int {
+ let json = try await postJSON("/beacons/create.cfm", payload: [
+ "BusinessID": businessId,
+ "Name": name,
+ "UUID": uuid
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to create beacon: \(err(json))")
+ }
+ return (json["BeaconID"] as? Int)
+ ?? (json["ID"] as? Int)
+ ?? Int(json["BeaconID"] as? String ?? "")
+ ?? 0
+ }
+
+ func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws {
+ let json = try await postJSON("/beacons/update.cfm", payload: [
+ "BeaconID": beaconId,
+ "BusinessID": businessId,
+ "Name": name,
+ "UUID": uuid,
+ "IsActive": isActive
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to update beacon: \(err(json))")
+ }
+ }
+
+ func deleteBeacon(beaconId: Int) async throws {
+ let json = try await postJSON("/beacons/delete.cfm", payload: [
+ "BeaconID": beaconId,
+ "BusinessID": businessId
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to delete beacon: \(err(json))")
+ }
+ }
+
+ // MARK: - Service Points
+
+ func listServicePoints() async throws -> [ServicePoint] {
+ let json = try await postJSON("/servicePoints/list.cfm", payload: [
+ "BusinessID": businessId
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to load service points: \(err(json))")
+ }
+ guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] }
+ return arr.map { ServicePoint(json: $0) }
+ }
+
+ func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws {
+ var payload: [String: Any] = [
+ "ServicePointID": servicePointId,
+ "BusinessID": businessId
+ ]
+ if let bid = beaconId {
+ payload["BeaconID"] = bid
+ }
+ let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload)
+ guard ok(json) else {
+ throw APIError.serverError("Failed to assign beacon: \(err(json))")
+ }
+ }
+
+ func listServicePointTypes() async throws -> [ServicePointType] {
+ let json = try await postJSON("/servicePoints/types.cfm", payload: [
+ "BusinessID": businessId
+ ])
+ guard ok(json) else {
+ throw APIError.serverError("Failed to load service point types: \(err(json))")
+ }
+ guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] }
+ return arr.map { ServicePointType(json: $0) }
+ }
+
+ // MARK: - URL Helpers
+
+ func resolvePhotoUrl(_ rawUrl: String) -> String {
+ let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return "" }
+ if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
+ let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
+ if trimmed.hasPrefix("/") { return baseDomain + trimmed }
+ return baseDomain + "/" + trimmed
+ }
+
+ // MARK: - Date Parsing
+
+ private static let iso8601Formatter: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return f
+ }()
+
+ private static let iso8601NoFrac: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime]
+ return f
+ }()
+
+ private static let simpleDateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ return f
+ }()
+
+ private static let cfmlDateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ return f
+ }()
+
+ private static let cfmlShortFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
+ f.locale = Locale(identifier: "en_US_POSIX")
+ return f
+ }()
+
+ private static let cfmlAltFormatters: [DateFormatter] = {
+ let formats = [
+ "MMM dd, yyyy HH:mm:ss",
+ "MM/dd/yyyy HH:mm:ss",
+ "yyyy-MM-dd HH:mm:ss.S",
+ "yyyy-MM-dd'T'HH:mm:ss.S",
+ "yyyy-MM-dd'T'HH:mm:ssZ",
+ "yyyy-MM-dd'T'HH:mm:ss.SZ",
+ ]
+ return formats.map { fmt in
+ let f = DateFormatter()
+ f.dateFormat = fmt
+ f.locale = Locale(identifier: "en_US_POSIX")
+ return f
+ }
+ }()
+
+ nonisolated static func parseDate(_ string: String) -> Date? {
+ let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ if s.isEmpty { return nil }
+
+ if let epoch = Double(s) {
+ if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
+ if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
+ }
+
+ if let d = iso8601Formatter.date(from: s) { return d }
+ if let d = iso8601NoFrac.date(from: s) { return d }
+ if let d = simpleDateFormatter.date(from: s) { return d }
+ if let d = cfmlDateFormatter.date(from: s) { return d }
+ if let d = cfmlShortFormatter.date(from: s) { return d }
+ for formatter in cfmlAltFormatters {
+ if let d = formatter.date(from: s) { return d }
+ }
+ return nil
+ }
+}
diff --git a/_backup/Services/AuthStorage.swift b/_backup/Services/AuthStorage.swift
new file mode 100644
index 0000000..04a165a
--- /dev/null
+++ b/_backup/Services/AuthStorage.swift
@@ -0,0 +1,95 @@
+import Foundation
+import Security
+
+struct AuthCredentials {
+ let userId: Int
+ let token: String
+ let userName: String?
+ let photoUrl: String?
+}
+
+actor AuthStorage {
+ static let shared = AuthStorage()
+
+ private let userIdKey = "payfrit_beacon_user_id"
+ private let userNameKey = "payfrit_beacon_user_name"
+ private let userPhotoKey = "payfrit_beacon_user_photo"
+ private let serviceName = "com.payfrit.beacon"
+ private let tokenAccount = "auth_token"
+
+ // MARK: - Save
+
+ func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
+ UserDefaults.standard.set(userId, forKey: userIdKey)
+ // Always overwrite name/photo to prevent stale data from previous user
+ if let name = userName, !name.isEmpty {
+ UserDefaults.standard.set(name, forKey: userNameKey)
+ } else {
+ UserDefaults.standard.removeObject(forKey: userNameKey)
+ }
+ if let photo = photoUrl, !photo.isEmpty {
+ UserDefaults.standard.set(photo, forKey: userPhotoKey)
+ } else {
+ UserDefaults.standard.removeObject(forKey: userPhotoKey)
+ }
+ saveToKeychain(token)
+ }
+
+ // MARK: - Load
+
+ func loadAuth() -> AuthCredentials? {
+ let userId = UserDefaults.standard.integer(forKey: userIdKey)
+ guard userId > 0 else { return nil }
+ guard let token = loadFromKeychain(), !token.isEmpty else { return nil }
+ let userName = UserDefaults.standard.string(forKey: userNameKey)
+ let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey)
+ return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl)
+ }
+
+ // MARK: - Clear
+
+ func clearAuth() {
+ UserDefaults.standard.removeObject(forKey: userIdKey)
+ UserDefaults.standard.removeObject(forKey: userNameKey)
+ UserDefaults.standard.removeObject(forKey: userPhotoKey)
+ deleteFromKeychain()
+ }
+
+ // MARK: - Keychain
+
+ private func saveToKeychain(_ token: String) {
+ deleteFromKeychain()
+ guard let data = token.data(using: .utf8) else { return }
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: tokenAccount,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+ SecItemAdd(query as CFDictionary, nil)
+ }
+
+ private func loadFromKeychain() -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: tokenAccount,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ private func deleteFromKeychain() {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: tokenAccount
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/_backup/Services/BeaconScanner.swift b/_backup/Services/BeaconScanner.swift
new file mode 100644
index 0000000..4363653
--- /dev/null
+++ b/_backup/Services/BeaconScanner.swift
@@ -0,0 +1,262 @@
+import UIKit
+import CoreBluetooth
+import CoreLocation
+
+/// Beacon scanner for detecting BLE beacons by UUID.
+/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement.
+/// All mutable state is confined to the main thread via @MainActor.
+@MainActor
+final class BeaconScanner: NSObject, ObservableObject {
+ private let targetUUID: String
+ private let normalizedTargetUUID: String
+ private let onBeaconDetected: (Double) -> Void
+ private let onRSSIUpdate: ((Int, Int) -> Void)?
+ private let onBluetoothOff: (() -> Void)?
+ private let onPermissionDenied: (() -> Void)?
+ private let onError: ((String) -> Void)?
+
+ @Published var isScanning = false
+
+ private var locationManager: CLLocationManager?
+ private var activeConstraint: CLBeaconIdentityConstraint?
+ private var checkTimer: Timer?
+ private var bluetoothManager: CBCentralManager?
+
+ // RSSI samples for dwell time enforcement
+ private var rssiSamples: [Int] = []
+ private let minSamplesToConfirm = 5 // ~5 seconds
+ private let rssiThreshold = -75
+ private var hasConfirmed = false
+ private var isPendingPermission = false
+
+ init(targetUUID: String,
+ onBeaconDetected: @escaping (Double) -> Void,
+ onRSSIUpdate: ((Int, Int) -> Void)? = nil,
+ onBluetoothOff: (() -> Void)? = nil,
+ onPermissionDenied: (() -> Void)? = nil,
+ onError: ((String) -> Void)? = nil) {
+ self.targetUUID = targetUUID
+ self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
+ self.onBeaconDetected = onBeaconDetected
+ self.onRSSIUpdate = onRSSIUpdate
+ self.onBluetoothOff = onBluetoothOff
+ self.onPermissionDenied = onPermissionDenied
+ self.onError = onError
+ super.init()
+ }
+
+ // MARK: - UUID formatting
+
+ private nonisolated func formatUUID(_ uuid: String) -> String {
+ let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
+ guard clean.count == 32 else { return uuid }
+ let i = clean.startIndex
+ let p1 = clean[i..= rssiThreshold {
+ rssiSamples.append(rssi)
+ onRSSIUpdate?(rssi, rssiSamples.count)
+
+ if rssiSamples.count >= minSamplesToConfirm {
+ let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
+ hasConfirmed = true
+ onBeaconDetected(avg)
+ return
+ }
+ } else {
+ if !rssiSamples.isEmpty {
+ rssiSamples.removeAll()
+ onRSSIUpdate?(rssi, 0)
+ }
+ }
+ }
+
+ if !foundThisCycle && !rssiSamples.isEmpty {
+ rssiSamples.removeAll()
+ onRSSIUpdate?(0, 0)
+ }
+ }
+
+ fileprivate func handleRangingError(_ error: Error) {
+ onError?("Beacon ranging failed: \(error.localizedDescription)")
+ }
+
+ fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) {
+ if status == .authorizedWhenInUse || status == .authorizedAlways {
+ // Permission granted — start ranging only if we were waiting for permission
+ if isPendingPermission && !isScanning {
+ isPendingPermission = false
+ let formatted = formatUUID(targetUUID)
+ if let uuid = UUID(uuidString: formatted) {
+ beginRanging(uuid: uuid)
+ }
+ }
+ } else if status == .denied || status == .restricted {
+ isPendingPermission = false
+ stopScanning()
+ onPermissionDenied?()
+ }
+ }
+}
+
+// MARK: - CLLocationManagerDelegate
+// These delegate callbacks arrive on the main thread since CLLocationManager was created on main.
+// We forward to @MainActor methods above.
+
+extension BeaconScanner: CLLocationManagerDelegate {
+ nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
+ satisfying constraint: CLBeaconIdentityConstraint) {
+ Task { @MainActor in
+ self.handleRangedBeacons(beacons)
+ }
+ }
+
+ nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
+ Task { @MainActor in
+ self.handleRangingError(error)
+ }
+ }
+
+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+ Task { @MainActor in
+ self.handleAuthorizationChange(manager.authorizationStatus)
+ }
+ }
+}
+
+// MARK: - CBCentralManagerDelegate
+
+extension BeaconScanner: CBCentralManagerDelegate {
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ Task { @MainActor in
+ if central.state == .poweredOff {
+ self.stopScanning()
+ self.onBluetoothOff?()
+ }
+ }
+ }
+}
diff --git a/_backup/ViewModels/AppState.swift b/_backup/ViewModels/AppState.swift
new file mode 100644
index 0000000..4fbec6c
--- /dev/null
+++ b/_backup/ViewModels/AppState.swift
@@ -0,0 +1,49 @@
+import SwiftUI
+
+@MainActor
+final class AppState: ObservableObject {
+ @Published var userId: Int?
+ @Published var userName: String?
+ @Published var userPhotoUrl: String?
+ @Published var userToken: String?
+ @Published var businessId: Int = 0
+ @Published var businessName: String = ""
+ @Published var isAuthenticated = false
+
+ func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
+ self.userId = userId
+ self.userToken = token
+ self.userName = userName
+ self.userPhotoUrl = photoUrl
+ self.isAuthenticated = true
+ }
+
+ func setBusiness(id: Int, name: String) {
+ self.businessId = id
+ self.businessName = name
+ }
+
+ func clearAuth() {
+ userId = nil
+ userToken = nil
+ userName = nil
+ userPhotoUrl = nil
+ isAuthenticated = false
+ businessId = 0
+ businessName = ""
+ }
+
+ /// Handle 401 unauthorized — clear everything and force re-login
+ func handleUnauthorized() async {
+ await AuthStorage.shared.clearAuth()
+ await APIService.shared.logout()
+ clearAuth()
+ }
+
+ func loadSavedAuth() async {
+ let creds = await AuthStorage.shared.loadAuth()
+ guard let creds = creds else { return }
+ await APIService.shared.setAuth(token: creds.token, userId: creds.userId)
+ setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl)
+ }
+}
diff --git a/_backup/Views/BeaconDashboard.swift b/_backup/Views/BeaconDashboard.swift
new file mode 100644
index 0000000..e8e148e
--- /dev/null
+++ b/_backup/Views/BeaconDashboard.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct BeaconDashboard: View {
+ @EnvironmentObject var appState: AppState
+ let business: Employment
+ @State private var isReady = false
+
+ var body: some View {
+ Group {
+ if isReady {
+ TabView {
+ BeaconListScreen()
+ .tabItem {
+ Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill")
+ }
+
+ ServicePointListScreen()
+ .tabItem {
+ Label("Service Points", systemImage: "mappin.and.ellipse")
+ }
+
+ ScannerScreen()
+ .tabItem {
+ Label("Scanner", systemImage: "antenna.radiowaves.left.and.right")
+ }
+ }
+ .tint(.payfritGreen)
+ } else {
+ ProgressView("Loading...")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .task {
+ await APIService.shared.setBusinessId(business.businessId)
+ appState.setBusiness(id: business.businessId, name: business.businessName)
+ isReady = true
+ }
+ }
+}
diff --git a/_backup/Views/BeaconDetailScreen.swift b/_backup/Views/BeaconDetailScreen.swift
new file mode 100644
index 0000000..1b4aac9
--- /dev/null
+++ b/_backup/Views/BeaconDetailScreen.swift
@@ -0,0 +1,116 @@
+import SwiftUI
+
+struct BeaconDetailScreen: View {
+ let beacon: Beacon
+ var onSaved: () -> Void
+
+ @State private var name: String = ""
+ @State private var uuid: String = ""
+ @State private var isActive: Bool = true
+ @State private var isSaving = false
+ @State private var isDeleting = false
+ @State private var error: String?
+ @State private var showDeleteConfirm = false
+ @EnvironmentObject var appState: AppState
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ Form {
+ Section("Beacon Info") {
+ TextField("Name", text: $name)
+ TextField("UUID (32 hex characters)", text: $uuid)
+ .textInputAutocapitalization(.characters)
+ .autocorrectionDisabled()
+ .font(.system(.body, design: .monospaced))
+ Toggle("Active", isOn: $isActive)
+ }
+
+ Section("Details") {
+ LabeledContent("ID", value: "\(beacon.id)")
+ LabeledContent("Business ID", value: "\(beacon.businessId)")
+ if let date = beacon.createdAt {
+ LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened))
+ }
+ if let date = beacon.updatedAt {
+ LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened))
+ }
+ }
+
+ if let error = error {
+ Section {
+ HStack {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundColor(.red)
+ Text(error)
+ .foregroundColor(.red)
+ }
+ }
+ }
+
+ Section {
+ Button("Save Changes") { save() }
+ .frame(maxWidth: .infinity)
+ .disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty)
+ }
+
+ Section {
+ Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) {
+ showDeleteConfirm = true
+ }
+ .frame(maxWidth: .infinity)
+ .disabled(isSaving || isDeleting)
+ }
+ }
+ .navigationTitle(beacon.name)
+ .onAppear {
+ name = beacon.name
+ uuid = beacon.uuid
+ isActive = beacon.isActive
+ }
+ .alert("Delete Beacon?", isPresented: $showDeleteConfirm) {
+ Button("Delete", role: .destructive) { deleteBeacon() }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.")
+ }
+ }
+
+ private func save() {
+ isSaving = true
+ error = nil
+ Task {
+ do {
+ try await APIService.shared.updateBeacon(
+ beaconId: beacon.id,
+ name: name.trimmingCharacters(in: .whitespaces),
+ uuid: uuid.trimmingCharacters(in: .whitespaces),
+ isActive: isActive
+ )
+ onSaved()
+ dismiss()
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isSaving = false
+ }
+ }
+
+ private func deleteBeacon() {
+ isDeleting = true
+ error = nil
+ Task {
+ do {
+ try await APIService.shared.deleteBeacon(beaconId: beacon.id)
+ onSaved()
+ dismiss()
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isDeleting = false
+ }
+ }
+}
diff --git a/_backup/Views/BeaconEditSheet.swift b/_backup/Views/BeaconEditSheet.swift
new file mode 100644
index 0000000..d3ffc9b
--- /dev/null
+++ b/_backup/Views/BeaconEditSheet.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+
+struct BeaconEditSheet: View {
+ var onSaved: () -> Void
+
+ @State private var name = ""
+ @State private var uuid = ""
+ @State private var isSaving = false
+ @State private var error: String?
+ @EnvironmentObject var appState: AppState
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("New Beacon") {
+ TextField("Name (e.g. Table 1 Beacon)", text: $name)
+ TextField("UUID (32 hex characters)", text: $uuid)
+ .textInputAutocapitalization(.characters)
+ .autocorrectionDisabled()
+ .font(.system(.body, design: .monospaced))
+ }
+
+ Section {
+ Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ if let error = error {
+ Section {
+ HStack {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundColor(.red)
+ Text(error)
+ .foregroundColor(.red)
+ }
+ }
+ }
+ }
+ .navigationTitle("Add Beacon")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") { save() }
+ .disabled(isSaving || name.isEmpty || uuid.isEmpty)
+ }
+ }
+ }
+ }
+
+ private func save() {
+ let trimmedName = name.trimmingCharacters(in: .whitespaces)
+ let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces)
+
+ guard !trimmedName.isEmpty else {
+ error = "Name is required"
+ return
+ }
+ guard !trimmedUUID.isEmpty else {
+ error = "UUID is required"
+ return
+ }
+
+ isSaving = true
+ error = nil
+ Task {
+ do {
+ _ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID)
+ onSaved()
+ dismiss()
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isSaving = false
+ }
+ }
+}
diff --git a/_backup/Views/BeaconListScreen.swift b/_backup/Views/BeaconListScreen.swift
new file mode 100644
index 0000000..4d9b1c7
--- /dev/null
+++ b/_backup/Views/BeaconListScreen.swift
@@ -0,0 +1,154 @@
+import SwiftUI
+
+struct BeaconListScreen: View {
+ @EnvironmentObject var appState: AppState
+ @State private var beacons: [Beacon] = []
+ @State private var isLoading = true
+ @State private var error: String?
+ @State private var showAddSheet = false
+ @State private var isDeleting = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ ProgressView("Loading beacons...")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let error = error {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.largeTitle)
+ .foregroundColor(.orange)
+ Text(error)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Retry") { loadBeacons() }
+ .buttonStyle(.borderedProminent)
+ .tint(.payfritGreen)
+ }
+ .padding()
+ } else if beacons.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
+ .font(.system(size: 48))
+ .foregroundColor(.secondary)
+ Text("No beacons yet")
+ .font(.title3)
+ .foregroundColor(.secondary)
+ Text("Tap + to add your first beacon")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ } else {
+ List {
+ ForEach(beacons) { beacon in
+ NavigationLink(value: beacon) {
+ BeaconRow(beacon: beacon)
+ }
+ }
+ .onDelete(perform: deleteBeacons)
+ }
+ }
+ }
+ .navigationTitle("Beacons")
+ .navigationDestination(for: Beacon.self) { beacon in
+ BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() })
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showAddSheet) {
+ BeaconEditSheet(onSaved: { loadBeacons() })
+ }
+ .refreshable {
+ await withCheckedContinuation { continuation in
+ loadBeacons { continuation.resume() }
+ }
+ }
+ }
+ .task { loadBeacons() }
+ }
+
+ private func loadBeacons(completion: (() -> Void)? = nil) {
+ isLoading = beacons.isEmpty
+ error = nil
+ Task {
+ do {
+ beacons = try await APIService.shared.listBeacons()
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoading = false
+ completion?()
+ }
+ }
+
+ private func deleteBeacons(at offsets: IndexSet) {
+ guard !isDeleting else { return }
+ let toDelete = offsets.map { beacons[$0] }
+ // Optimistic removal
+ beacons.remove(atOffsets: offsets)
+ isDeleting = true
+
+ Task {
+ var failedBeacons: [Beacon] = []
+ for beacon in toDelete {
+ do {
+ try await APIService.shared.deleteBeacon(beaconId: beacon.id)
+ } catch {
+ failedBeacons.append(beacon)
+ self.error = error.localizedDescription
+ }
+ }
+ // Restore any that failed to delete
+ if !failedBeacons.isEmpty {
+ beacons.append(contentsOf: failedBeacons)
+ }
+ isDeleting = false
+ }
+ }
+}
+
+// MARK: - Beacon Row
+
+struct BeaconRow: View {
+ let beacon: Beacon
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(beacon.name)
+ .font(.headline)
+ Spacer()
+ if beacon.isActive {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ .font(.caption)
+ } else {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+ }
+ Text(beacon.formattedUUID)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// Make Beacon Hashable for NavigationLink
+extension Beacon: Hashable {
+ static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id }
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
+}
diff --git a/_backup/Views/BusinessSelectionScreen.swift b/_backup/Views/BusinessSelectionScreen.swift
new file mode 100644
index 0000000..c410417
--- /dev/null
+++ b/_backup/Views/BusinessSelectionScreen.swift
@@ -0,0 +1,196 @@
+import SwiftUI
+
+struct BusinessSelectionScreen: View {
+ @EnvironmentObject var appState: AppState
+ @State private var businesses: [Employment] = []
+ @State private var isLoading = true
+ @State private var error: String?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ ProgressView("Loading businesses...")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let error = error {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.largeTitle)
+ .foregroundColor(.orange)
+ Text(error)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Retry") { loadBusinesses() }
+ .buttonStyle(.borderedProminent)
+ .tint(.payfritGreen)
+ }
+ .padding()
+ } else if businesses.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "building.2")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ Text("No businesses found")
+ .foregroundColor(.secondary)
+ }
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(businesses) { biz in
+ NavigationLink(value: biz) {
+ VStack(spacing: 0) {
+ BusinessHeaderImage(businessId: biz.businessId)
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(biz.businessName)
+ .font(.subheadline.weight(.semibold))
+ .foregroundColor(.primary)
+ if !biz.businessCity.isEmpty {
+ Text(biz.businessCity)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ }
+ .background(Color(.systemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color(.systemGray4), lineWidth: 0.5)
+ )
+ .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 20)
+ }
+ }
+ }
+ .navigationTitle("Select Business")
+ .navigationDestination(for: Employment.self) { biz in
+ BeaconDashboard(business: biz)
+ }
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ logout()
+ } label: {
+ Image(systemName: "rectangle.portrait.and.arrow.right")
+ }
+ }
+ }
+ }
+ .task { loadBusinesses() }
+ }
+
+ private func loadBusinesses() {
+ isLoading = true
+ error = nil
+ Task {
+ do {
+ businesses = try await APIService.shared.getMyBusinesses()
+ isLoading = false
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ isLoading = false
+ }
+ }
+ }
+
+ private func logout() {
+ Task {
+ await AuthStorage.shared.clearAuth()
+ await APIService.shared.logout()
+ appState.clearAuth()
+ }
+ }
+}
+
+// Make Employment Hashable for NavigationLink
+extension Employment: Hashable {
+ static func == (lhs: Employment, rhs: Employment) -> Bool {
+ lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId
+ }
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(employeeId)
+ hasher.combine(businessId)
+ }
+}
+
+// MARK: - Business Header Image
+
+struct BusinessHeaderImage: View {
+ let businessId: Int
+
+ @State private var loadedImage: UIImage?
+ @State private var isLoading = true
+
+ private var imageURLs: [URL] {
+ [
+ "https://dev.payfrit.com/uploads/headers/\(businessId).png",
+ "https://dev.payfrit.com/uploads/headers/\(businessId).jpg",
+ ].compactMap { URL(string: $0) }
+ }
+
+ var body: some View {
+ ZStack {
+ Color(.systemGray6)
+
+ if let image = loadedImage {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxWidth: .infinity)
+ } else if isLoading {
+ ProgressView()
+ .tint(.payfritGreen)
+ .frame(maxWidth: .infinity)
+ .frame(height: 100)
+ } else {
+ Image(systemName: "building.2")
+ .font(.system(size: 30))
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity)
+ .frame(height: 100)
+ }
+ }
+ .task {
+ await loadImage()
+ }
+ }
+
+ private func loadImage() async {
+ for url in imageURLs {
+ do {
+ let (data, response) = try await URLSession.shared.data(from: url)
+ if let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200,
+ let image = UIImage(data: data) {
+ await MainActor.run {
+ loadedImage = image
+ isLoading = false
+ }
+ return
+ }
+ } catch {
+ continue
+ }
+ }
+ await MainActor.run {
+ isLoading = false
+ }
+ }
+}
diff --git a/_backup/Views/LoginScreen.swift b/_backup/Views/LoginScreen.swift
new file mode 100644
index 0000000..af5db0f
--- /dev/null
+++ b/_backup/Views/LoginScreen.swift
@@ -0,0 +1,136 @@
+import SwiftUI
+
+struct LoginScreen: View {
+ @EnvironmentObject var appState: AppState
+ @State private var username = ""
+ @State private var password = ""
+ @State private var showPassword = false
+ @State private var isLoading = false
+ @State private var error: String?
+ @State private var isDev = false
+
+ var body: some View {
+ GeometryReader { geo in
+ ScrollView {
+ VStack(spacing: 12) {
+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.payfritGreen)
+ .padding(.top, 40)
+
+ Text("Payfrit Beacon")
+ .font(.system(size: 28, weight: .bold))
+
+ Text("Sign in to manage beacons")
+ .foregroundColor(.secondary)
+
+ if isDev {
+ Text("DEV MODE — password: 123456")
+ .font(.caption)
+ .foregroundColor(.red)
+ .fontWeight(.medium)
+ }
+
+ VStack(spacing: 12) {
+ TextField("Email or Phone", text: $username)
+ .textFieldStyle(.roundedBorder)
+ .textContentType(.emailAddress)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+
+ ZStack(alignment: .trailing) {
+ Group {
+ if showPassword {
+ TextField("Password", text: $password)
+ .textContentType(.password)
+ } else {
+ SecureField("Password", text: $password)
+ .textContentType(.password)
+ }
+ }
+ .textFieldStyle(.roundedBorder)
+ .onSubmit { login() }
+
+ Button {
+ showPassword.toggle()
+ } label: {
+ Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
+ .foregroundColor(.secondary)
+ .font(.subheadline)
+ }
+ .padding(.trailing, 8)
+ }
+ }
+ .padding(.top, 8)
+
+ if let error = error {
+ HStack {
+ Image(systemName: "exclamationmark.circle.fill")
+ .foregroundColor(.red)
+ Text(error)
+ .foregroundColor(.red)
+ .font(.callout)
+ Spacer()
+ }
+ .padding(12)
+ .background(Color.red.opacity(0.1))
+ .cornerRadius(8)
+ }
+
+ Button(action: login) {
+ if isLoading {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .white))
+ .frame(maxWidth: .infinity, minHeight: 44)
+ } else {
+ Text("Sign In")
+ .font(.headline)
+ .frame(maxWidth: .infinity, minHeight: 44)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.payfritGreen)
+ .disabled(isLoading)
+ }
+ .padding(.horizontal, 24)
+ .frame(minHeight: geo.size.height)
+ }
+ }
+ .background(Color(.systemGroupedBackground))
+ .task { isDev = await APIService.shared.isDev }
+ }
+
+ private func login() {
+ let user = username.trimmingCharacters(in: .whitespaces)
+ let pass = password
+ guard !user.isEmpty, !pass.isEmpty else {
+ error = "Please enter username and password"
+ return
+ }
+
+ isLoading = true
+ error = nil
+
+ Task {
+ do {
+ let response = try await APIService.shared.login(username: user, password: pass)
+ let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl)
+ await AuthStorage.shared.saveAuth(
+ userId: response.userId,
+ token: response.token,
+ userName: response.userFirstName,
+ photoUrl: resolvedPhoto
+ )
+ appState.setAuth(
+ userId: response.userId,
+ token: response.token,
+ userName: response.userFirstName,
+ photoUrl: resolvedPhoto
+ )
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoading = false
+ }
+ }
+}
diff --git a/_backup/Views/RootView.swift b/_backup/Views/RootView.swift
new file mode 100644
index 0000000..0cfd3d6
--- /dev/null
+++ b/_backup/Views/RootView.swift
@@ -0,0 +1,88 @@
+import SwiftUI
+import LocalAuthentication
+
+struct RootView: View {
+ @EnvironmentObject var appState: AppState
+ @State private var isCheckingAuth = true
+ @State private var isDev = false
+
+ var body: some View {
+ Group {
+ if isCheckingAuth {
+ loadingView
+ } else if appState.isAuthenticated {
+ BusinessSelectionScreen()
+ } else {
+ LoginScreen()
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated)
+ .overlay(alignment: .bottomLeading) {
+ if isDev {
+ Text("DEV")
+ .font(.caption.bold())
+ .foregroundColor(.white)
+ .frame(width: 80, height: 20)
+ .background(Color.orange)
+ .rotationEffect(.degrees(45))
+ .offset(x: -20, y: -6)
+ .allowsHitTesting(false)
+ }
+ }
+ .task {
+ isDev = await APIService.shared.isDev
+ await checkAuthWithBiometrics()
+ isCheckingAuth = false
+ }
+ }
+
+ private var loadingView: some View {
+ ZStack {
+ Color.white.ignoresSafeArea()
+ VStack(spacing: 16) {
+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.payfritGreen)
+ Text("Payfrit Beacon")
+ .font(.title2.bold())
+ ProgressView()
+ .tint(.payfritGreen)
+ }
+ }
+ }
+
+ private func checkAuthWithBiometrics() async {
+ let creds = await AuthStorage.shared.loadAuth()
+ guard creds != nil else { return }
+
+ #if targetEnvironment(simulator)
+ await appState.loadSavedAuth()
+ return
+ #else
+ let context = LAContext()
+ context.localizedCancelTitle = "Use Password"
+ var error: NSError?
+ let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+
+ guard canUseBiometrics else {
+ // No biometrics available — allow login with saved credentials
+ await appState.loadSavedAuth()
+ return
+ }
+
+ do {
+ let success = try await context.evaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ localizedReason: "Sign in to Payfrit Beacon"
+ )
+ if success {
+ await appState.loadSavedAuth()
+ }
+ } catch {
+ // User cancelled biometrics — still allow them in with saved credentials
+ NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)")
+ await appState.loadSavedAuth()
+ }
+ #endif
+ }
+}
diff --git a/_backup/Views/ScannerScreen.swift b/_backup/Views/ScannerScreen.swift
new file mode 100644
index 0000000..8466cb8
--- /dev/null
+++ b/_backup/Views/ScannerScreen.swift
@@ -0,0 +1,225 @@
+import SwiftUI
+import CoreLocation
+
+struct ScannerScreen: View {
+ @State private var beacons: [Beacon] = []
+ @State private var selectedBeacon: Beacon?
+ @State private var isLoading = true
+
+ // Scanner state
+ @StateObject private var scanner = ScannerViewModel()
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ // Beacon selector
+ if isLoading {
+ ProgressView()
+ .padding()
+ } else {
+ Picker("Select Beacon", selection: $selectedBeacon) {
+ Text("Choose a beacon...").tag(nil as Beacon?)
+ ForEach(beacons) { beacon in
+ Text(beacon.name).tag(beacon as Beacon?)
+ }
+ }
+ .pickerStyle(.menu)
+ .padding()
+ }
+
+ Divider()
+
+ // Scanner display
+ VStack(spacing: 24) {
+ Spacer()
+
+ // Status indicator
+ ZStack {
+ Circle()
+ .fill(scanner.statusColor.opacity(0.15))
+ .frame(width: 160, height: 160)
+
+ Circle()
+ .fill(scanner.statusColor.opacity(0.3))
+ .frame(width: 120, height: 120)
+
+ Image(systemName: scanner.statusIcon)
+ .font(.system(size: 48))
+ .foregroundColor(scanner.statusColor)
+ }
+
+ Text(scanner.statusText)
+ .font(.title3.bold())
+
+ if scanner.isScanning {
+ VStack(spacing: 8) {
+ if scanner.rssi != 0 {
+ HStack {
+ Text("RSSI:")
+ .foregroundColor(.secondary)
+ Text("\(scanner.rssi) dBm")
+ .font(.system(.body, design: .monospaced))
+ .bold()
+ }
+ HStack {
+ Text("Samples:")
+ .foregroundColor(.secondary)
+ Text("\(scanner.sampleCount)/\(scanner.requiredSamples)")
+ .font(.system(.body, design: .monospaced))
+ }
+ // Signal strength bar
+ SignalStrengthBar(rssi: scanner.rssi)
+ .frame(height: 20)
+ .padding(.horizontal, 40)
+ } else {
+ Text("Searching for beacon signal...")
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ Spacer()
+
+ // Start/Stop button
+ Button {
+ if scanner.isScanning {
+ scanner.stop()
+ } else if let beacon = selectedBeacon {
+ scanner.start(uuid: beacon.uuid)
+ }
+ } label: {
+ HStack {
+ Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill")
+ Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning")
+ }
+ .font(.headline)
+ .frame(maxWidth: .infinity, minHeight: 50)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(scanner.isScanning ? .red : .payfritGreen)
+ .disabled(selectedBeacon == nil && !scanner.isScanning)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 24)
+ }
+ }
+ .navigationTitle("Beacon Scanner")
+ }
+ .task {
+ do {
+ beacons = try await APIService.shared.listBeacons()
+ } catch {
+ // Silently fail — user can still see the scanner
+ }
+ isLoading = false
+ }
+ .onChange(of: selectedBeacon) { _ in
+ if scanner.isScanning {
+ scanner.stop()
+ }
+ }
+ }
+}
+
+// MARK: - Scanner ViewModel
+
+@MainActor
+final class ScannerViewModel: ObservableObject {
+ @Published var isScanning = false
+ @Published var statusText = "Select a beacon to scan"
+ @Published var statusColor: Color = .secondary
+ @Published var statusIcon = "sensor.tag.radiowaves.forward.fill"
+ @Published var rssi: Int = 0
+ @Published var sampleCount = 0
+ let requiredSamples = 5
+
+ private var beaconScanner: BeaconScanner?
+
+ func start(uuid: String) {
+ beaconScanner?.dispose()
+
+ beaconScanner = BeaconScanner(
+ targetUUID: uuid,
+ onBeaconDetected: { [weak self] avgRssi in
+ self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)"
+ self?.statusColor = .green
+ self?.statusIcon = "checkmark.circle.fill"
+ },
+ onRSSIUpdate: { [weak self] currentRssi, samples in
+ self?.rssi = currentRssi
+ self?.sampleCount = samples
+ },
+ onBluetoothOff: { [weak self] in
+ self?.statusText = "Bluetooth is OFF"
+ self?.statusColor = .orange
+ self?.statusIcon = "bluetooth.slash"
+ },
+ onPermissionDenied: { [weak self] in
+ self?.statusText = "Location Permission Denied"
+ self?.statusColor = .red
+ self?.statusIcon = "location.slash.fill"
+ self?.isScanning = false
+ },
+ onError: { [weak self] message in
+ self?.statusText = message
+ self?.statusColor = .red
+ self?.statusIcon = "exclamationmark.triangle.fill"
+ self?.isScanning = false
+ }
+ )
+
+ beaconScanner?.startScanning()
+ isScanning = true
+ statusText = "Scanning..."
+ statusColor = .blue
+ statusIcon = "antenna.radiowaves.left.and.right"
+ rssi = 0
+ sampleCount = 0
+ }
+
+ func stop() {
+ beaconScanner?.dispose()
+ beaconScanner = nil
+ isScanning = false
+ statusText = "Select a beacon to scan"
+ statusColor = .secondary
+ statusIcon = "sensor.tag.radiowaves.forward.fill"
+ rssi = 0
+ sampleCount = 0
+ }
+
+ deinit {
+ // Ensure cleanup if view is removed while scanning
+ // Note: deinit runs on main actor since class is @MainActor
+ }
+}
+
+// MARK: - Signal Strength Bar
+
+struct SignalStrengthBar: View {
+ let rssi: Int
+
+ private var strength: Double {
+ // Map RSSI from -100..-30 to 0..1
+ let clamped = max(-100, min(-30, rssi))
+ return Double(clamped + 100) / 70.0
+ }
+
+ private var barColor: Color {
+ if strength > 0.7 { return .green }
+ if strength > 0.4 { return .yellow }
+ return .red
+ }
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color.secondary.opacity(0.2))
+
+ RoundedRectangle(cornerRadius: 4)
+ .fill(barColor)
+ .frame(width: geo.size.width * strength)
+ }
+ }
+ }
+}
diff --git a/_backup/Views/ServicePointListScreen.swift b/_backup/Views/ServicePointListScreen.swift
new file mode 100644
index 0000000..e86bc85
--- /dev/null
+++ b/_backup/Views/ServicePointListScreen.swift
@@ -0,0 +1,232 @@
+import SwiftUI
+
+struct ServicePointListScreen: View {
+ @EnvironmentObject var appState: AppState
+ @State private var servicePoints: [ServicePoint] = []
+ @State private var beacons: [Beacon] = []
+ @State private var isLoading = true
+ @State private var error: String?
+ @State private var assigningPointId: Int?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if isLoading {
+ ProgressView("Loading service points...")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if let error = error {
+ VStack(spacing: 16) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.largeTitle)
+ .foregroundColor(.orange)
+ Text(error)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ Button("Retry") { loadData() }
+ .buttonStyle(.borderedProminent)
+ .tint(.payfritGreen)
+ }
+ .padding()
+ } else if servicePoints.isEmpty {
+ VStack(spacing: 16) {
+ Image(systemName: "mappin.and.ellipse")
+ .font(.system(size: 48))
+ .foregroundColor(.secondary)
+ Text("No service points")
+ .font(.title3)
+ .foregroundColor(.secondary)
+ }
+ } else {
+ List(servicePoints) { sp in
+ ServicePointRow(
+ servicePoint: sp,
+ beacons: beacons,
+ isAssigning: assigningPointId == sp.id,
+ onAssignBeacon: { beaconId in
+ assignBeacon(servicePointId: sp.id, beaconId: beaconId)
+ }
+ )
+ }
+ }
+ }
+ .navigationTitle("Service Points")
+ .refreshable {
+ await withCheckedContinuation { continuation in
+ loadData { continuation.resume() }
+ }
+ }
+ }
+ .task { loadData() }
+ }
+
+ private func loadData(completion: (() -> Void)? = nil) {
+ isLoading = servicePoints.isEmpty
+ error = nil
+ Task {
+ do {
+ async let sp = APIService.shared.listServicePoints()
+ async let b = APIService.shared.listBeacons()
+ servicePoints = try await sp
+ beacons = try await b
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isLoading = false
+ completion?()
+ }
+ }
+
+ private func assignBeacon(servicePointId: Int, beaconId: Int?) {
+ assigningPointId = servicePointId
+ Task {
+ do {
+ try await APIService.shared.assignBeaconToServicePoint(
+ servicePointId: servicePointId,
+ beaconId: beaconId
+ )
+ loadData()
+ } catch let apiError as APIError where apiError == .unauthorized {
+ await appState.handleUnauthorized()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ assigningPointId = nil
+ }
+ }
+}
+
+// MARK: - Service Point Row
+
+struct ServicePointRow: View {
+ let servicePoint: ServicePoint
+ let beacons: [Beacon]
+ let isAssigning: Bool
+ var onAssignBeacon: (Int?) -> Void
+
+ @State private var showBeaconPicker = false
+
+ private var assignedBeacon: Beacon? {
+ guard let bid = servicePoint.beaconId else { return nil }
+ return beacons.first { $0.id == bid }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(servicePoint.name)
+ .font(.headline)
+ if !servicePoint.typeName.isEmpty {
+ Text(servicePoint.typeName)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ Spacer()
+ if servicePoint.isActive {
+ Circle()
+ .fill(.green)
+ .frame(width: 8, height: 8)
+ } else {
+ Circle()
+ .fill(.red)
+ .frame(width: 8, height: 8)
+ }
+ }
+
+ // Beacon assignment
+ HStack {
+ Image(systemName: "sensor.tag.radiowaves.forward.fill")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if isAssigning {
+ ProgressView()
+ .controlSize(.small)
+ } else if let beacon = assignedBeacon {
+ Text(beacon.name)
+ .font(.subheadline)
+ .foregroundColor(.payfritGreen)
+ } else {
+ Text("No beacon assigned")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ Button {
+ showBeaconPicker = true
+ } label: {
+ Text(assignedBeacon != nil ? "Change" : "Assign")
+ .font(.caption)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ if assignedBeacon != nil {
+ Button {
+ onAssignBeacon(nil)
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundColor(.red)
+ .font(.caption)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ .sheet(isPresented: $showBeaconPicker) {
+ BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in
+ onAssignBeacon(selectedId)
+ }
+ }
+ }
+}
+
+// MARK: - Beacon Picker Sheet
+
+struct BeaconPickerSheet: View {
+ let beacons: [Beacon]
+ let currentBeaconId: Int?
+ var onSelect: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ List(beacons) { beacon in
+ Button {
+ onSelect(beacon.id)
+ dismiss()
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(beacon.name)
+ .font(.headline)
+ .foregroundColor(.primary)
+ Text(beacon.formattedUUID)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ if beacon.id == currentBeaconId {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.payfritGreen)
+ }
+ }
+ }
+ }
+ .navigationTitle("Select Beacon")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ }
+}