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() } + } + } + } + } +}