diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index c931024..cc5c909 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; }; D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; }; + D01000000009 /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QRScannerView.swift */; }; D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; }; D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; }; @@ -46,6 +47,7 @@ D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = ""; }; D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -110,6 +112,7 @@ D02000000006 /* LoginView.swift */, D02000000007 /* BusinessListView.swift */, D02000000008 /* ScanView.swift */, + D02000000009 /* QRScannerView.swift */, D02000000010 /* Info.plist */, D02000000060 /* Assets.xcassets */, D02000000070 /* payfrit-favicon-light-outlines.svg */, @@ -254,6 +257,7 @@ D01000000006 /* LoginView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */, D01000000008 /* ScanView.swift in Sources */, + D01000000009 /* QRScannerView.swift in Sources */, D0100000000A /* RootView.swift in Sources */, D010000000B1 /* BLEBeaconScanner.swift in Sources */, D010000000B2 /* BeaconProvisioner.swift in Sources */, diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift deleted file mode 100644 index a229556..0000000 --- a/PayfritBeacon/Api.swift +++ /dev/null @@ -1,702 +0,0 @@ -import Foundation - -class Api { - static let shared = Api() - - // ── DEV toggle: driven by DEV compiler flag (set in build configuration) ── - #if DEV - static let IS_DEV = true - #else - static let IS_DEV = false - #endif - - private static var BASE_URL: String { - IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" - } - - private let session: URLSession - private var authToken: String? - - private init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 30 - session = URLSession(configuration: config) - } - - func setAuthToken(_ token: String?) { - authToken = token - } - - func getAuthToken() -> String? { - return authToken - } - - private func buildRequest(endpoint: String) -> URLRequest { - let url = URL(string: "\(Api.BASE_URL)\(endpoint)")! - var request = URLRequest(url: url) - if let token = authToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(token, forHTTPHeaderField: "X-User-Token") - } - return request - } - - private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] { - var request = buildRequest(endpoint: endpoint) - request.httpMethod = "POST" - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: body) - for (key, value) in extraHeaders { - request.setValue(value, forHTTPHeaderField: key) - } - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ApiException("Invalid response") - } - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw ApiException("Request failed: \(httpResponse.statusCode)") - } - guard !data.isEmpty else { - throw ApiException("Empty response") - } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ApiException("Invalid JSON response") - } - return json - } - - private func getRequest(endpoint: String) async throws -> [String: Any] { - var request = buildRequest(endpoint: endpoint) - request.httpMethod = "GET" - - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw ApiException("Invalid response") - } - guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { - throw ApiException("Request failed: \(httpResponse.statusCode)") - } - guard !data.isEmpty else { - throw ApiException("Empty response") - } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw ApiException("Invalid JSON response") - } - return json - } - - // ========================================================================= - // AUTH - // ========================================================================= - - func sendLoginOtp(phone: String) async throws -> OtpResponse { - let json = try await postRequest(endpoint: "/auth/loginOTP.php", body: ["Phone": phone]) - - guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else { - throw ApiException("Server error - please try again") - } - - return OtpResponse(uuid: uuid) - } - - func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { - let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.php", body: [ - "UUID": uuid, - "OTP": otp - ]) - - let ok = parseBool(json["OK"] ?? json["ok"]) - if !ok { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code" - throw ApiException(error) - } - - return LoginResponse( - userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0, - token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "", - userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? "" - ) - } - - // ========================================================================= - // BUSINESSES - // ========================================================================= - - func listBusinesses() async throws -> [Business] { - let json = try await postRequest(endpoint: "/businesses/list.php", body: [:]) - - guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else { - return [] - } - - return businesses.compactMap { b in - guard let businessId = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { - return nil - } - let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" - let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String - return Business(businessId: businessId, name: name, headerImageExtension: headerImageExtension) - } - } - - /// Get a single business by ID - func getBusiness(businessId: Int) async throws -> Business? { - let json = try await postRequest( - endpoint: "/businesses/get.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - guard parseBool(json["OK"] ?? json["ok"]) else { - return nil - } - - guard let b = (json["BUSINESS"] ?? json["business"]) as? [String: Any], - let id = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { - return nil - } - - let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" - let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String - return Business(businessId: id, name: name, headerImageExtension: headerImageExtension) - } - - // Business name cache (matches Android's caching behavior) - private var businessNameCache: [Int: String] = [:] - - /// Get business name with caching (avoids repeated API calls) - func getBusinessName(businessId: Int) async throws -> String { - if let cached = businessNameCache[businessId] { - return cached - } - if let business = try await getBusiness(businessId: businessId) { - businessNameCache[business.businessId] = business.name - return business.name - } - return "" - } - - // ========================================================================= - // BEACONS - // ========================================================================= - - func listAllBeacons() async throws -> [String: Int] { - let json = try await getRequest(endpoint: "/beacons/list_all.php") - - guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { - return [:] - } - - var result: [String: Int] = [:] - for item in items { - guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)? - .normalizedUUID, - let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else { - continue - } - result[uuid] = beaconId - } - return result - } - - func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { - if uuids.isEmpty { return [] } - - let json = try await postRequest(endpoint: "/beacons/lookup.php", body: ["UUIDs": uuids]) - - guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { - return [] - } - - return beacons.compactMap { b in - guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? - .normalizedUUID else { - return nil - } - return BeaconLookupResult( - uuid: uuid, - beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0, - businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0, - businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "", - servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "", - beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" - ) - } - } - - func listBeacons(businessId: Int) async throws -> [BeaconInfo] { - let json = try await postRequest( - endpoint: "/beacons/list.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { - return [] - } - - return beacons.compactMap { b in - let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0 - let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? "" - let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? - .normalizedUUID ?? "" - let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true) - return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive) - } - } - - func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon { - var params: [String: Any] = [ - "BusinessID": businessId, - "Name": name, - "UUID": uuid - ] - if let mac = macAddress, !mac.isEmpty { - params["MACAddress"] = mac - } - - let json = try await postRequest( - endpoint: "/beacons/save.php", - body: params, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon") - } - - let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] - - return SavedBeacon( - beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0, - name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name, - uuid: uuid, - macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String - ) - } - - func lookupByMac(macAddress: String) async throws -> MacLookupResult? { - let json = try await postRequest(endpoint: "/beacons/lookupByMac.php", body: ["MACAddress": macAddress]) - - if !parseBool(json["OK"] ?? json["ok"]) { - return nil - } - - guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else { - return nil - } - - return MacLookupResult( - beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0, - businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0, - businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "", - beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "", - uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "", - macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "", - servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? "" - ) - } - - func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { - let json = try await postRequest( - endpoint: "/beacons/wipe.php", - body: ["BusinessID": businessId, "BeaconID": beaconId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon" - let message = (json["MESSAGE"] ?? json["message"]) as? String - throw ApiException(message ?? error) - } - - return true - } - - // ========================================================================= - // BEACON SHARDING / PROVISIONING - // ========================================================================= - - /// Get beacon config for a service point (UUID, Major, Minor to write to beacon) - func getBeaconConfig(businessId: Int, servicePointId: Int) async throws -> BeaconConfigResponse { - DebugLog.shared.log("[API] getBeaconConfig businessId=\(businessId) servicePointId=\(servicePointId)") - - let json = try await postRequest( - endpoint: "/beacon-sharding/get_beacon_config.php", - body: ["BusinessID": businessId, "ServicePointID": servicePointId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] getBeaconConfig response keys: \(json.keys.sorted())") - DebugLog.shared.log("[API] getBeaconConfig full response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"] ?? json["Ok"]) { - let error = ((json["ERROR"] ?? json["error"] ?? json["Error"]) as? String) ?? "Failed to get beacon config" - throw ApiException(error) - } - - guard let uuid = (json["UUID"] ?? json["uuid"] ?? json["Uuid"] ?? json["BeaconUUID"] ?? json["BEACONUUID"]) as? String else { - throw ApiException("Invalid beacon config response - no UUID. Keys: \(json.keys.sorted())") - } - - guard let major = parseIntValue(json["MAJOR"] ?? json["major"] ?? json["Major"] ?? json["BeaconMajor"] ?? json["BEACONMAJOR"]) else { - throw ApiException("Invalid beacon config response - no Major. Keys: \(json.keys.sorted())") - } - - guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { - throw ApiException("Invalid beacon config response - no Minor. Keys: \(json.keys.sorted())") - } - - // Parse new fields from updated endpoint - let measuredPower = parseIntValue(json["MeasuredPower"] ?? json["MEASUREDPOWER"] ?? json["measuredPower"]) ?? -59 - let advInterval = parseIntValue(json["AdvInterval"] ?? json["ADVINTERVAL"] ?? json["advInterval"]) ?? 2 - let txPower = parseIntValue(json["TxPower"] ?? json["TXPOWER"] ?? json["txPower"]) ?? 1 - let servicePointName = (json["ServicePointName"] ?? json["SERVICEPOINTNAME"] ?? json["servicePointName"]) as? String ?? "" - let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" - - DebugLog.shared.log("[API] getBeaconConfig parsed: uuid=\(uuid) major=\(major) minor=\(minor) measuredPower=\(measuredPower) advInterval=\(advInterval) txPower=\(txPower)") - - return BeaconConfigResponse( - uuid: uuid.normalizedUUID, - major: UInt16(major), - minor: UInt16(minor), - measuredPower: Int8(clamping: measuredPower), - advInterval: UInt8(clamping: advInterval), - txPower: UInt8(clamping: txPower), - servicePointName: servicePointName, - businessName: businessName - ) - } - - /// Register beacon hardware after provisioning - func registerBeaconHardware(businessId: Int, servicePointId: Int, uuid: String, major: UInt16, minor: UInt16, hardwareId: String, macAddress: String? = nil) async throws -> Bool { - var body: [String: Any] = [ - "BusinessID": businessId, - "ServicePointID": servicePointId, - "UUID": uuid, - "Major": major, - "Minor": minor, - "HardwareId": hardwareId - ] - if let mac = macAddress, !mac.isEmpty { - body["MACAddress"] = mac - } - - DebugLog.shared.log("[API] registerBeaconHardware body: \(body)") - - let json = try await postRequest( - endpoint: "/beacon-sharding/register_beacon_hardware.php", - body: body, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] registerBeaconHardware response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to register beacon" - throw ApiException(error) - } - - return true - } - - /// Resolve beacon ownership by UUID and Major - func resolveBusiness(uuid: String, major: UInt16) async throws -> ResolveBusinessResponse { - let json = try await postRequest( - endpoint: "/beacon-sharding/resolve_business.php", - body: ["UUID": uuid, "Major": Int(major)] - ) - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to resolve business" - throw ApiException(error) - } - - guard let businessId = parseIntValue(json["BusinessID"] ?? json["BUSINESSID"] ?? json["businessId"]) else { - throw ApiException("Invalid resolve response - no BusinessID") - } - - let businessName = (json["BusinessName"] ?? json["BUSINESSNAME"] ?? json["businessName"]) as? String ?? "" - - return ResolveBusinessResponse(businessId: businessId, businessName: businessName) - } - - /// 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.php", - 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.php", - body: ["BusinessID": businessId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - // Debug log - DebugLog.shared.log("[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, - uuidClean: uuid.normalizedUUID, - major: UInt16(major), - alreadyAllocated: parseBool(json["AlreadyAllocated"] ?? json["ALREADYALLOCATED"]) - ) - } - - /// Allocate a minor value for a service point (auto-assigns next available minor) - func allocateServicePointMinor(businessId: Int, servicePointId: Int) async throws -> UInt16 { - let json = try await postRequest( - endpoint: "/beacon-sharding/allocate_servicepoint_minor.php", - body: ["BusinessID": businessId, "ServicePointID": servicePointId], - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] allocateServicePointMinor response: \(json)") - - if !parseBool(json["OK"] ?? json["ok"]) { - let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to allocate minor" - throw ApiException(error) - } - - guard let minor = parseIntValue(json["MINOR"] ?? json["minor"] ?? json["Minor"] ?? json["BeaconMinor"] ?? json["BEACONMINOR"]) else { - throw ApiException("Invalid response - no Minor value") - } - - return UInt16(minor) - } - - /// List service points for a business (for beacon assignment) - func listServicePoints(businessId: Int) async throws -> [ServicePoint] { - let json = try await postRequest( - endpoint: "/servicepoints/list.php", - 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 - } - - DebugLog.shared.log("[API] saveServicePoint businessId=\(businessId) name=\(name) servicePointId=\(String(describing: servicePointId))") - - let json = try await postRequest( - endpoint: "/servicepoints/save.php", - body: body, - extraHeaders: ["X-Business-Id": String(businessId)] - ) - - DebugLog.shared.log("[API] saveServicePoint response: \(json)") - - 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"]) - - DebugLog.shared.log("[API] saveServicePoint parsed: spId=\(spId) beaconMinor=\(String(describing: beaconMinor))") - - if spId == 0 { - DebugLog.shared.log("[API] WARNING: servicePointId is 0! Full sp dict: \(sp)") - } - - 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) - } - - /// Delete a service point - func deleteServicePoint(businessId: Int, servicePointId: Int) async throws { - let json = try await postRequest( - endpoint: "/servicepoints/delete.php", - 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 delete service point" - throw ApiException(error) - } - } - - // ========================================================================= - // HELPERS - // ========================================================================= - - private func parseBool(_ value: Any?) -> Bool { - switch value { - case nil: - return false - case let b as Bool: - return b - case let n as NSNumber: - return n.intValue != 0 - case let s as String: - return ["true", "1"].contains(s.lowercased()) - default: - return false - } - } - - private func parseIntValue(_ value: Any?) -> Int? { - if let n = value as? NSNumber { - return n.intValue - } - if let s = value as? String, let i = Int(s) { - return i - } - return nil - } -} - -struct ApiException: LocalizedError { - let message: String - init(_ message: String) { self.message = message } - var errorDescription: String? { message } -} - -// ========================================================================= -// DATA MODELS -// ========================================================================= - -struct OtpResponse { - let uuid: String -} - -struct LoginResponse { - let userId: Int - let token: String - let userFirstName: String -} - -struct Business: Identifiable { - var id: Int { businessId } - let businessId: Int - let name: String - let headerImageExtension: String? -} - -struct BeaconLookupResult { - let uuid: String - let beaconId: Int - let businessId: Int - let businessName: String - let servicePointName: String - let beaconName: String -} - -struct BeaconInfo { - let beaconId: Int - let name: String - let uuid: String - let isActive: Bool -} - -struct SavedBeacon { - let beaconId: Int - let name: String - let uuid: String - let macAddress: String? -} - -struct MacLookupResult { - let beaconId: Int - let businessId: Int - let businessName: String - let beaconName: String - let uuid: String - let macAddress: String - let servicePointName: String -} - -struct BeaconConfigResponse { - let uuid: String // 32-char hex, no dashes - let major: UInt16 - let minor: UInt16 - let measuredPower: Int8 // RSSI@1m (e.g., -59) - let advInterval: UInt8 // Advertising interval (raw value, e.g., 2 = 200ms) - let txPower: UInt8 // TX power level - let servicePointName: String - let businessName: String -} - -struct ResolveBusinessResponse { - let businessId: Int - let businessName: String -} - -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 // Original UUID from API (with dashes, as-is) - let uuidClean: String // 32-char hex, no dashes, uppercase (for BLE provisioning) - let major: UInt16 - let alreadyAllocated: Bool -} diff --git a/PayfritBeacon/App/AppPrefs.swift b/PayfritBeacon/App/AppPrefs.swift new file mode 100644 index 0000000..2e5013d --- /dev/null +++ b/PayfritBeacon/App/AppPrefs.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Non-sensitive preferences (UserDefaults) +enum AppPrefs { + private static let defaults = UserDefaults.standard + + static var lastBusinessId: String? { + get { defaults.string(forKey: "lastBusinessId") } + set { defaults.set(newValue, forKey: "lastBusinessId") } + } +} diff --git a/PayfritBeacon/App/AppState.swift b/PayfritBeacon/App/AppState.swift new file mode 100644 index 0000000..72db257 --- /dev/null +++ b/PayfritBeacon/App/AppState.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// Central app state — drives navigation between Login → Business Select → Scan +@MainActor +final class AppState: ObservableObject { + + enum Screen { + case login + case businessList + case scan(business: Business) + } + + @Published var currentScreen: Screen = .login + @Published var token: String? + @Published var userId: String? + + init() { + // Restore saved session + if let saved = SecureStorage.loadSession() { + self.token = saved.token + self.userId = saved.userId + self.currentScreen = .businessList + } + } + + func didLogin(token: String, userId: String) { + self.token = token + self.userId = userId + SecureStorage.saveSession(token: token, userId: userId) + currentScreen = .businessList + } + + func selectBusiness(_ business: Business) { + AppPrefs.lastBusinessId = business.id + currentScreen = .scan(business: business) + } + + func backToBusinessList() { + currentScreen = .businessList + } + + func logout() { + token = nil + userId = nil + SecureStorage.clearSession() + currentScreen = .login + } +} diff --git a/_backup/PayfritBeaconApp.swift b/PayfritBeacon/App/PayfritBeaconApp.swift similarity index 71% rename from _backup/PayfritBeaconApp.swift rename to PayfritBeacon/App/PayfritBeaconApp.swift index 4a67203..cf7fe39 100644 --- a/_backup/PayfritBeaconApp.swift +++ b/PayfritBeacon/App/PayfritBeaconApp.swift @@ -11,7 +11,3 @@ struct PayfritBeaconApp: App { } } } - -extension Color { - static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294) -} diff --git a/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json b/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 650bdd4..0000000 --- a/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.294", - "green" : "0.698", - "red" : "0.133" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 421ff78..0000000 --- a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "images" : [ - { - "filename" : "appicon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "filename" : "appicon.png", - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png deleted file mode 100644 index d295e50..0000000 Binary files a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png and /dev/null differ diff --git a/PayfritBeacon/Assets.xcassets/Contents.json b/PayfritBeacon/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/PayfritBeacon/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PayfritBeacon/BLEBeaconScanner.swift b/PayfritBeacon/BLEBeaconScanner.swift deleted file mode 100644 index dfea0ff..0000000 --- a/PayfritBeacon/BLEBeaconScanner.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Beacon type detected by service UUID or name -enum BeaconType: String { - case kbeacon = "KBeacon" - case dxsmart = "DX-Smart" - 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 { - return 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 1 second (beacons advertise every ~200ms so 1s is plenty) - scanTimer?.invalidate() - scanTimer = Timer.scheduledTimer(withTimeInterval: 1.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 > -70 && rssiValue < 0 else { return } // Only show nearby devices - - let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" - let nameUpper = name.uppercased() - - // Best-effort type hint from advertised services OR device name - // Note: Some DX-Smart beacons don't advertise FFE0 in scan response, so also filter by name - var beaconType: BeaconType = .unknown - if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { - if serviceUUIDs.contains(BLEBeaconScanner.KBEACON_SERVICE) { - beaconType = .dxsmart - } else if serviceUUIDs.contains(BLEBeaconScanner.BLUECHARM_SERVICE) { - beaconType = .bluecharm - } - } - - // Also detect DX-Smart by name pattern (some beacons don't advertise FFE0) - if beaconType == .unknown && (nameUpper.contains("DX") || nameUpper.contains("SMART")) { - beaconType = .dxsmart - } - - // 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 deleted file mode 100644 index 7215149..0000000 --- a/PayfritBeacon/BeaconProvisioner.swift +++ /dev/null @@ -1,1441 +0,0 @@ -import Foundation -import CoreBluetooth - -/// Structured provisioning error codes (matches Android's BeaconConfig error codes) -enum ProvisioningError: String, LocalizedError { - case bluetoothUnavailable = "BLUETOOTH_UNAVAILABLE" - case connectionFailed = "CONNECTION_FAILED" - case connectionTimeout = "CONNECTION_TIMEOUT" - case serviceNotFound = "SERVICE_NOT_FOUND" - case authFailed = "AUTH_FAILED" - case writeFailed = "WRITE_FAILED" - case verificationFailed = "VERIFICATION_FAILED" - case disconnected = "DISCONNECTED" - case noConfig = "NO_CONFIG" - case timeout = "TIMEOUT" - case unknown = "UNKNOWN" - - var errorDescription: String? { - switch self { - case .bluetoothUnavailable: return "Bluetooth not available" - case .connectionFailed: return "Failed to connect to beacon" - case .connectionTimeout: return "Connection timed out" - case .serviceNotFound: return "Config service not found on device" - case .authFailed: return "Authentication failed - all passwords rejected" - case .writeFailed: return "Failed to write configuration" - case .verificationFailed: return "Beacon not broadcasting expected values" - case .disconnected: return "Unexpected disconnect" - case .noConfig: return "No configuration provided" - case .timeout: return "Operation timed out" - case .unknown: return "Unknown error" - } - } -} - -/// Result of a provisioning operation -enum ProvisioningResult { - case success(macAddress: String?) - case failure(String) - case failureWithCode(ProvisioningError, detail: String? = nil) -} - -/// Configuration to write to a beacon -struct BeaconConfig { - let uuid: String // 32-char hex, no dashes - let major: UInt16 - let minor: UInt16 - let measuredPower: Int8 // RSSI@1m (e.g., -59) - from server, NOT hardcoded - let advInterval: UInt8 // Advertising interval raw value (e.g., 2 = 200ms) - from server - let txPower: UInt8 // TX power level - from server - let deviceName: String? // Service point name (max 20 ASCII chars for DX-Smart) - - init(uuid: String, major: UInt16, minor: UInt16, measuredPower: Int8, advInterval: UInt8, txPower: UInt8, deviceName: String? = nil) { - self.uuid = uuid - self.major = major - self.minor = minor - self.measuredPower = measuredPower - self.advInterval = advInterval - self.txPower = txPower - self.deviceName = deviceName - } -} - -/// Result of reading a beacon's current configuration -struct BeaconCheckResult { - // Parsed DX-Smart iBeacon config - var uuid: String? // iBeacon UUID (formatted with dashes) - var major: UInt16? - var minor: UInt16? - var rssiAt1m: Int8? - var advInterval: UInt16? // Raw value (multiply by 100 for ms) - var txPower: UInt8? - var deviceName: String? - var battery: UInt8? - var macAddress: String? - var frameSlots: [UInt8]? - - // Discovery info - var servicesFound: [String] = [] - var characteristicsFound: [String] = [] - var rawResponses: [String] = [] // Raw response hex for debugging - - var hasConfig: Bool { - uuid != nil || major != nil || minor != nil || deviceName != nil - } -} - -// MARK: - BeaconProvisioner - -/// Handles GATT connection and provisioning of DX-Smart CP28 beacons. -/// -/// v3: Clean refactor based on working pre-refactor code + Android reference. -/// -/// Architecture: -/// - Callback-driven state machine (CoreBluetooth delegates) -/// - Response gating: wait for FFE1 notification after each FFE2 write (matches Android) -/// - Disconnect recovery: reconnect + re-auth + resume from saved write index -/// - 16-step write sequence: DeviceName, Frame1 (device info), Frame2 (iBeacon), Save -/// -/// Key learnings preserved from previous iterations: -/// 1. Skip device info read (0x30) — causes disconnects, MAC is optional -/// 2. Skip extra frame disables (3-6) — fewer writes = fewer disconnects -/// 3. Full reconnect on FFE2 miss (CoreBluetooth caches stale GATT) -/// 4. SaveConfig write-error = success (beacon reboots immediately) -/// 5. Response gating between writes prevents MCU overload -/// 6. Adaptive delays: heavy commands (frame select/type) need 1s, others 0.5s -class BeaconProvisioner: NSObject, ObservableObject { - - // MARK: - Constants - - private static let DXSMART_SERVICE = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") - private static let DXSMART_NOTIFY_CHAR = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") // Notifications (RX) - private static let DXSMART_COMMAND_CHAR = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") // Commands (TX) - private static let DXSMART_PASSWORD_CHAR = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") // Password auth - - private static let DXSMART_HEADER: [UInt8] = [0x4E, 0x4F] - private static let DXSMART_PASSWORDS = ["555555", "dx1234", "000000"] - - private enum DXCmd: UInt8 { - case frameTable = 0x10 - case frameSelectSlot0 = 0x11 // Frame 1 (device info) - case frameSelectSlot1 = 0x12 // Frame 2 (iBeacon) - case frameSelectSlot2 = 0x13 // Frame 3 - case frameSelectSlot3 = 0x14 // Frame 4 - case frameSelectSlot4 = 0x15 // Frame 5 - case frameSelectSlot5 = 0x16 // Frame 6 - case authCheck = 0x25 - case deviceInfo = 0x30 - case deviceName = 0x43 // Read device name - case saveConfig = 0x60 - case deviceInfoType = 0x61 // Set frame as device info (broadcasts name) - case iBeaconType = 0x62 // Set frame as iBeacon - case deviceNameWrite = 0x71 // Write device name (max 20 ASCII chars) - case uuid = 0x74 - case major = 0x75 - case minor = 0x76 - case rssiAt1m = 0x77 - case advInterval = 0x78 - case txPower = 0x79 - case triggerOff = 0xA0 - case frameDisable = 0xFF - } - - // Timing constants (tuned from extensive testing) - private static let BASE_WRITE_DELAY: Double = 0.5 // Default delay between commands - private static let HEAVY_WRITE_DELAY: Double = 1.0 // After frame select/type commands (MCU state change) - private static let LARGE_PAYLOAD_DELAY: Double = 0.8 // After UUID/large payload writes - private static let RESPONSE_GATE_TIMEOUT: Double = 1.0 // 1s matches Android's withTimeoutOrNull(1000L) - private static let WRITE_TIMEOUT_SECONDS: Double = 5.0 // Per-write timeout (matches Android) - private static let GLOBAL_TIMEOUT: Double = 90.0 // Overall provisioning timeout - - // Retry limits - private static let MAX_CONNECTION_RETRIES = 3 - private static let MAX_DISCONNECT_RETRIES = 5 - private static let MAX_FFE2_RECONNECTS = 2 - private static let MAX_WRITE_RETRIES = 1 - - // MARK: - Published State - - @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) - } - - // MARK: - Core State - - private var centralManager: CBCentralManager! - private var peripheral: CBPeripheral? - private var config: BeaconConfig? - private var completion: ((ProvisioningResult) -> Void)? - private var currentBeacon: DiscoveredBeacon? - - // GATT state - private var configService: CBService? - private var characteristics: [CBUUID: CBCharacteristic] = [:] - - // Auth state - private var passwordIndex = 0 - private var authenticated = false - - // Write queue state - private var commandQueue: [Data] = [] - private var writeIndex = 0 - private var resumeAfterReconnect = false // When true, skip queue rebuild on reconnect - - // Retry counters - private var connectionRetryCount = 0 - private var disconnectRetryCount = 0 - private var ffe2ReconnectCount = 0 - private var writeRetryCount = 0 - - // Timers - private var writeTimeoutTimer: DispatchWorkItem? - private var responseGateTimer: DispatchWorkItem? - private var globalTimeoutTimer: DispatchWorkItem? - - // Response gating — wait for FFE1 notification after each FFE2 write - private var awaitingResponse = false - - // Guard against re-entrant disconnect/success/fail - private var isTerminating = false - - // Read config state - private enum OperationMode { case provisioning, readingConfig } - private var operationMode: OperationMode = .provisioning - private var readCompletion: ((BeaconCheckResult?, String?) -> Void)? - private var readResult = BeaconCheckResult() - private var readTimeout: DispatchWorkItem? - private var allDiscoveredServices: [CBService] = [] - private var servicesToExplore: [CBService] = [] - private var dxReadQueries: [Data] = [] - private var dxReadQueryIndex = 0 - private var responseBuffer: [UInt8] = [] - - // MARK: - Init - - override init() { - super.init() - centralManager = CBCentralManager(delegate: self, queue: .main) - } - - // MARK: - Public: Provision - - /// Provision a beacon with the given configuration - func provision(beacon: DiscoveredBeacon, config: BeaconConfig, completion: @escaping (ProvisioningResult) -> Void) { - guard centralManager.state == .poweredOn else { - completion(.failureWithCode(.bluetoothUnavailable)) - return - } - - resetAllState() - - let resolvedPeripheral = resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.config = config - self.completion = completion - self.operationMode = .provisioning - self.currentBeacon = beacon - - state = .connecting - progress = "Connecting to \(beacon.displayName)..." - DebugLog.shared.log("BLE: Starting provision for \(beacon.displayName)") - - centralManager.connect(resolvedPeripheral, options: nil) - scheduleGlobalTimeout() - } - - /// Cancel current provisioning - func cancel() { - if let peripheral = peripheral { - centralManager.cancelPeripheralConnection(peripheral) - } - cleanup() - } - - // MARK: - Public: Read Config - - /// Read the current configuration from a beacon - func readConfig(beacon: DiscoveredBeacon, completion: @escaping (BeaconCheckResult?, String?) -> Void) { - guard centralManager.state == .poweredOn else { - completion(nil, "Bluetooth not available") - return - } - - resetAllState() - - let resolvedPeripheral = resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.operationMode = .readingConfig - self.readCompletion = completion - self.readResult = BeaconCheckResult() - self.currentBeacon = beacon - - state = .connecting - progress = "Connecting to \(beacon.displayName)..." - - centralManager.connect(resolvedPeripheral, options: nil) - - // 15-second timeout for read operations - let timeout = DispatchWorkItem { [weak self] in - guard let self = self, self.operationMode == .readingConfig else { return } - DebugLog.shared.log("BLE: Read timeout reached") - self.finishRead() - } - readTimeout = timeout - DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timeout) - } - - // MARK: - State Reset - - private func resetAllState() { - cancelAllTimers() - characteristics.removeAll() - commandQueue.removeAll() - writeIndex = 0 - passwordIndex = 0 - authenticated = false - resumeAfterReconnect = false - awaitingResponse = false - isTerminating = false - connectionRetryCount = 0 - disconnectRetryCount = 0 - ffe2ReconnectCount = 0 - writeRetryCount = 0 - responseBuffer.removeAll() - configService = nil - } - - private func cleanup() { - cancelAllTimers() - peripheral = nil - config = nil - completion = nil - currentBeacon = nil - configService = nil - characteristics.removeAll() - commandQueue.removeAll() - writeIndex = 0 - passwordIndex = 0 - authenticated = false - resumeAfterReconnect = false - awaitingResponse = false - isTerminating = false - connectionRetryCount = 0 - disconnectRetryCount = 0 - ffe2ReconnectCount = 0 - writeRetryCount = 0 - responseBuffer.removeAll() - state = .idle - progress = "" - } - - // MARK: - Terminal States - - private func fail(_ message: String, code: ProvisioningError? = nil) { - guard !isTerminating else { - DebugLog.shared.log("BLE: fail() called but already terminating, ignoring") - return - } - isTerminating = true - DebugLog.shared.log("BLE: Failed [\(code?.rawValue ?? "UNTYPED")] - \(message)") - state = .failed(message) - disconnectPeripheral() - if let code = code { - completion?(.failureWithCode(code, detail: message)) - } else { - completion?(.failure(message)) - } - cleanup() - } - - private func succeed() { - guard !isTerminating else { - DebugLog.shared.log("BLE: succeed() called but already terminating, ignoring") - return - } - isTerminating = true - DebugLog.shared.log("BLE: Provisioning success!") - state = .success - disconnectPeripheral() - completion?(.success(macAddress: nil)) - cleanup() - } - - private func disconnectPeripheral() { - if let peripheral = peripheral, peripheral.state == .connected { - centralManager.cancelPeripheralConnection(peripheral) - } - } - - // MARK: - Peripheral Resolution - - /// Re-retrieve peripheral from our own CBCentralManager (the one from scanner may not work) - private func resolvePeripheral(_ beacon: DiscoveredBeacon) -> CBPeripheral { - let retrieved = centralManager.retrievePeripherals(withIdentifiers: [beacon.peripheral.identifier]) - return retrieved.first ?? beacon.peripheral - } - - // MARK: - DX-Smart: Authentication - - /// Start auth flow: subscribe to FFE1 notifications, then write password to FFE3 - private func startAuth() { - if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { - DebugLog.shared.log("BLE: Subscribing to FFE1 notifications") - peripheral?.setNotifyValue(true, for: notifyChar) - } else { - DebugLog.shared.log("BLE: FFE1 not found, proceeding to auth directly") - authenticate() - } - } - - /// Write password to FFE3 (tries multiple passwords in sequence) - private func authenticate() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - fail("FFE3 password characteristic not found", code: .serviceNotFound) - return - } - - guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: All passwords exhausted in read mode") - finishRead() - } else { - fail("Authentication failed - all passwords rejected", code: .authFailed) - } - return - } - - state = .authenticating - let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] - progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." - - let passwordData = Data(currentPassword.utf8) - DebugLog.shared.log("BLE: Writing password \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count) to FFE3 (\(passwordData.count) bytes)") - peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) - } - - /// Try next password after rejection - private func retryNextPassword() { - passwordIndex += 1 - if passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count { - DebugLog.shared.log("BLE: Password rejected, trying next (\(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.authenticate() - } - } else if operationMode == .readingConfig { - finishRead() - } else { - fail("Authentication failed - all \(BeaconProvisioner.DXSMART_PASSWORDS.count) passwords rejected", code: .authFailed) - } - } - - // MARK: - DX-Smart: Write Config - - /// Build the 16-step command sequence and start writing. - /// - /// Write sequence for DX-Smart CP28: - /// 1. DeviceName 0x71 [name bytes] — service point name (max 20 ASCII chars) - /// 2. Frame1_Select 0x11 — select frame 1 - /// 3. Frame1_Type 0x61 — enable as device info (broadcasts name) - /// 4. Frame1_RSSI 0x77 [measuredPower] — RSSI@1m for frame 1 - /// 5. Frame1_AdvInt 0x78 [advInterval] — adv interval for frame 1 - /// 6. Frame1_TxPow 0x79 [txPower] — tx power for frame 1 - /// 7. Frame2_Select 0x12 — select frame 2 - /// 8. Frame2_Type 0x62 — set as iBeacon - /// 9. UUID 0x74 [16 bytes] - /// 10. Major 0x75 [2 bytes BE] - /// 11. Minor 0x76 [2 bytes BE] - /// 12. RSSI@1m 0x77 [measuredPower] - /// 13. AdvInterval 0x78 [advInterval] - /// 14. TxPower 0x79 [txPower] - /// 15. TriggerOff 0xA0 - /// 16. SaveConfig 0x60 — persist to flash - /// - /// Frames 3-6 intentionally left untouched — fewer writes = fewer disconnects. - private func buildAndStartWriting() { - guard let config = config else { - fail("No config provided", code: .noConfig) - return - } - - state = .writing - progress = "Writing DX-Smart configuration..." - - commandQueue.removeAll() - writeIndex = 0 - - let measuredPowerByte = UInt8(bitPattern: config.measuredPower) - - // 1. DeviceName (0x71) - if let name = config.deviceName, !name.isEmpty { - let truncatedName = String(name.prefix(20)) - let nameBytes = Array(truncatedName.utf8) - commandQueue.append(buildDXPacket(cmd: .deviceNameWrite, data: nameBytes)) - } - - // --- Frame 1: Device Info --- - commandQueue.append(buildDXPacket(cmd: .frameSelectSlot0, data: [])) // 2. Select - commandQueue.append(buildDXPacket(cmd: .deviceInfoType, data: [])) // 3. Type - commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 4. RSSI - commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 5. AdvInt - commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 6. TxPower - - // --- Frame 2: iBeacon --- - commandQueue.append(buildDXPacket(cmd: .frameSelectSlot1, data: [])) // 7. Select - commandQueue.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 8. Type - - if let uuidData = hexStringToData(config.uuid) { // 9. UUID - commandQueue.append(buildDXPacket(cmd: .uuid, data: Array(uuidData))) - } - - let majorHi = UInt8((config.major >> 8) & 0xFF) - let majorLo = UInt8(config.major & 0xFF) - commandQueue.append(buildDXPacket(cmd: .major, data: [majorHi, majorLo])) // 10. Major - - let minorHi = UInt8((config.minor >> 8) & 0xFF) - let minorLo = UInt8(config.minor & 0xFF) - commandQueue.append(buildDXPacket(cmd: .minor, data: [minorHi, minorLo])) // 11. Minor - - commandQueue.append(buildDXPacket(cmd: .rssiAt1m, data: [measuredPowerByte])) // 12. RSSI - commandQueue.append(buildDXPacket(cmd: .advInterval, data: [config.advInterval])) // 13. AdvInt - commandQueue.append(buildDXPacket(cmd: .txPower, data: [config.txPower])) // 14. TxPower - commandQueue.append(buildDXPacket(cmd: .triggerOff, data: [])) // 15. TriggerOff - commandQueue.append(buildDXPacket(cmd: .saveConfig, data: [])) // 16. Save - - DebugLog.shared.log("BLE: Command queue built with \(commandQueue.count) commands") - sendNextCommand() - } - - /// Send the next command in the queue - private func sendNextCommand() { - guard writeIndex < commandQueue.count else { - cancelWriteTimeout() - DebugLog.shared.log("BLE: All commands written!") - progress = "Configuration saved!" - succeed() - return - } - - let packet = commandQueue[writeIndex] - let current = writeIndex + 1 - let total = commandQueue.count - progress = "Writing config (\(current)/\(total))..." - - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - // FFE2 missing — CoreBluetooth returns cached stale GATT after disconnect. - // Need full disconnect → reconnect → re-discover → re-auth → resume. - handleFFE2Missing() - return - } - - DebugLog.shared.log("BLE: Writing command \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") - writeRetryCount = 0 - scheduleWriteTimeout() - peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - - /// Handle FFE2 characteristic missing (stale GATT cache) - private func handleFFE2Missing() { - guard ffe2ReconnectCount < BeaconProvisioner.MAX_FFE2_RECONNECTS else { - fail("FFE2 not found after \(ffe2ReconnectCount) reconnect attempts", code: .serviceNotFound) - return - } - ffe2ReconnectCount += 1 - DebugLog.shared.log("BLE: FFE2 missing — full reconnect (attempt \(ffe2ReconnectCount)/\(BeaconProvisioner.MAX_FFE2_RECONNECTS))") - progress = "FFE2 missing, reconnecting..." - - // Preserve write position, clear connection state - resumeAfterReconnect = true - authenticated = false - passwordIndex = 0 - characteristics.removeAll() - responseBuffer.removeAll() - awaitingResponse = false - cancelResponseGateTimeout() - state = .connecting - - disconnectPeripheral() - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - resolvedPeripheral.delegate = self - DebugLog.shared.log("BLE: Reconnecting after FFE2 miss...") - self.centralManager.connect(resolvedPeripheral, options: nil) - } - } - - // MARK: - Response Gating - - /// After a successful write, advance with the appropriate delay. - /// Called when FFE1 response arrives or when 1s gate timeout fires. - private func advanceToNextCommand() { - let justWritten = writeIndex - writeIndex += 1 - let delay = delayForCommand(at: justWritten) - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendNextCommand() - } - } - - /// Schedule 1s response gate timeout (matches Android's withTimeoutOrNull(1000L)) - private func scheduleResponseGateTimeout() { - cancelResponseGateTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - guard self.awaitingResponse else { return } - self.awaitingResponse = false - DebugLog.shared.log("BLE: No FFE1 response within 1s for command \(self.writeIndex + 1) — advancing (OK)") - self.advanceToNextCommand() - } - responseGateTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.RESPONSE_GATE_TIMEOUT, execute: timer) - } - - private func cancelResponseGateTimeout() { - responseGateTimer?.cancel() - responseGateTimer = nil - } - - // MARK: - Write Timeout - - /// Per-write timeout — if callback doesn't arrive, retry once or handle gracefully - private func scheduleWriteTimeout() { - cancelWriteTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - guard self.state == .writing else { return } - - let current = self.writeIndex + 1 - let total = self.commandQueue.count - let isNonFatal = self.writeIndex < 6 // First 6 commands are optional - let isSaveConfig = self.writeIndex >= self.commandQueue.count - 1 - - if isSaveConfig { - // SaveConfig: beacon reboots, no callback expected - DebugLog.shared.log("BLE: SaveConfig write timeout (beacon rebooted) — treating as success") - self.succeed() - } else if self.writeRetryCount < BeaconProvisioner.MAX_WRITE_RETRIES { - // Retry once - self.writeRetryCount += 1 - DebugLog.shared.log("BLE: Write timeout for command \(current)/\(total) — retrying (\(self.writeRetryCount))") - if let commandChar = self.characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] { - let packet = self.commandQueue[self.writeIndex] - self.scheduleWriteTimeout() - self.peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - } else if isNonFatal { - // Non-fatal: skip - DebugLog.shared.log("BLE: Write timeout for non-fatal command \(current)/\(total) — skipping") - self.writeIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNextCommand() - } - } else { - // Fatal timeout - DebugLog.shared.log("BLE: Write timeout for critical command \(current)/\(total) — failing") - self.fail("Write timeout at step \(current)/\(total)", code: .writeFailed) - } - } - writeTimeoutTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.WRITE_TIMEOUT_SECONDS, execute: timer) - } - - private func cancelWriteTimeout() { - writeTimeoutTimer?.cancel() - writeTimeoutTimer = nil - } - - // MARK: - Global Timeout - - private func scheduleGlobalTimeout() { - cancelGlobalTimeout() - let timer = DispatchWorkItem { [weak self] in - guard let self = self else { return } - if self.state != .success && self.state != .idle { - self.fail("Provisioning timeout after \(Int(BeaconProvisioner.GLOBAL_TIMEOUT))s", code: .connectionTimeout) - } - } - globalTimeoutTimer = timer - DispatchQueue.main.asyncAfter(deadline: .now() + BeaconProvisioner.GLOBAL_TIMEOUT, execute: timer) - } - - private func cancelGlobalTimeout() { - globalTimeoutTimer?.cancel() - globalTimeoutTimer = nil - } - - private func cancelAllTimers() { - cancelWriteTimeout() - cancelResponseGateTimeout() - cancelGlobalTimeout() - readTimeout?.cancel() - readTimeout = nil - } - - // MARK: - Adaptive Delays - - /// Calculate delay after writing a command. Frame selection (0x11-0x16) and type - /// commands (0x61, 0x62) trigger MCU state changes that need extra processing time. - private func delayForCommand(at index: Int) -> Double { - guard index < commandQueue.count else { return BeaconProvisioner.BASE_WRITE_DELAY } - - let packet = commandQueue[index] - guard packet.count >= 3 else { return BeaconProvisioner.BASE_WRITE_DELAY } - - let cmd = packet[2] // Command byte at offset 2 (after 4E 4F header) - - switch DXCmd(rawValue: cmd) { - case .frameSelectSlot0, .frameSelectSlot1, .frameSelectSlot2, - .frameSelectSlot3, .frameSelectSlot4, .frameSelectSlot5: - return BeaconProvisioner.HEAVY_WRITE_DELAY - case .deviceInfoType, .iBeaconType: - return BeaconProvisioner.HEAVY_WRITE_DELAY - case .uuid: - return BeaconProvisioner.LARGE_PAYLOAD_DELAY - default: - return BeaconProvisioner.BASE_WRITE_DELAY - } - } - - // MARK: - DX-Smart Packet Builder - - /// Build a DX-Smart CP28 packet: [4E][4F][CMD][LEN][DATA...][XOR_CHECKSUM] - private func buildDXPacket(cmd: DXCmd, data: [UInt8]) -> Data { - var packet: [UInt8] = [] - packet.append(contentsOf: BeaconProvisioner.DXSMART_HEADER) - packet.append(cmd.rawValue) - packet.append(UInt8(data.count)) - packet.append(contentsOf: data) - - var checksum: UInt8 = cmd.rawValue ^ UInt8(data.count) - for byte in data { - checksum ^= byte - } - packet.append(checksum) - - return Data(packet) - } - - // MARK: - Read Config: Service Exploration - - private func startReadExplore() { - guard let services = peripheral?.services, !services.isEmpty else { - readFail("No services found on device") - return - } - - allDiscoveredServices = services - servicesToExplore = services - state = .discoveringServices - progress = "Exploring \(services.count) services..." - - DebugLog.shared.log("BLE: Read mode — found \(services.count) services") - for s in services { - readResult.servicesFound.append(s.uuid.uuidString) - } - - exploreNextService() - } - - private func exploreNextService() { - guard !servicesToExplore.isEmpty else { - DebugLog.shared.log("BLE: All services explored, starting DX-Smart read") - startDXSmartRead() - return - } - - let service = servicesToExplore.removeFirst() - DebugLog.shared.log("BLE: Discovering chars for service \(service.uuid)") - progress = "Exploring \(service.uuid.uuidString.prefix(8))..." - peripheral?.discoverCharacteristics(nil, for: service) - } - - // MARK: - Read Config: DX-Smart Protocol - - private func startDXSmartRead() { - guard characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] != nil, - characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] != nil else { - DebugLog.shared.log("BLE: No FFE0 service — not a DX-Smart beacon") - progress = "No DX-Smart service found" - finishRead() - return - } - - // Subscribe to FFE1 for responses - if let notifyChar = characteristics[BeaconProvisioner.DXSMART_NOTIFY_CHAR] { - DebugLog.shared.log("BLE: Read mode — subscribing to FFE1") - progress = "Subscribing to notifications..." - peripheral?.setNotifyValue(true, for: notifyChar) - } else { - DebugLog.shared.log("BLE: FFE1 not found, attempting auth without notifications") - readAuth() - } - } - - private func readAuth() { - guard let passwordChar = characteristics[BeaconProvisioner.DXSMART_PASSWORD_CHAR] else { - DebugLog.shared.log("BLE: No FFE3 for auth, finishing") - finishRead() - return - } - - guard passwordIndex < BeaconProvisioner.DXSMART_PASSWORDS.count else { - DebugLog.shared.log("BLE: All passwords exhausted in read mode") - finishRead() - return - } - - state = .authenticating - let currentPassword = BeaconProvisioner.DXSMART_PASSWORDS[passwordIndex] - progress = "Authenticating (attempt \(passwordIndex + 1)/\(BeaconProvisioner.DXSMART_PASSWORDS.count))..." - - let passwordData = Data(currentPassword.utf8) - DebugLog.shared.log("BLE: Read mode — writing password \(passwordIndex + 1) to FFE3") - peripheral?.writeValue(passwordData, for: passwordChar, type: .withResponse) - } - - private func readQueryAfterAuth() { - dxReadQueries.removeAll() - dxReadQueryIndex = 0 - responseBuffer.removeAll() - - dxReadQueries.append(buildDXPacket(cmd: .frameTable, data: [])) // 0x10 - dxReadQueries.append(buildDXPacket(cmd: .iBeaconType, data: [])) // 0x62 - dxReadQueries.append(buildDXPacket(cmd: .deviceInfo, data: [])) // 0x30 - dxReadQueries.append(buildDXPacket(cmd: .deviceName, data: [])) // 0x43 - - DebugLog.shared.log("BLE: Sending \(dxReadQueries.count) read queries") - state = .verifying - progress = "Reading config..." - sendNextReadQuery() - } - - private func sendNextReadQuery() { - guard dxReadQueryIndex < dxReadQueries.count else { - DebugLog.shared.log("BLE: All read queries sent, waiting 2s for final responses") - progress = "Collecting responses..." - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self = self, self.operationMode == .readingConfig else { return } - self.finishRead() - } - return - } - - guard let commandChar = characteristics[BeaconProvisioner.DXSMART_COMMAND_CHAR] else { - DebugLog.shared.log("BLE: FFE2 not found, finishing read") - finishRead() - return - } - - let packet = dxReadQueries[dxReadQueryIndex] - let current = dxReadQueryIndex + 1 - let total = dxReadQueries.count - progress = "Reading \(current)/\(total)..." - DebugLog.shared.log("BLE: Read query \(current)/\(total): \(packet.map { String(format: "%02X", $0) }.joined(separator: " "))") - peripheral?.writeValue(packet, for: commandChar, type: .withResponse) - } - - // MARK: - Read Config: Response Parsing - - private func processFFE1Response(_ data: Data) { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: FFE1 raw: \(hex)") - - responseBuffer.append(contentsOf: data) - - while responseBuffer.count >= 5 { - guard let headerIdx = findDXHeader() else { - responseBuffer.removeAll() - break - } - - if headerIdx > 0 { - responseBuffer.removeFirst(headerIdx) - } - - guard responseBuffer.count >= 5 else { break } - - let cmd = responseBuffer[2] - let len = Int(responseBuffer[3]) - let frameLen = 4 + len + 1 - - guard responseBuffer.count >= frameLen else { break } - - let frame = Array(responseBuffer[0.. Int? { - guard responseBuffer.count >= 2 else { return nil } - for i in 0..<(responseBuffer.count - 1) { - if responseBuffer[i] == 0x4E && responseBuffer[i + 1] == 0x4F { - return i - } - } - return nil - } - - private func parseResponseCmd(cmd: UInt8, data: [UInt8]) { - let dataHex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: Response cmd=0x\(String(format: "%02X", cmd)) len=\(data.count) data=[\(dataHex)]") - readResult.rawResponses.append("0x\(String(format: "%02X", cmd)): \(dataHex)") - - switch DXCmd(rawValue: cmd) { - - case .frameTable: - readResult.frameSlots = data - DebugLog.shared.log("BLE: Frame slots: \(data.map { String(format: "0x%02X", $0) })") - - case .iBeaconType: - guard data.count >= 2 else { return } - var offset = 1 // Skip type echo byte - - if data.count >= offset + 16 { - let uuidBytes = Array(data[offset..<(offset + 16)]) - let uuidHex = uuidBytes.map { String(format: "%02X", $0) }.joined() - readResult.uuid = formatUUID(uuidHex) - offset += 16 - } - if data.count >= offset + 2 { - readResult.major = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) - offset += 2 - } - if data.count >= offset + 2 { - readResult.minor = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) - offset += 2 - } - if data.count >= offset + 1 { - readResult.rssiAt1m = Int8(bitPattern: data[offset]) - offset += 1 - } - if data.count >= offset + 1 { - readResult.advInterval = UInt16(data[offset]) - offset += 1 - } - if data.count >= offset + 1 { - readResult.txPower = data[offset] - offset += 1 - } - DebugLog.shared.log("BLE: Parsed iBeacon — UUID=\(readResult.uuid ?? "?") Major=\(readResult.major ?? 0) Minor=\(readResult.minor ?? 0)") - - case .deviceInfo: - if data.count >= 1 { - readResult.battery = data[0] - } - if data.count >= 7 { - let macBytes = Array(data[1..<7]) - readResult.macAddress = macBytes.map { String(format: "%02X", $0) }.joined(separator: ":") - } - DebugLog.shared.log("BLE: Device info — battery=\(readResult.battery ?? 0)% MAC=\(readResult.macAddress ?? "?")") - - case .deviceName: - readResult.deviceName = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) - DebugLog.shared.log("BLE: Device name = \(readResult.deviceName ?? "?")") - - case .authCheck: - if data.count >= 1 { - let authRequired = data[0] != 0x00 - DebugLog.shared.log("BLE: Auth required: \(authRequired)") - } - - default: - DebugLog.shared.log("BLE: Unhandled response cmd 0x\(String(format: "%02X", cmd))") - } - } - - // MARK: - Read Config: Finish - - private func finishRead() { - readTimeout?.cancel() - readTimeout = nil - - disconnectPeripheral() - - let result = readResult - state = .success - progress = "" - readCompletion?(result, nil) - cleanupRead() - } - - private func readFail(_ message: String) { - DebugLog.shared.log("BLE: Read failed - \(message)") - readTimeout?.cancel() - readTimeout = nil - - disconnectPeripheral() - state = .failed(message) - readCompletion?(nil, message) - cleanupRead() - } - - private func cleanupRead() { - peripheral = nil - readCompletion = nil - readResult = BeaconCheckResult() - readTimeout = nil - dxReadQueries.removeAll() - dxReadQueryIndex = 0 - responseBuffer.removeAll() - allDiscoveredServices.removeAll() - servicesToExplore.removeAll() - configService = nil - characteristics.removeAll() - connectionRetryCount = 0 - disconnectRetryCount = 0 - currentBeacon = nil - operationMode = .provisioning - state = .idle - progress = "" - } - - // MARK: - Helpers - - private func hexStringToData(_ hex: String) -> Data? { - let clean = hex.normalizedUUID - 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.. String { - let clean = hex.uppercased() - guard clean.count == 32 else { return hex } - let c = Array(clean) - return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" - } -} - -// MARK: - CBCentralManagerDelegate - -extension BeaconProvisioner: CBCentralManagerDelegate { - - func centralManagerDidUpdateState(_ central: CBCentralManager) { - DebugLog.shared.log("BLE: Central state = \(central.state.rawValue)") - } - - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - DebugLog.shared.log("BLE: Connected to \(peripheral.name ?? "unknown")") - peripheral.delegate = self - - let maxWriteLen = peripheral.maximumWriteValueLength(for: .withResponse) - DebugLog.shared.log("BLE: Max write length: \(maxWriteLen) bytes") - if maxWriteLen < 21 { - DebugLog.shared.log("BLE: WARNING — max write length \(maxWriteLen) may be too small for UUID packet (21 bytes)") - } - - state = .discoveringServices - progress = "Discovering services..." - - if operationMode == .readingConfig { - peripheral.discoverServices(nil) - } else { - peripheral.discoverServices([BeaconProvisioner.DXSMART_SERVICE]) - } - } - - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - let errorMsg = error?.localizedDescription ?? "unknown error" - DebugLog.shared.log("BLE: Connection failed (attempt \(connectionRetryCount + 1)): \(errorMsg)") - - if connectionRetryCount < BeaconProvisioner.MAX_CONNECTION_RETRIES { - connectionRetryCount += 1 - let delay = Double(connectionRetryCount) - progress = "Connection failed, retrying (\(connectionRetryCount)/\(BeaconProvisioner.MAX_CONNECTION_RETRIES))..." - DebugLog.shared.log("BLE: Retrying in \(delay)s...") - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - self.centralManager.connect(resolvedPeripheral, options: nil) - } - } else { - let msg = "Failed to connect after \(BeaconProvisioner.MAX_CONNECTION_RETRIES) attempts: \(errorMsg)" - if operationMode == .readingConfig { - readFail(msg) - } else { - fail(msg, code: .connectionFailed) - } - } - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - DebugLog.shared.log("BLE: Disconnected | state=\(state) mode=\(operationMode) writeIdx=\(writeIndex)/\(commandQueue.count) terminating=\(isTerminating) error=\(error?.localizedDescription ?? "none")") - - // Already terminating (succeed/fail called) — expected cleanup disconnect - if isTerminating { - DebugLog.shared.log("BLE: Disconnect during termination, ignoring") - return - } - - // Read mode - if operationMode == .readingConfig { - if state != .success && state != .idle { - finishRead() - } - return - } - - // Terminal states - if state == .success || state == .idle { - return - } - if case .failed = state { - DebugLog.shared.log("BLE: Disconnect after failure, ignoring") - return - } - - // Intentional disconnect for FFE2 reconnect — already handling - if state == .connecting && resumeAfterReconnect { - DebugLog.shared.log("BLE: Intentional disconnect for FFE2 reconnect, ignoring") - return - } - - // SaveConfig was the last command — beacon rebooted to apply - if state == .writing && commandQueue.count > 0 && writeIndex >= commandQueue.count - 1 { - DebugLog.shared.log("BLE: Disconnect after SaveConfig (idx=\(writeIndex)/\(commandQueue.count)) — treating as success") - succeed() - return - } - - // Cancel pending timers - cancelWriteTimeout() - cancelResponseGateTimeout() - awaitingResponse = false - - // Unexpected disconnect during active phase — retry with reconnect - let isActivePhase = (state == .discoveringServices || state == .authenticating || state == .writing || state == .verifying) - if isActivePhase && disconnectRetryCount < BeaconProvisioner.MAX_DISCONNECT_RETRIES { - disconnectRetryCount += 1 - let wasWriting = (state == .writing && !commandQueue.isEmpty) - DebugLog.shared.log("BLE: Disconnect during \(state) — reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES)) wasWriting=\(wasWriting) writeIdx=\(writeIndex)") - progress = "Beacon disconnected, reconnecting (\(disconnectRetryCount)/\(BeaconProvisioner.MAX_DISCONNECT_RETRIES))..." - - // Reset connection state, preserve write position if writing - authenticated = false - passwordIndex = 0 - characteristics.removeAll() - responseBuffer.removeAll() - - if wasWriting { - resumeAfterReconnect = true - DebugLog.shared.log("BLE: Will resume from command \(writeIndex + 1)/\(commandQueue.count) after reconnect") - } else { - commandQueue.removeAll() - writeIndex = 0 - resumeAfterReconnect = false - } - state = .connecting - - let delay = Double(disconnectRetryCount) + 2.0 // 3s, 4s, 5s... backoff - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self, let beacon = self.currentBeacon else { return } - guard self.state == .connecting else { return } - let resolvedPeripheral = self.resolvePeripheral(beacon) - self.peripheral = resolvedPeripheral - resolvedPeripheral.delegate = self - self.centralManager.connect(resolvedPeripheral, options: nil) - } - return - } - - // All retries exhausted - DebugLog.shared.log("BLE: UNEXPECTED disconnect — all retries exhausted") - fail("Beacon disconnected \(disconnectRetryCount + 1) times during \(state). Move closer to the beacon and try again.", code: .disconnected) - } -} - -// MARK: - CBPeripheralDelegate - -extension BeaconProvisioner: CBPeripheralDelegate { - - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error = error { - if operationMode == .readingConfig { - readFail("Service discovery failed: \(error.localizedDescription)") - } else { - fail("Service discovery failed: \(error.localizedDescription)", code: .serviceNotFound) - } - return - } - - guard let services = peripheral.services else { - if operationMode == .readingConfig { - readFail("No services found") - } else { - fail("No services found", code: .serviceNotFound) - } - return - } - - DebugLog.shared.log("BLE: Discovered \(services.count) services") - for service in services { - NSLog(" Service: \(service.uuid)") - } - - if operationMode == .readingConfig { - startReadExplore() - return - } - - // Provisioning: look for DX-Smart service - for service in services { - if service.uuid == BeaconProvisioner.DXSMART_SERVICE { - configService = service - state = .discoveringServices - progress = "Discovering characteristics..." - peripheral.discoverCharacteristics([ - BeaconProvisioner.DXSMART_NOTIFY_CHAR, - BeaconProvisioner.DXSMART_COMMAND_CHAR, - BeaconProvisioner.DXSMART_PASSWORD_CHAR - ], for: service) - return - } - } - fail("Config service not found on device", code: .serviceNotFound) - } - - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - if let error = error { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Char discovery failed for \(service.uuid): \(error.localizedDescription)") - exploreNextService() - } else { - fail("Characteristic discovery failed: \(error.localizedDescription)", code: .serviceNotFound) - } - return - } - - guard let chars = service.characteristics else { - if operationMode == .readingConfig { - exploreNextService() - } else { - fail("No characteristics found", code: .serviceNotFound) - } - return - } - - DebugLog.shared.log("BLE: Discovered \(chars.count) characteristics for \(service.uuid)") - for char in chars { - let props = char.properties - let propStr = [ - props.contains(.read) ? "R" : "", - props.contains(.write) ? "W" : "", - props.contains(.writeWithoutResponse) ? "Wn" : "", - props.contains(.notify) ? "N" : "", - props.contains(.indicate) ? "I" : "" - ].filter { !$0.isEmpty }.joined(separator: ",") - NSLog(" Char: \(char.uuid) [\(propStr)]") - characteristics[char.uuid] = char - - if operationMode == .readingConfig { - readResult.characteristicsFound.append("\(char.uuid.uuidString)[\(propStr)]") - } - } - - if operationMode == .readingConfig { - exploreNextService() - } else { - // Provisioning: start auth flow - startAuth() - } - } - - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - cancelWriteTimeout() - - if let error = error { - DebugLog.shared.log("BLE: Write failed for \(characteristic.uuid): \(error.localizedDescription)") - - // Password rejected - if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { - if passwordIndex + 1 < BeaconProvisioner.DXSMART_PASSWORDS.count { - retryNextPassword() - } else if operationMode == .readingConfig { - readFail("Authentication failed - all passwords rejected") - } else { - fail("Authentication failed - all passwords rejected", code: .authFailed) - } - return - } - - // Command write failed - if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Read query failed, skipping") - dxReadQueryIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.sendNextReadQuery() - } - } else { - let isNonFatal = writeIndex < 6 - let isSaveConfig = writeIndex >= commandQueue.count - 1 - - if isSaveConfig { - // SaveConfig write error = beacon rebooted = success - DebugLog.shared.log("BLE: SaveConfig write error (beacon rebooted) — treating as success") - succeed() - } else if isNonFatal { - DebugLog.shared.log("BLE: Non-fatal command failed at step \(writeIndex + 1), continuing...") - writeIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.sendNextCommand() - } - } else { - fail("Command write failed at step \(writeIndex + 1)/\(commandQueue.count): \(error.localizedDescription)", code: .writeFailed) - } - } - return - } - - if operationMode == .readingConfig { - DebugLog.shared.log("BLE: Write failed in read mode, ignoring") - return - } - fail("Write failed: \(error.localizedDescription)", code: .writeFailed) - return - } - - DebugLog.shared.log("BLE: Write succeeded for \(characteristic.uuid)") - - // Password auth succeeded - if characteristic.uuid == BeaconProvisioner.DXSMART_PASSWORD_CHAR { - DebugLog.shared.log("BLE: Authenticated!") - authenticated = true - if operationMode == .readingConfig { - readQueryAfterAuth() - } else if resumeAfterReconnect { - // Resume writing from saved position - resumeAfterReconnect = false - state = .writing - DebugLog.shared.log("BLE: Resuming write from command \(writeIndex + 1)/\(commandQueue.count)") - progress = "Resuming config write..." - // 1.5s delay after reconnect for BLE stability - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.sendNextCommand() - } - } else { - // Fresh provisioning: build queue and start writing - DebugLog.shared.log("BLE: Auth complete, proceeding to config write") - buildAndStartWriting() - } - return - } - - // Command write succeeded - if characteristic.uuid == BeaconProvisioner.DXSMART_COMMAND_CHAR { - if operationMode == .readingConfig { - dxReadQueryIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.sendNextReadQuery() - } - } else { - // Gate on FFE1 response before next command - awaitingResponse = true - scheduleResponseGateTimeout() - } - return - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - DebugLog.shared.log("BLE: Notification state failed for \(characteristic.uuid): \(error.localizedDescription)") - } else { - DebugLog.shared.log("BLE: Notifications \(characteristic.isNotifying ? "enabled" : "disabled") for \(characteristic.uuid)") - } - - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - if operationMode == .readingConfig { - readAuth() - } else { - authenticate() - } - } - } - - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if let error = error { - DebugLog.shared.log("BLE: Read error for \(characteristic.uuid): \(error.localizedDescription)") - return - } - - let data = characteristic.value ?? Data() - - if operationMode == .readingConfig { - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - processFFE1Response(data) - } else { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: Read \(characteristic.uuid): \(hex)") - } - } else { - // Provisioning: FFE1 notification = beacon response to our command - if characteristic.uuid == BeaconProvisioner.DXSMART_NOTIFY_CHAR { - let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ") - DebugLog.shared.log("BLE: FFE1 notification: \(hex)") - - if awaitingResponse { - awaitingResponse = false - cancelResponseGateTimeout() - - // Check for rejection: 4E 4F 00 = command rejected - let bytes = [UInt8](data) - if bytes.count >= 3 && bytes[0] == 0x4E && bytes[1] == 0x4F && bytes[2] == 0x00 { - let isNonFatal = writeIndex < 6 - if isNonFatal { - DebugLog.shared.log("BLE: Command \(writeIndex + 1) rejected by beacon (non-fatal, continuing)") - } else { - DebugLog.shared.log("BLE: Command \(writeIndex + 1) REJECTED by beacon") - } - } - - advanceToNextCommand() - } - } - } - } -} diff --git a/PayfritBeacon/BeaconScanner.swift b/PayfritBeacon/BeaconScanner.swift deleted file mode 100644 index 83ac47c..0000000 --- a/PayfritBeacon/BeaconScanner.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation -import CoreLocation - -/// Native beacon scanner using CoreLocation for iBeacon detection. -/// Based on the proven BeaconManager from payfrit-user-ios. -class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate { - - private static let TAG = "BeaconScanner" - private static let MIN_RSSI: Int = -90 - - @Published var isScanning = false - @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined - - private var locationManager: CLLocationManager! - private var activeRegions: [CLBeaconRegion] = [] - // Key: "UUID|Major|Minor", Value: beacon sample data - private var beaconSamples: [String: BeaconSampleData] = [:] - - private struct BeaconSampleData { - let uuid: String - let major: UInt16 - let minor: UInt16 - var rssiSamples: [Int] - } - - override init() { - super.init() - if Thread.isMainThread { - setupLocationManager() - } else { - DispatchQueue.main.sync { - setupLocationManager() - } - } - } - - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager.delegate = self - authorizationStatus = locationManager.authorizationStatus - } - - func requestPermission() { - locationManager.requestWhenInUseAuthorization() - } - - func hasPermissions() -> Bool { - let status = locationManager.authorizationStatus - return status == .authorizedWhenInUse || status == .authorizedAlways - } - - /// Start ranging for the given UUIDs. Call stopAndCollect() after your desired duration. - func startRanging(uuids: [UUID]) { - NSLog("\(BeaconScanner.TAG): startRanging called with \(uuids.count) UUIDs") - - stopRanging() - beaconSamples.removeAll() - - guard !uuids.isEmpty else { - NSLog("\(BeaconScanner.TAG): No target UUIDs provided") - return - } - - isScanning = true - - for uuid in uuids { - let constraint = CLBeaconIdentityConstraint(uuid: uuid) - let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString) - activeRegions.append(region) - - NSLog("\(BeaconScanner.TAG): Starting ranging for UUID: \(uuid.uuidString)") - locationManager.startRangingBeacons(satisfying: constraint) - } - - NSLog("\(BeaconScanner.TAG): Ranging started for \(activeRegions.count) regions") - } - - /// Stop ranging and return collected results sorted by signal strength. - func stopAndCollect() -> [DetectedBeacon] { - NSLog("\(BeaconScanner.TAG): stopAndCollect - beaconSamples has \(beaconSamples.count) entries") - - stopRanging() - - var results: [DetectedBeacon] = [] - for (_, data) in beaconSamples { - let avgRssi = data.rssiSamples.reduce(0, +) / max(data.rssiSamples.count, 1) - NSLog("\(BeaconScanner.TAG): Beacon \(data.uuid) major=\(data.major) minor=\(data.minor) - avgRssi=\(avgRssi), samples=\(data.rssiSamples.count)") - results.append(DetectedBeacon( - uuid: data.uuid, - major: data.major, - minor: data.minor, - rssi: avgRssi, - samples: data.rssiSamples.count - )) - } - - results.sort { $0.rssi > $1.rssi } - return results - } - - private func stopRanging() { - for region in activeRegions { - let constraint = CLBeaconIdentityConstraint(uuid: region.uuid) - locationManager.stopRangingBeacons(satisfying: constraint) - } - activeRegions.removeAll() - isScanning = false - } - - // MARK: - CLLocationManagerDelegate - - func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - NSLog("\(BeaconScanner.TAG): Authorization changed to \(status.rawValue)") - authorizationStatus = status - } - - func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], - satisfying constraint: CLBeaconIdentityConstraint) { - for beacon in beacons { - let rssiValue = beacon.rssi - guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue } - - let uuid = beacon.uuid.uuidString.normalizedUUID - let major = beacon.major.uint16Value - let minor = beacon.minor.uint16Value - let key = "\(uuid)|\(major)|\(minor)" - - if beaconSamples[key] == nil { - beaconSamples[key] = BeaconSampleData(uuid: uuid, major: major, minor: minor, rssiSamples: []) - } - beaconSamples[key]?.rssiSamples.append(rssiValue) - } - } - - func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { - NSLog("\(BeaconScanner.TAG): Ranging FAILED for \(constraint.uuid): \(error.localizedDescription)") - } -} - -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 - return "UUID: \(uuid.uuidWithDashes)\nMajor: \(major)\nMinor: \(minor)" - } -} diff --git a/PayfritBeacon/BeaconShardPool.swift b/PayfritBeacon/BeaconShardPool.swift deleted file mode 100644 index dc0cf0a..0000000 --- a/PayfritBeacon/BeaconShardPool.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import CoreLocation - -/// Static list of Payfrit shard UUIDs for iBeacon scanning. -/// -/// Apps scan for ALL these UUIDs. The backend handles resolution. -/// If we need more capacity, ship more UUIDs in a future app version. -enum BeaconShardPool { - - /// All Payfrit shard UUIDs as strings (from BeaconShards database table). - static let uuidStrings: [String] = [ - "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. - static let uuids: [UUID] = uuidStrings.compactMap { UUID(uuidString: $0) } - - /// Check if a UUID is a Payfrit shard. - static func isPayfrit(_ uuid: UUID) -> Bool { - return uuids.contains(uuid) - } - - /// Check if a UUID string is a Payfrit shard. - static func isPayfrit(_ uuidString: String) -> Bool { - guard let uuid = UUID(uuidString: uuidString) else { return false } - return isPayfrit(uuid) - } - - /// Create CLBeaconRegions for all Payfrit shards (for ranging). - static func createRegions() -> [CLBeaconRegion] { - return uuids.enumerated().map { index, uuid in - CLBeaconRegion(uuid: uuid, identifier: "payfrit-shard-\(index)") - } - } - - /// Create a CLBeaconRegion for a specific business (for background monitoring). - static func createBusinessRegion(uuid: UUID, major: UInt16) -> CLBeaconRegion { - return CLBeaconRegion( - uuid: uuid, - major: CLBeaconMajorValue(major), - identifier: "payfrit-business-\(major)" - ) - } -} diff --git a/PayfritBeacon/BusinessListView.swift b/PayfritBeacon/BusinessListView.swift deleted file mode 100644 index b88cd4b..0000000 --- a/PayfritBeacon/BusinessListView.swift +++ /dev/null @@ -1,144 +0,0 @@ -import SwiftUI -import Kingfisher - -struct BusinessListView: View { - @Binding var hasAutoSelected: Bool - var onBusinessSelected: (Business) -> Void - var onLogout: () -> Void - - @State private var businesses: [Business] = [] - @State private var isLoading = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - VStack { - if isLoading { - Spacer() - ProgressView("Loading businesses...") - Spacer() - } else if let error = errorMessage { - Spacer() - Text(error) - .foregroundColor(.errorRed) - .multilineTextAlignment(.center) - .padding() - addBusinessButton - Spacer() - } else if businesses.isEmpty { - Spacer() - Text("No businesses found") - .foregroundColor(.secondary) - addBusinessButton - Spacer() - } else { - List(businesses) { business in - Button { - onBusinessSelected(business) - } label: { - businessRow(business) - } - .buttonStyle(.plain) - } - .listStyle(.plain) - - addBusinessButton - } - } - .navigationTitle("Select Business") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Log Out") { - logout() - } - } - } - } - .onAppear { - loadBusinesses() - } - } - - private func businessRow(_ business: Business) -> some View { - HStack(spacing: 12) { - if let ext = business.headerImageExtension, !ext.isEmpty { - 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 { - Image(systemName: "building.2") - .font(.title2) - .foregroundColor(.secondary) - } - .scaledToFill() - .frame(width: 48, height: 48) - .clipShape(Circle()) - } else { - Image(systemName: "building.2") - .font(.title2) - .foregroundColor(.secondary) - .frame(width: 48, height: 48) - } - - VStack(alignment: .leading, spacing: 2) { - Text(business.name) - .font(.body.weight(.medium)) - .foregroundColor(.primary) - Text("Tap to configure beacons") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - .font(.caption) - } - .padding(.vertical, 4) - } - - private var addBusinessButton: some View { - Button { - 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: { - Text("Add Business via Portal") - .font(.callout) - .foregroundColor(.payfritGreen) - } - .padding() - } - - private func loadBusinesses() { - isLoading = true - errorMessage = nil - - Task { - do { - let result = try await Api.shared.listBusinesses() - businesses = result - isLoading = false - - if businesses.count == 1 && !hasAutoSelected { - hasAutoSelected = true - onBusinessSelected(businesses[0]) - } - } catch { - isLoading = false - errorMessage = error.localizedDescription - } - } - } - - private func logout() { - UserDefaults.standard.removeObject(forKey: "token") - UserDefaults.standard.removeObject(forKey: "userId") - UserDefaults.standard.removeObject(forKey: "firstName") - Api.shared.setAuthToken(nil) - onLogout() - } -} diff --git a/PayfritBeacon/DebugLog.swift b/PayfritBeacon/DebugLog.swift deleted file mode 100644 index 9725262..0000000 --- a/PayfritBeacon/DebugLog.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Simple in-app debug log — viewable from ScanView -class DebugLog: ObservableObject { - static let shared = DebugLog() - - @Published var entries: [String] = [] - - private let maxEntries = 200 - - func log(_ message: String) { - let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) - let entry = "[\(ts)] \(message)" - NSLog("[DebugLog] \(message)") - DispatchQueue.main.async { - self.entries.append(entry) - if self.entries.count > self.maxEntries { - self.entries.removeFirst(self.entries.count - self.maxEntries) - } - } - } - - func clear() { - DispatchQueue.main.async { - self.entries.removeAll() - } - } - - /// Get all entries as a single string for clipboard - var allText: String { - entries.joined(separator: "\n") - } -} diff --git a/PayfritBeacon/DevBanner.swift b/PayfritBeacon/DevBanner.swift deleted file mode 100644 index 4ca5245..0000000 --- a/PayfritBeacon/DevBanner.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -struct DevRibbon: View { - var body: some View { - if Api.IS_DEV { - GeometryReader { geo in - ZStack { - Text("DEV") - .font(.system(size: 11, weight: .bold)) - .tracking(2) - .foregroundColor(.white) - .frame(width: 140, height: 22) - .background(Color(red: 255/255, green: 152/255, blue: 0/255)) - .rotationEffect(.degrees(45)) - .position(x: 35, y: geo.size.height - 35) - } - } - .allowsHitTesting(false) - } - } -} - -struct DevBanner: ViewModifier { - func body(content: Content) -> some View { - content.overlay { - DevRibbon() - } - } -} diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist deleted file mode 100644 index 544965a..0000000 --- a/PayfritBeacon/Info.plist +++ /dev/null @@ -1,51 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(APP_DISPLAY_NAME) - CFBundleDisplayName - $(APP_DISPLAY_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSBluetoothAlwaysUsageDescription - Payfrit Beacon uses Bluetooth to discover and configure nearby beacons. - NSLocationWhenInUseUsageDescription - Payfrit Beacon uses your location to detect nearby iBeacons and verify beacon ownership. - NSFaceIDUsageDescription - Payfrit Beacon uses Face ID for quick sign-in. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UILaunchScreen - - - diff --git a/PayfritBeacon/LoginView.swift b/PayfritBeacon/LoginView.swift deleted file mode 100644 index 1e41bf4..0000000 --- a/PayfritBeacon/LoginView.swift +++ /dev/null @@ -1,236 +0,0 @@ -import SwiftUI -import LocalAuthentication -import SVGKit - -struct LoginView: View { - var onLoginSuccess: (String, Int) -> Void - - @State private var otpUuid: String? - @State private var phone = "" - @State private var otp = "" - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showPhoneInput = false - @State private var showOtpInput = false - @State private var hasCheckedAuth = false - - var body: some View { - ScrollView { - VStack(spacing: 24) { - Spacer(minLength: 80) - - // SVG Logo - SVGLogoView(width: 200) - .frame(width: 200, height: 117) - - Text("Payfrit Beacon") - .font(.title2.bold()) - .foregroundColor(.payfritGreen) - - if isLoading { - ProgressView() - .padding() - } - - if let error = errorMessage { - Text(error) - .foregroundColor(.errorRed) - .font(.callout) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - - if showPhoneInput { - VStack(spacing: 12) { - TextField("Phone number", text: $phone) - .keyboardType(.phonePad) - .textContentType(.telephoneNumber) - .foregroundColor(.primary) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: sendOtp) { - Text("Send Code") - .frame(maxWidth: .infinity) - .padding() - .background(Color.payfritGreen) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) - .padding(.horizontal) - } - } - - if showOtpInput { - VStack(spacing: 12) { - Text("Please enter the 6-digit code sent to your phone") - .font(.callout) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - TextField("Verification code", text: $otp) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .foregroundColor(.primary) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - .padding(.horizontal) - - Button(action: verifyOtp) { - Text("Verify") - .frame(maxWidth: .infinity) - .padding() - .background(Color.payfritGreen) - .foregroundColor(.white) - .cornerRadius(8) - } - .disabled(isLoading) - .padding(.horizontal) - } - } - - Spacer(minLength: 40) - } - .frame(maxWidth: .infinity) - } - .onAppear { - guard !hasCheckedAuth else { return } - hasCheckedAuth = true - checkSavedAuth() - } - } - - private func checkSavedAuth() { - let defaults = UserDefaults.standard - let savedToken = defaults.string(forKey: "token") - let savedUserId = defaults.integer(forKey: "userId") - - if let token = savedToken, !token.isEmpty, savedUserId > 0 { - if canUseBiometrics() { - showBiometricPrompt(token: token, userId: savedUserId) - } else { - loginSuccess(token: token, userId: savedUserId) - } - } else { - showPhoneInput = true - } - } - - private func canUseBiometrics() -> Bool { - let context = LAContext() - var error: NSError? - return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - } - - private func showBiometricPrompt(token: String, userId: Int) { - let context = LAContext() - context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Sign in to Payfrit Beacon") { success, _ in - DispatchQueue.main.async { - if success { - loginSuccess(token: token, userId: userId) - } else { - showPhoneInput = true - } - } - } - } - - private func sendOtp() { - let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 10 else { - errorMessage = "Enter a valid phone number" - return - } - - isLoading = true - errorMessage = nil - - Task { - do { - let response = try await Api.shared.sendLoginOtp(phone: trimmed) - otpUuid = response.uuid - - showPhoneInput = false - showOtpInput = true - isLoading = false - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - - private func verifyOtp() { - guard let uuid = otpUuid else { return } - let trimmed = otp.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count >= 4 else { - errorMessage = "Enter the verification code" - return - } - - isLoading = true - errorMessage = nil - - Task { - do { - let response = try await Api.shared.verifyLoginOtp(uuid: uuid, otp: trimmed) - - let defaults = UserDefaults.standard - defaults.set(response.token, forKey: "token") - defaults.set(response.userId, forKey: "userId") - defaults.set(response.userFirstName, forKey: "firstName") - - loginSuccess(token: response.token, userId: response.userId) - } catch { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - - private func loginSuccess(token: String, userId: Int) { - Api.shared.setAuthToken(token) - onLoginSuccess(token, userId) - } -} - -// MARK: - SVG Logo UIKit wrapper - -struct SVGLogoView: UIViewRepresentable { - var width: CGFloat = 200 - - func makeUIView(context: Context) -> UIView { - let container = UIView() - container.clipsToBounds = true - - let svgPath = Bundle.main.path(forResource: "payfrit-favicon-light-outlines", ofType: "svg") ?? "" - guard let svgImage = SVGKImage(contentsOfFile: svgPath) else { return container } - - // Scale SVG proportionally based on width - let nativeW = svgImage.size.width - let nativeH = svgImage.size.height - let scale = width / nativeW - svgImage.size = CGSize(width: width, height: nativeH * scale) - - let imageView = SVGKLayeredImageView(svgkImage: svgImage) ?? UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(imageView) - - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: svgImage.size.width), - imageView.heightAnchor.constraint(equalToConstant: svgImage.size.height), - imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor), - imageView.centerYAnchor.constraint(equalTo: container.centerYAnchor), - ]) - - return container - } - - func updateUIView(_ uiView: UIView, context: Context) {} -} diff --git a/PayfritBeacon/Models/BeaconConfig.swift b/PayfritBeacon/Models/BeaconConfig.swift new file mode 100644 index 0000000..8332828 --- /dev/null +++ b/PayfritBeacon/Models/BeaconConfig.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Configuration to write to a physical beacon +struct BeaconConfig { + let uuid: String // 32 hex chars, no dashes + let major: UInt16 + let minor: UInt16 + let measuredPower: Int8 // RSSI @ 1m, typically -100 + let advInterval: UInt16 // in units of 100ms (2 = 200ms) + let txPower: UInt8 // 0-7 + let servicePointName: String + let businessName: String + + /// UUID formatted with dashes (8-4-4-4-12) + var formattedUUID: String { uuid.uuidWithDashes } + + static let defaultMeasuredPower: Int8 = -100 + static let defaultAdvInterval: UInt16 = 2 + static let defaultTxPower: UInt8 = 1 +} + +/// Result from provisioning a beacon +struct ProvisionResult { + let success: Bool + let message: String + let config: BeaconConfig? +} + +/// Result from verifying a beacon broadcast +struct VerifyResult { + let found: Bool + let rssi: Int? + let message: String +} diff --git a/PayfritBeacon/Models/BeaconType.swift b/PayfritBeacon/Models/BeaconType.swift new file mode 100644 index 0000000..8c0bf4c --- /dev/null +++ b/PayfritBeacon/Models/BeaconType.swift @@ -0,0 +1,33 @@ +import Foundation +import CoreBluetooth + +/// Supported beacon hardware types +enum BeaconType: String, CaseIterable { + case kbeacon = "KBeacon" + case dxsmart = "DX-Smart" + case bluecharm = "BlueCharm" + case unknown = "Unknown" +} + +/// A BLE beacon discovered during scanning +struct DiscoveredBeacon: Identifiable { + let id: UUID // CBPeripheral identifier + let peripheral: CBPeripheral + let name: String + let type: BeaconType + var rssi: Int + var lastSeen: Date + + var displayName: String { + name.isEmpty ? "\(id.uuidString.prefix(8))…" : name + } + + var signalDescription: String { + switch rssi { + case -50...0: return "Excellent" + case -65 ... -51: return "Good" + case -80 ... -66: return "Fair" + default: return "Weak" + } + } +} diff --git a/PayfritBeacon/Models/Business.swift b/PayfritBeacon/Models/Business.swift new file mode 100644 index 0000000..9988954 --- /dev/null +++ b/PayfritBeacon/Models/Business.swift @@ -0,0 +1,18 @@ +import Foundation + +struct Business: Identifiable, Codable, Hashable { + let id: String + let name: String + let imageExtension: String? + + enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "BusinessName" + case imageExtension = "ImageExtension" + } + + var headerImageURL: URL? { + guard let ext = imageExtension, !ext.isEmpty else { return nil } + return URL(string: "\(APIConfig.imageBaseURL)/businesses/\(id)/header.\(ext)") + } +} diff --git a/PayfritBeacon/Models/ServicePoint.swift b/PayfritBeacon/Models/ServicePoint.swift new file mode 100644 index 0000000..e9a5291 --- /dev/null +++ b/PayfritBeacon/Models/ServicePoint.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ServicePoint: Identifiable, Codable, Hashable { + let id: String + let name: String + let businessId: String + + enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "Name" + case businessId = "BusinessID" + } +} diff --git a/PayfritBeacon/PayfritBeaconApp.swift b/PayfritBeacon/PayfritBeaconApp.swift deleted file mode 100644 index e1e0736..0000000 --- a/PayfritBeacon/PayfritBeaconApp.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -@main -struct PayfritBeaconApp: App { - var body: some Scene { - WindowGroup { - RootView() - .preferredColorScheme(.light) - } - } -} - -extension Color { - static let payfritGreen = Color(red: 34/255, green: 178/255, blue: 75/255) // #22B24B - static let signalStrong = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 - static let signalMedium = Color(red: 249/255, green: 168/255, blue: 37/255) // #F9A825 - static let signalWeak = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A - static let infoBlue = Color(red: 33/255, green: 150/255, blue: 243/255) // #2196F3 - static let warningOrange = Color(red: 255/255, green: 152/255, blue: 0/255) // #FF9800 - static let errorRed = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A - static let successGreen = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 - static let newBg = Color(red: 76/255, green: 175/255, blue: 80/255).opacity(0.12) - static let assignedBg = Color(red: 33/255, green: 150/255, blue: 243/255).opacity(0.12) - static let bannedBg = Color(red: 186/255, green: 26/255, blue: 26/255).opacity(0.12) -} diff --git a/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift new file mode 100644 index 0000000..7e497b1 --- /dev/null +++ b/PayfritBeacon/Provisioners/BlueCharmProvisioner.swift @@ -0,0 +1,397 @@ +import Foundation +import CoreBluetooth + +/// Provisioner for BlueCharm / BC04P hardware +/// +/// Supports two service variants: +/// - FEA0 service (BC04P): FEA1 write, FEA2 notify, FEA3 config +/// - FFF0 service (legacy): FFF1 password, FFF2 UUID, FFF3 major, FFF4 minor +/// +/// BC04P write methods (tried in order): +/// 1. Direct config write to FEA3: [0x01] + UUID + Major + Minor + TxPower +/// 2. Raw data write to FEA1: UUID + Major + Minor + TxPower + Interval, then save commands +/// 3. Indexed parameter writes to FEA1: [index] + data, then [0xFF] save +/// +/// Legacy write: individual characteristics per parameter (FFF1-FFF4) +final class BlueCharmProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Constants + + // 5 passwords matching Android (16 bytes each) + private static let passwords: [Data] = [ + Data(repeating: 0, count: 16), // All zeros + "0000000000000000".data(using: .utf8)!, // ASCII zeros + "1234567890123456".data(using: .utf8)!, // Common + "minew123".data(using: .utf8)!.padded(to: 16), // Minew default + "bc04p".data(using: .utf8)!.padded(to: 16), // Model name + ] + + // Legacy FFF0 passwords + private static let legacyPasswords = ["000000", "123456", "bc0000"] + + // Legacy characteristic UUIDs + private static let fff1Password = CBUUID(string: "0000FFF1-0000-1000-8000-00805F9B34FB") + private static let fff2UUID = CBUUID(string: "0000FFF2-0000-1000-8000-00805F9B34FB") + private static let fff3Major = CBUUID(string: "0000FFF3-0000-1000-8000-00805F9B34FB") + private static let fff4Minor = CBUUID(string: "0000FFF4-0000-1000-8000-00805F9B34FB") + + // FEA0 characteristic UUIDs + private static let fea1Write = CBUUID(string: "0000FEA1-0000-1000-8000-00805F9B34FB") + private static let fea2Notify = CBUUID(string: "0000FEA2-0000-1000-8000-00805F9B34FB") + private static let fea3Config = CBUUID(string: "0000FEA3-0000-1000-8000-00805F9B34FB") + + // MARK: - State + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + + private var discoveredService: CBService? + private var writeChar: CBCharacteristic? // FEA1 or first writable + private var notifyChar: CBCharacteristic? // FEA2 + private var configChar: CBCharacteristic? // FEA3 + private var isLegacy = false // Using FFF0 service + + private var connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + private var writeOKContinuation: CheckedContinuation? + + private(set) var isConnected = false + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + if !isLegacy { + try await authenticateBC04P() + } + isConnected = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected else { + throw ProvisionError.notConnected + } + + let uuidBytes = config.uuid.hexToBytes + guard uuidBytes.count == 16 else { + throw ProvisionError.writeFailed("Invalid UUID length") + } + + if isLegacy { + try await writeLegacy(config, uuidBytes: uuidBytes) + } else { + try await writeBC04P(config, uuidBytes: uuidBytes) + } + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + } + + // MARK: - BC04P Write (3 fallback methods, matching Android) + + private func writeBC04P(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { + let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) + let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) + let txPowerByte = config.txPower + let intervalUnits = UInt16(Double(config.advInterval) * 100.0 / 0.625) + let intervalBytes = Data([UInt8(intervalUnits >> 8), UInt8(intervalUnits & 0xFF)]) + + // Method 1: Write directly to FEA3 (config characteristic) + if let fea3 = configChar { + var iBeaconData = Data([0x01]) // iBeacon frame type + iBeaconData.append(contentsOf: uuidBytes) + iBeaconData.append(majorBytes) + iBeaconData.append(minorBytes) + iBeaconData.append(txPowerByte) + + if let _ = try? await writeDirectAndWait(fea3, data: iBeaconData) { + try await Task.sleep(nanoseconds: 500_000_000) + return // Success + } + } + + // Method 2: Raw data write to FEA1 + if let fea1 = writeChar { + var rawData = Data(uuidBytes) + rawData.append(majorBytes) + rawData.append(minorBytes) + rawData.append(txPowerByte) + rawData.append(intervalBytes) + + if let _ = try? await writeDirectAndWait(fea1, data: rawData) { + try await Task.sleep(nanoseconds: 300_000_000) + + // Send save/apply commands (matching Android) + let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) // Save variant 1 + try await Task.sleep(nanoseconds: 200_000_000) + let _ = try? await writeDirectAndWait(fea1, data: Data([0x00])) // Apply/commit + try await Task.sleep(nanoseconds: 200_000_000) + let _ = try? await writeDirectAndWait(fea1, data: Data([0x57])) // KBeacon save + try await Task.sleep(nanoseconds: 200_000_000) + let _ = try? await writeDirectAndWait(fea1, data: Data([0x01, 0x00])) // Enable slot 0 + try await Task.sleep(nanoseconds: 500_000_000) + return + } + } + + // Method 3: Indexed parameter writes to FEA1 + if let fea1 = writeChar { + // Index 0 = UUID + let _ = try? await writeDirectAndWait(fea1, data: Data([0x00]) + Data(uuidBytes)) + try await Task.sleep(nanoseconds: 100_000_000) + + // Index 1 = Major + let _ = try? await writeDirectAndWait(fea1, data: Data([0x01]) + majorBytes) + try await Task.sleep(nanoseconds: 100_000_000) + + // Index 2 = Minor + let _ = try? await writeDirectAndWait(fea1, data: Data([0x02]) + minorBytes) + try await Task.sleep(nanoseconds: 100_000_000) + + // Index 3 = TxPower + let _ = try? await writeDirectAndWait(fea1, data: Data([0x03, txPowerByte])) + try await Task.sleep(nanoseconds: 100_000_000) + + // Index 4 = Interval + let _ = try? await writeDirectAndWait(fea1, data: Data([0x04]) + intervalBytes) + try await Task.sleep(nanoseconds: 100_000_000) + + // Save command + let _ = try? await writeDirectAndWait(fea1, data: Data([0xFF])) + try await Task.sleep(nanoseconds: 500_000_000) + return + } + + throw ProvisionError.writeFailed("No write characteristic available") + } + + // MARK: - Legacy FFF0 Write + + private func writeLegacy(_ config: BeaconConfig, uuidBytes: [UInt8]) async throws { + guard let service = discoveredService else { + throw ProvisionError.serviceNotFound + } + + // Try passwords + for password in Self.legacyPasswords { + if let char = service.characteristics?.first(where: { $0.uuid == Self.fff1Password }), + let data = password.data(using: .utf8) { + let _ = try? await writeDirectAndWait(char, data: data) + try await Task.sleep(nanoseconds: 200_000_000) + } + } + + // Write UUID + if let char = service.characteristics?.first(where: { $0.uuid == Self.fff2UUID }) { + let _ = try await writeDirectAndWait(char, data: Data(uuidBytes)) + } + + // Write Major + let majorBytes = Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]) + if let char = service.characteristics?.first(where: { $0.uuid == Self.fff3Major }) { + let _ = try await writeDirectAndWait(char, data: majorBytes) + } + + // Write Minor + let minorBytes = Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]) + if let char = service.characteristics?.first(where: { $0.uuid == Self.fff4Minor }) { + let _ = try await writeDirectAndWait(char, data: minorBytes) + } + } + + // MARK: - Auth (BC04P) + + private func authenticateBC04P() async throws { + guard let fea1 = writeChar else { + throw ProvisionError.characteristicNotFound + } + + // Enable notifications on FEA2 if available + if let fea2 = notifyChar { + peripheral.setNotifyValue(true, for: fea2) + try await Task.sleep(nanoseconds: 200_000_000) + } + + // No explicit auth command needed for BC04P — the write methods + // handle auth implicitly. Android's BlueCharm provisioner also + // doesn't do a CMD_CONNECT auth for the FEA0 path. + } + + // MARK: - Private Helpers + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.fea0Service, GATTConstants.fff0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + private func writeDirectAndWait(_ char: CBCharacteristic, data: Data) async throws -> Void { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + writeOKContinuation = cont + peripheral.writeValue(data, for: char, type: .withResponse) + + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in + if let c = self?.writeOKContinuation { + self?.writeOKContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension BlueCharmProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + // Prefer FEA0 (BC04P), fallback to FFF0 (legacy) + if let fea0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fea0Service }) { + discoveredService = fea0Service + isLegacy = false + peripheral.discoverCharacteristics(nil, for: fea0Service) + } else if let fff0Service = peripheral.services?.first(where: { $0.uuid == GATTConstants.fff0Service }) { + discoveredService = fff0Service + isLegacy = true + peripheral.discoverCharacteristics(nil, for: fff0Service) + } else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + if isLegacy { + // Legacy: just need the service with characteristics + serviceContinuation?.resume() + serviceContinuation = nil + return + } + + // BC04P: map specific characteristics + for char in service.characteristics ?? [] { + switch char.uuid { + case Self.fea1Write: + writeChar = char + case Self.fea2Notify: + notifyChar = char + case Self.fea3Config: + configChar = char + default: + // Also grab any writable char as fallback + if writeChar == nil && (char.properties.contains(.write) || char.properties.contains(.writeWithoutResponse)) { + writeChar = char + } + if notifyChar == nil && char.properties.contains(.notify) { + notifyChar = char + } + } + } + + if writeChar != nil || configChar != nil { + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let cont = writeOKContinuation { + writeOKContinuation = nil + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + return + } + + if let error, let cont = writeContinuation { + writeContinuation = nil + cont.resume(throwing: ProvisionError.writeFailed(error.localizedDescription)) + } + } +} + +// MARK: - Data Extension + +private extension Data { + /// Pad data to target length with zero bytes + func padded(to length: Int) -> Data { + if count >= length { return self } + var padded = self + padded.append(contentsOf: [UInt8](repeating: 0, count: length - count)) + return padded + } +} diff --git a/PayfritBeacon/Provisioners/DXSmartProvisioner.swift b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift new file mode 100644 index 0000000..bb2acdb --- /dev/null +++ b/PayfritBeacon/Provisioners/DXSmartProvisioner.swift @@ -0,0 +1,405 @@ +import Foundation +import CoreBluetooth + +/// Provisioner for DXSmart / CP28 hardware +/// +/// Implements BOTH the new SDK protocol (preferred) and old SDK fallback: +/// +/// **New SDK (2024.10+)**: Writes to FFE2, notifications on FFE1 +/// - Frame selection (0x11/0x12) → frame type (0x62 = iBeacon) +/// - Param writes: 0x74 UUID, 0x75 Major, 0x76 Minor, 0x77 RSSI, 0x78 AdvInt, 0x79 TxPower +/// - Save: 0x60 +/// - All wrapped in 4E 4F protocol packets +/// +/// **Old SDK fallback**: Writes to FFE1, re-sends 555555 before each command +/// - 0x36 UUID, 0x37 Major, 0x38 Minor, 0x39 TxPower, 0x40 RfPower, 0x41 AdvInt, 0x43 Name +/// - 0x44 Restart (includes password) +/// +/// Auth: "555555" to FFE3 (config mode) → "dx1234" to FFE3 (authenticate) +/// NOTE: CoreBluetooth doesn't expose raw MAC addresses, so 48:87:2D OUI detection +/// (used on Android) is not available on iOS. Beacons are detected by name/service UUID. +final class DXSmartProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Constants + + private static let triggerPassword = "555555" + private static let defaultPassword = "dx1234" + + // MARK: - State + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + private var ffe1Char: CBCharacteristic? // FFE1 — notify (ACK responses) + private var ffe2Char: CBCharacteristic? // FFE2 — write (new SDK commands) + private var ffe3Char: CBCharacteristic? // FFE3 — password + + private var connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var responseContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + + private(set) var isConnected = false + private(set) var isFlashing = false // Beacon LED flashing after trigger + private var useNewSDK = true // Prefer new SDK, fallback to old + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + try await authenticate() + isConnected = true + isFlashing = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected else { + throw ProvisionError.notConnected + } + + let uuidBytes = config.uuid.hexToBytes + guard uuidBytes.count == 16 else { + throw ProvisionError.writeFailed("Invalid UUID length") + } + + // Try new SDK first (FFE2), fall back to old SDK (FFE1) + if useNewSDK, let ffe2 = ffe2Char { + try await writeConfigNewSDK(config, uuidBytes: uuidBytes, writeChar: ffe2) + } else if let ffe1 = ffe1Char { + try await writeConfigOldSDK(config, uuidBytes: uuidBytes, writeChar: ffe1) + } else { + throw ProvisionError.characteristicNotFound + } + + isFlashing = false + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + isFlashing = false + } + + // MARK: - New SDK Protocol (FFE2, 2024.10+) + // Matches Android DXSmartProvisioner.writeBeaconConfig() + + private func writeConfigNewSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { + // Build command sequence matching Android's writeBeaconConfig() + let commands: [(String, Data)] = [ + // Frame 1: device info + radio params + ("Frame1_Select", buildProtocolPacket(cmd: 0x11, data: Data())), + ("Frame1_DevInfo", buildProtocolPacket(cmd: 0x61, data: Data())), + ("Frame1_RSSI", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("Frame1_AdvInt", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("Frame1_TxPow", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), + + // Frame 2: iBeacon config + ("Frame2_Select", buildProtocolPacket(cmd: 0x12, data: Data())), + ("Frame2_iBeacon", buildProtocolPacket(cmd: 0x62, data: Data())), + ("UUID", buildProtocolPacket(cmd: 0x74, data: Data(uuidBytes))), + ("Major", buildProtocolPacket(cmd: 0x75, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), + ("Minor", buildProtocolPacket(cmd: 0x76, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), + ("RSSI@1m", buildProtocolPacket(cmd: 0x77, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("AdvInterval", buildProtocolPacket(cmd: 0x78, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("TxPower", buildProtocolPacket(cmd: 0x79, data: Data([config.txPower]))), + ("TriggerOff", buildProtocolPacket(cmd: 0xA0, data: Data())), + + // Disable frames 3-6 + ("Frame3_Select", buildProtocolPacket(cmd: 0x13, data: Data())), + ("Frame3_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame4_Select", buildProtocolPacket(cmd: 0x14, data: Data())), + ("Frame4_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame5_Select", buildProtocolPacket(cmd: 0x15, data: Data())), + ("Frame5_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + ("Frame6_Select", buildProtocolPacket(cmd: 0x16, data: Data())), + ("Frame6_NoData", buildProtocolPacket(cmd: 0xFF, data: Data())), + + // Save to flash + ("SaveConfig", buildProtocolPacket(cmd: 0x60, data: Data())), + ] + + for (name, packet) in commands { + try await writeToCharAndWaitACK(writeChar, data: packet, label: name) + // 200ms between commands (matches Android SDK timer interval) + try await Task.sleep(nanoseconds: 200_000_000) + } + } + + // MARK: - Old SDK Protocol (FFE1, pre-2024.10) + // Matches Android DXSmartProvisioner.writeFrame1() + // Key difference: must re-send "555555" to FFE3 before EVERY command + + private func writeConfigOldSDK(_ config: BeaconConfig, uuidBytes: [UInt8], writeChar: CBCharacteristic) async throws { + guard let ffe3 = ffe3Char else { + throw ProvisionError.characteristicNotFound + } + + let commands: [(String, Data)] = [ + ("UUID", buildProtocolPacket(cmd: 0x36, data: Data(uuidBytes))), + ("Major", buildProtocolPacket(cmd: 0x37, data: Data([UInt8(config.major >> 8), UInt8(config.major & 0xFF)]))), + ("Minor", buildProtocolPacket(cmd: 0x38, data: Data([UInt8(config.minor >> 8), UInt8(config.minor & 0xFF)]))), + ("TxPower", buildProtocolPacket(cmd: 0x39, data: Data([UInt8(bitPattern: config.measuredPower)]))), + ("RfPower", buildProtocolPacket(cmd: 0x40, data: Data([config.txPower]))), + ("AdvInt", buildProtocolPacket(cmd: 0x41, data: Data([UInt8(config.advInterval & 0xFF)]))), + ("Name", buildProtocolPacket(cmd: 0x43, data: Data("Payfrit".utf8))), + ] + + for (name, packet) in commands { + // Step 1: Re-send "555555" to FFE3 before each command (old SDK requirement) + if let triggerData = Self.triggerPassword.data(using: .utf8) { + peripheral.writeValue(triggerData, for: ffe3, type: .withResponse) + try await waitForWriteCallback() + } + + // Step 2: 50ms delay (SDK timer, half of 100ms default — tested OK) + try await Task.sleep(nanoseconds: 50_000_000) + + // Step 3: Write command to FFE1 + try await writeToCharAndWaitACK(writeChar, data: packet, label: name) + + // 200ms settle between params + try await Task.sleep(nanoseconds: 200_000_000) + } + } + + // MARK: - Read-Back Verification + + /// Read frame 2 (iBeacon config) to verify the write succeeded. + /// Returns the raw response data, or nil if read fails. + func readFrame2() async throws -> Data? { + guard let ffe2 = ffe2Char ?? ffe1Char else { return nil } + + let readCmd = buildProtocolPacket(cmd: 0x62, data: Data()) + peripheral.writeValue(readCmd, for: ffe2, type: .withResponse) + + do { + let response = try await waitForResponse(timeout: 2.0) + return response + } catch { + return nil + } + } + + // MARK: - Protocol Packet Builder + // Format: 4E 4F [CMD] [LEN] [DATA...] [CHECKSUM] + // Checksum = XOR of CMD, LEN, and all data bytes + + private func buildProtocolPacket(cmd: UInt8, data: Data) -> Data { + let len = UInt8(data.count) + var checksum = Int(cmd) ^ Int(len) + for byte in data { + checksum ^= Int(byte) + } + + var packet = Data([0x4E, 0x4F, cmd, len]) + packet.append(data) + packet.append(UInt8(checksum & 0xFF)) + return packet + } + + // MARK: - Private Helpers + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.ffe0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + /// Two-step DXSmart auth: + /// 1. Send "555555" to FFE3 — fire and forget (WRITE_NO_RESPONSE) — enters config mode + /// 2. Send "dx1234" to FFE3 — fire and forget — authenticates + /// Matches Android enterConfigModeAndLogin(): both use WRITE_TYPE_NO_RESPONSE + private func authenticate() async throws { + guard let ffe3 = ffe3Char else { + throw ProvisionError.characteristicNotFound + } + + // Step 1: Trigger — fire and forget (matches Android's WRITE_TYPE_NO_RESPONSE) + if let triggerData = Self.triggerPassword.data(using: .utf8) { + peripheral.writeValue(triggerData, for: ffe3, type: .withoutResponse) + try await Task.sleep(nanoseconds: 100_000_000) // 100ms (matches Android SDK timer) + } + + // Step 2: Auth password — fire and forget + if let authData = Self.defaultPassword.data(using: .utf8) { + peripheral.writeValue(authData, for: ffe3, type: .withoutResponse) + try await Task.sleep(nanoseconds: 100_000_000) // 100ms settle + } + } + + /// Write data to a characteristic and wait for ACK notification on FFE1 + private func writeToCharAndWaitACK(_ char: CBCharacteristic, data: Data, label: String) async throws { + let _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + responseContinuation = cont + peripheral.writeValue(data, for: char, type: .withResponse) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.responseContinuation { + self?.responseContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } + + /// Wait for a write callback (used for FFE3 password writes in old SDK path) + private func waitForWriteCallback() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + writeContinuation = cont + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.writeContinuation { + self?.writeContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } + + /// Wait for a response notification with custom timeout + private func waitForResponse(timeout: TimeInterval) async throws -> Data { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + responseContinuation = cont + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + if let c = self?.responseContinuation { + self?.responseContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension DXSmartProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + return + } + + peripheral.discoverCharacteristics( + [GATTConstants.ffe1Char, GATTConstants.ffe2Char, GATTConstants.ffe3Char], + for: service + ) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + for char in service.characteristics ?? [] { + switch char.uuid { + case GATTConstants.ffe1Char: + ffe1Char = char + // FFE1 is used for notify (ACK responses) + if char.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: char) + } + case GATTConstants.ffe2Char: + ffe2Char = char + case GATTConstants.ffe3Char: + ffe3Char = char + default: + break + } + } + + // Need at least FFE1 (notify) + FFE3 (password) + // FFE2 is preferred for writes but optional (old firmware uses FFE1) + if ffe1Char != nil && ffe3Char != nil { + useNewSDK = (ffe2Char != nil) + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard let data = characteristic.value else { return } + + if let cont = responseContinuation { + responseContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + // Handle write callback for old SDK FFE3 password writes + if let cont = writeContinuation { + writeContinuation = nil + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + return + } + + // Handle write errors for command writes + if let error, let cont = responseContinuation { + responseContinuation = nil + cont.resume(throwing: error) + } + } +} diff --git a/PayfritBeacon/Provisioners/FallbackProvisioner.swift b/PayfritBeacon/Provisioners/FallbackProvisioner.swift new file mode 100644 index 0000000..d9580de --- /dev/null +++ b/PayfritBeacon/Provisioners/FallbackProvisioner.swift @@ -0,0 +1,56 @@ +import Foundation +import CoreBluetooth + +/// Tries KBeacon → DXSmart → BlueCharm in sequence for unknown beacon types. +/// Matches Android's fallback behavior when beacon type can't be determined. +final class FallbackProvisioner: BeaconProvisioner { + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + private var activeProvisioner: (any BeaconProvisioner)? + + private(set) var isConnected: Bool = false + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + } + + func connect() async throws { + let provisioners: [() -> any BeaconProvisioner] = [ + { KBeaconProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + { DXSmartProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + { BlueCharmProvisioner(peripheral: self.peripheral, centralManager: self.centralManager) }, + ] + + var lastError: Error = ProvisionError.connectionTimeout + + for makeProvisioner in provisioners { + let provisioner = makeProvisioner() + do { + try await provisioner.connect() + activeProvisioner = provisioner + isConnected = true + return + } catch { + provisioner.disconnect() + lastError = error + } + } + + throw lastError + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard let provisioner = activeProvisioner else { + throw ProvisionError.notConnected + } + try await provisioner.writeConfig(config) + } + + func disconnect() { + activeProvisioner?.disconnect() + activeProvisioner = nil + isConnected = false + } +} diff --git a/PayfritBeacon/Provisioners/KBeaconProvisioner.swift b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift new file mode 100644 index 0000000..398bc4b --- /dev/null +++ b/PayfritBeacon/Provisioners/KBeaconProvisioner.swift @@ -0,0 +1,262 @@ +import Foundation +import CoreBluetooth + +/// Provisioner for KBeacon / KBPro hardware +/// Protocol: FFE0 service, FFE1 write, FFE2 notify +/// Auth via CMD_AUTH (0x01), config via CMD_WRITE_PARAMS (0x03), save via CMD_SAVE (0x04) +final class KBeaconProvisioner: NSObject, BeaconProvisioner { + + // MARK: - Protocol Commands + private enum CMD: UInt8 { + case auth = 0x01 + case readParams = 0x02 + case writeParams = 0x03 + case save = 0x04 + } + + // MARK: - Parameter IDs + private enum ParamID: UInt8 { + case uuid = 0x10 + case major = 0x11 + case minor = 0x12 + case txPower = 0x13 + case advInterval = 0x14 + } + + // MARK: - Known passwords (tried in order, matching Android) + private static let passwords: [Data] = [ + "kd1234".data(using: .utf8)!, + Data(repeating: 0, count: 16), + Data([0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x30,0x31,0x32,0x33,0x34,0x35,0x36]), + "0000000000000000".data(using: .utf8)!, + "1234567890123456".data(using: .utf8)! + ] + + // MARK: - State + + private let peripheral: CBPeripheral + private let centralManager: CBCentralManager + private var writeChar: CBCharacteristic? + private var notifyChar: CBCharacteristic? + + private var connectionContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + + private(set) var isConnected = false + + // MARK: - Init + + init(peripheral: CBPeripheral, centralManager: CBCentralManager) { + self.peripheral = peripheral + self.centralManager = centralManager + super.init() + self.peripheral.delegate = self + } + + // MARK: - BeaconProvisioner + + func connect() async throws { + // Connect with retry + for attempt in 1...GATTConstants.maxRetries { + do { + try await connectOnce() + try await discoverServices() + try await authenticate() + isConnected = true + return + } catch { + disconnect() + if attempt < GATTConstants.maxRetries { + try await Task.sleep(nanoseconds: UInt64(GATTConstants.retryDelay * 1_000_000_000)) + } else { + throw error + } + } + } + } + + func writeConfig(_ config: BeaconConfig) async throws { + guard isConnected, let writeChar else { + throw ProvisionError.notConnected + } + + // Build parameter payload + var params = Data() + + // UUID (16 bytes) + params.append(ParamID.uuid.rawValue) + let uuidBytes = config.uuid.hexToBytes + params.append(contentsOf: uuidBytes) + + // Major (2 bytes BE) + params.append(ParamID.major.rawValue) + params.append(UInt8(config.major >> 8)) + params.append(UInt8(config.major & 0xFF)) + + // Minor (2 bytes BE) + params.append(ParamID.minor.rawValue) + params.append(UInt8(config.minor >> 8)) + params.append(UInt8(config.minor & 0xFF)) + + // TX Power + params.append(ParamID.txPower.rawValue) + params.append(config.txPower) + + // Adv Interval + params.append(ParamID.advInterval.rawValue) + params.append(UInt8(config.advInterval >> 8)) + params.append(UInt8(config.advInterval & 0xFF)) + + // Send CMD_WRITE_PARAMS + let writeCmd = Data([CMD.writeParams.rawValue]) + params + let writeResp = try await sendCommand(writeCmd) + guard writeResp.first == CMD.writeParams.rawValue else { + throw ProvisionError.writeFailed("Unexpected write response") + } + + // Send CMD_SAVE to flash + let saveResp = try await sendCommand(Data([CMD.save.rawValue])) + guard saveResp.first == CMD.save.rawValue else { + throw ProvisionError.saveFailed + } + } + + func disconnect() { + if peripheral.state == .connected || peripheral.state == .connecting { + centralManager.cancelPeripheralConnection(peripheral) + } + isConnected = false + } + + // MARK: - Private: Connection + + private func connectOnce() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connectionContinuation = cont + centralManager.connect(peripheral, options: nil) + + // Timeout + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.connectionTimeout) { [weak self] in + if let c = self?.connectionContinuation { + self?.connectionContinuation = nil + c.resume(throwing: ProvisionError.connectionTimeout) + } + } + } + } + + private func discoverServices() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + serviceContinuation = cont + peripheral.discoverServices([GATTConstants.ffe0Service]) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.serviceContinuation { + self?.serviceContinuation = nil + c.resume(throwing: ProvisionError.serviceDiscoveryTimeout) + } + } + } + } + + private func authenticate() async throws { + for password in Self.passwords { + let cmd = Data([CMD.auth.rawValue]) + password + do { + let resp = try await sendCommand(cmd) + if resp.first == CMD.auth.rawValue && resp.count > 1 && resp[1] == 0x00 { + return // Auth success + } + } catch { + continue + } + } + throw ProvisionError.authFailed + } + + private func sendCommand(_ data: Data) async throws -> Data { + guard let writeChar else { throw ProvisionError.notConnected } + + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + writeContinuation = cont + peripheral.writeValue(data, for: writeChar, type: .withResponse) + + DispatchQueue.main.asyncAfter(deadline: .now() + GATTConstants.operationTimeout) { [weak self] in + if let c = self?.writeContinuation { + self?.writeContinuation = nil + c.resume(throwing: ProvisionError.operationTimeout) + } + } + } + } +} + +// MARK: - CBPeripheralDelegate + +extension KBeaconProvisioner: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == GATTConstants.ffe0Service }) else { + serviceContinuation?.resume(throwing: ProvisionError.serviceNotFound) + serviceContinuation = nil + return + } + + peripheral.discoverCharacteristics([GATTConstants.ffe1Char, GATTConstants.ffe2Char], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error { + serviceContinuation?.resume(throwing: error) + serviceContinuation = nil + return + } + + for char in service.characteristics ?? [] { + switch char.uuid { + case GATTConstants.ffe1Char: + writeChar = char + case GATTConstants.ffe2Char: + notifyChar = char + peripheral.setNotifyValue(true, for: char) + default: + break + } + } + + if writeChar != nil { + serviceContinuation?.resume() + serviceContinuation = nil + } else { + serviceContinuation?.resume(throwing: ProvisionError.characteristicNotFound) + serviceContinuation = nil + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == GATTConstants.ffe2Char, + let data = characteristic.value else { return } + + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(returning: data) + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + // Write acknowledgment — actual response comes via notify on FFE2 + if let error { + if let cont = writeContinuation { + writeContinuation = nil + cont.resume(throwing: error) + } + } + } +} diff --git a/PayfritBeacon/Provisioners/ProvisionError.swift b/PayfritBeacon/Provisioners/ProvisionError.swift new file mode 100644 index 0000000..23053b3 --- /dev/null +++ b/PayfritBeacon/Provisioners/ProvisionError.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Errors during beacon provisioning +enum ProvisionError: LocalizedError { + case connectionTimeout + case serviceDiscoveryTimeout + case operationTimeout + case serviceNotFound + case characteristicNotFound + case authFailed + case notConnected + case writeFailed(String) + case saveFailed + case verifyFailed + + var errorDescription: String? { + switch self { + case .connectionTimeout: return "Connection timed out" + case .serviceDiscoveryTimeout: return "Service discovery timed out" + case .operationTimeout: return "Operation timed out" + case .serviceNotFound: return "GATT service not found" + case .characteristicNotFound: return "GATT characteristic not found" + case .authFailed: return "Beacon authentication failed" + case .notConnected: return "Not connected to beacon" + case .writeFailed(let msg): return "Write failed: \(msg)" + case .saveFailed: return "Failed to save config to flash" + case .verifyFailed: return "Beacon broadcast verification failed" + } + } +} diff --git a/PayfritBeacon/Provisioners/ProvisionerProtocol.swift b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift new file mode 100644 index 0000000..da93d77 --- /dev/null +++ b/PayfritBeacon/Provisioners/ProvisionerProtocol.swift @@ -0,0 +1,40 @@ +import Foundation +import CoreBluetooth + +/// Common protocol for all beacon provisioners +protocol BeaconProvisioner { + /// Connect to the beacon and authenticate + func connect() async throws + + /// Write the full beacon configuration + func writeConfig(_ config: BeaconConfig) async throws + + /// Disconnect from the beacon + func disconnect() + + /// Whether we're currently connected + var isConnected: Bool { get } +} + +/// GATT UUIDs shared across provisioner types +enum GATTConstants { + // FFE0 service (KBeacon, DXSmart) + static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + static let ffe1Char = CBUUID(string: "0000FFE1-0000-1000-8000-00805F9B34FB") + static let ffe2Char = CBUUID(string: "0000FFE2-0000-1000-8000-00805F9B34FB") + static let ffe3Char = CBUUID(string: "0000FFE3-0000-1000-8000-00805F9B34FB") + + // FFF0 service (BlueCharm) + static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") + static let fea0Service = CBUUID(string: "0000FEA0-0000-1000-8000-00805F9B34FB") + + // CCCD for enabling notifications + static let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB") + + // Timeouts (matching Android) + static let connectionTimeout: TimeInterval = 5.0 + static let operationTimeout: TimeInterval = 5.0 + static let maxRetries = 3 + static let retryDelay: TimeInterval = 1.0 + static let postFlashDelay: TimeInterval = 3.0 +} diff --git a/PayfritBeacon/RootView.swift b/PayfritBeacon/RootView.swift deleted file mode 100644 index 941120f..0000000 --- a/PayfritBeacon/RootView.swift +++ /dev/null @@ -1,58 +0,0 @@ -import SwiftUI - -struct RootView: View { - @State private var isAuthenticated = false - @State private var isCheckingAuth = true - @State private var userId: Int = 0 - @State private var selectedBusiness: Business? - @State private var hasAutoSelected = false - - var body: some View { - Group { - if isCheckingAuth { - VStack { - ProgressView() - } - } else if !isAuthenticated { - LoginView { token, uid in - userId = uid - isAuthenticated = true - } - } else { - BusinessListView( - hasAutoSelected: $hasAutoSelected, - onBusinessSelected: { business in - selectedBusiness = business - }, - onLogout: { - isAuthenticated = false - selectedBusiness = nil - hasAutoSelected = false - } - ) - .fullScreenCover(item: $selectedBusiness) { business in - ServicePointListView( - businessId: business.businessId, - businessName: business.name, - onBack: { selectedBusiness = nil } - ) - } - } - } - .modifier(DevBanner()) - .onAppear { - checkAuth() - } - } - - private func checkAuth() { - let token = UserDefaults.standard.string(forKey: "token") - let savedUserId = UserDefaults.standard.integer(forKey: "userId") - - if let token = token, !token.isEmpty, savedUserId > 0 { - isCheckingAuth = false - } else { - isCheckingAuth = false - } - } -} diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift deleted file mode 100644 index 16fb357..0000000 --- a/PayfritBeacon/ScanView.swift +++ /dev/null @@ -1,1051 +0,0 @@ -import SwiftUI - -// MARK: - ScanView (Beacon Provisioning) - -struct ScanView: View { - let businessId: Int - let businessName: String - var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP - var onBack: () -> Void - - @StateObject private var bleScanner = BLEBeaconScanner() - @StateObject private var provisioner = BeaconProvisioner() - @StateObject private var iBeaconScanner = BeaconScanner() - - @State private var namespace: BusinessNamespace? - @State private var servicePoints: [ServicePoint] = [] - @State private var nextTableNumber: Int = 1 - @State private var provisionedCount: Int = 0 - - // iBeacon ownership tracking - // Key: "UUID|Major" → (businessId, businessName) - @State private var detectedIBeacons: [DetectedBeacon] = [] - @State private var beaconOwnership: [String: (businessId: Int, businessName: String)] = [:] - - // UI State - @State private var snackMessage: String? - @State private var showAssignSheet = false - @State private var selectedBeacon: DiscoveredBeacon? - @State private var assignName = "" - @State private var isProvisioning = false - @State private var provisioningProgress = "" - @State private var provisioningError: String? - - // Action sheet + check config - @State private var showBeaconActionSheet = false - @State private var showCheckConfigSheet = false - @State private var checkConfigData: BeaconCheckResult? - @State private var isCheckingConfig = false - @State private var checkConfigError: String? - - // Debug log - @State private var showDebugLog = false - @ObservedObject private var debugLog = DebugLog.shared - - var body: some View { - VStack(spacing: 0) { - // Toolbar - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.title3) - } - Text("Beacon Setup") - .font(.headline) - Spacer() - if provisionedCount > 0 { - Text("\(provisionedCount) done") - .font(.caption) - .foregroundColor(.payfritGreen) - } - Button { - showDebugLog = true - } label: { - Image(systemName: "ladybug") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemBackground)) - - // Info bar - HStack { - Text(businessName) - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if let sp = reprovisionServicePoint { - Text("Re-provision: \(sp.name)") - .font(.subheadline.bold()) - .foregroundColor(.orange) - } else { - Text("Next: Table \(nextTableNumber)") - .font(.subheadline.bold()) - .foregroundColor(.payfritGreen) - } - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - // Bluetooth status - if bleScanner.bluetoothState != .poweredOn { - bluetoothWarning - } - - // Beacon lists - ScrollView { - LazyVStack(spacing: 8) { - // BLE devices section (for provisioning) - shown first - if !bleScanner.discoveredBeacons.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Configurable Devices") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal) - - ForEach(bleScanner.discoveredBeacons) { beacon in - beaconRow(beacon) - .onTapGesture { - selectedBeacon = beacon - showBeaconActionSheet = true - } - } - } - .padding(.top, 8) - - if !detectedIBeacons.isEmpty { - Divider() - .padding(.vertical, 8) - } - } else if bleScanner.isScanning { - VStack(spacing: 12) { - ProgressView() - Text("Scanning for beacons...") - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if detectedIBeacons.isEmpty { - 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) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } - - // Detected iBeacons section (shows ownership status) - if !detectedIBeacons.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Detected Beacons") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary) - .padding(.horizontal) - - ForEach(detectedIBeacons, id: \.minor) { ibeacon in - iBeaconRow(ibeacon) - } - } - } - } - .padding(.horizontal) - } - - // Bottom action bar - VStack(spacing: 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") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.payfritGreen, lineWidth: 1) - ) - .foregroundColor(.payfritGreen) - } - } - .padding() - } - .modifier(DevBanner()) - .overlay(snackOverlay, alignment: .bottom) - .sheet(isPresented: $showAssignSheet) { assignSheet } - .sheet(isPresented: $showCheckConfigSheet) { checkConfigSheet } - .sheet(isPresented: $showDebugLog) { debugLogSheet } - .confirmationDialog( - selectedBeacon?.displayName ?? "Beacon", - isPresented: $showBeaconActionSheet, - titleVisibility: .visible - ) { - if let sp = reprovisionServicePoint { - Button("Provision for \(sp.name)") { - guard selectedBeacon != nil else { return } - reprovisionBeacon() - } - } else { - Button("Configure (Assign & Provision)") { - guard selectedBeacon != nil else { return } - assignName = "Table \(nextTableNumber)" - showAssignSheet = true - } - } - Button("Check Current Config") { - guard let beacon = selectedBeacon else { return } - checkConfig(beacon) - } - Button("Cancel", role: .cancel) { - selectedBeacon = nil - } - } - .onAppear { loadServicePoints() } - } - - // MARK: - Bluetooth Warning - - private var bluetoothWarning: some View { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.warningOrange) - Text(bluetoothMessage) - .font(.caption) - Spacer() - } - .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: DiscoveredBeacon) -> some View { - HStack(spacing: 12) { - // Signal strength indicator - Rectangle() - .fill(signalColor(beacon.rssi)) - .frame(width: 4) - .cornerRadius(2) - - VStack(alignment: .leading, spacing: 4) { - Text(beacon.displayName) - .font(.system(.body, design: .default)) - .lineLimit(1) - - HStack(spacing: 8) { - if beacon.type != .unknown { - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) - .cornerRadius(4) - } - - Text("\(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - .padding(12) - .background(Color(.systemBackground)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.05), radius: 2, y: 1) - } - - private func signalColor(_ rssi: Int) -> Color { - if rssi >= -60 { return .signalStrong } - if rssi >= -75 { return .signalMedium } - return .signalWeak - } - - // MARK: - iBeacon Row (shows ownership status) - - private func iBeaconRow(_ beacon: DetectedBeacon) -> some View { - let ownership = getOwnershipStatus(for: beacon) - - return HStack(spacing: 12) { - // Signal strength indicator - Rectangle() - .fill(signalColor(beacon.rssi)) - .frame(width: 4) - .cornerRadius(2) - - VStack(alignment: .leading, spacing: 4) { - // Ownership status - all green (own business shows name, others show "Unconfigured") - Text(ownership.displayText) - .font(.system(.body, design: .default).weight(.medium)) - .foregroundColor(.payfritGreen) - .lineLimit(1) - - HStack(spacing: 8) { - Text("Major: \(beacon.major)") - .font(.caption) - .foregroundColor(.secondary) - - Text("Minor: \(beacon.minor)") - .font(.caption) - .foregroundColor(.secondary) - - Text("\(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding(12) - .background(Color(.systemBackground)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.05), radius: 2, y: 1) - } - - // MARK: - Assignment Sheet - - 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 : beacon.type == .dxsmart ? Color.orange : 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) - } - - // Provisioning progress — show live updates from provisioner - if isProvisioning { - HStack { - ProgressView() - Text(provisioner.progress.isEmpty ? provisioningProgress : provisioner.progress) - .font(.callout) - } - .padding() - .frame(maxWidth: .infinity) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Provisioning error - if let error = provisioningError { - HStack(alignment: .top) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.red) - Text(error) - .font(.callout) - .foregroundColor(.red) - } - .padding() - .background(Color.red.opacity(0.1)) - .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: - Check Config Sheet - - private var checkConfigSheet: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let beacon = selectedBeacon { - // Beacon identity - VStack(alignment: .leading, spacing: 8) { - Text("Beacon") - .font(.caption) - .foregroundColor(.secondary) - HStack { - Text(beacon.displayName) - .font(.headline) - Spacer() - if beacon.type != .unknown { - Text(beacon.type.rawValue) - .font(.caption2.weight(.medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) - .cornerRadius(4) - } - } - Text("Signal: \(beacon.rssi) dBm") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - - // Loading state - if isCheckingConfig { - HStack { - ProgressView() - Text(provisioner.progress.isEmpty ? "Connecting..." : provisioner.progress) - .font(.callout) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Error state - if let error = checkConfigError { - HStack(alignment: .top) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(error) - .font(.callout) - } - .padding() - .background(Color.orange.opacity(0.1)) - .cornerRadius(8) - } - - // Parsed config data - if let data = checkConfigData { - // iBeacon configuration - if data.hasConfig { - VStack(alignment: .leading, spacing: 10) { - Text("iBeacon Configuration") - .font(.subheadline.weight(.semibold)) - - if let major = data.major { - configRow("Major", "\(major)") - } - if let ns = namespace { - configRow("Shard", "\(ns.shardId)") - } - if let minor = data.minor { - configRow("Minor", "\(minor)") - } - if let name = data.deviceName { - configRow("Name", name) - } - if let rssi = data.rssiAt1m { - configRow("RSSI@1m", "\(rssi) dBm") - } - if let interval = data.advInterval { - configRow("Interval", "\(interval)00 ms") - } - if let tx = data.txPower { - configRow("TX Power", "\(tx)") - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Device info - if data.battery != nil || data.macAddress != nil { - VStack(alignment: .leading, spacing: 10) { - Text("Device Info") - .font(.subheadline.weight(.semibold)) - - if let battery = data.battery { - configRow("Battery", "\(battery)%") - } - if let mac = data.macAddress { - configRow("MAC", mac) - } - if let slots = data.frameSlots { - let slotStr = slots.enumerated().map { i, s in - "Slot\(i): 0x\(String(format: "%02X", s))" - }.joined(separator: " ") - configRow("Frames", slotStr) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // No config found - if !data.hasConfig && data.battery == nil && data.macAddress == nil && data.rawResponses.isEmpty { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "info.circle") - .foregroundColor(.secondary) - Text("No DX-Smart config data received") - .font(.callout) - .foregroundColor(.secondary) - } - - if !data.servicesFound.isEmpty { - Text("Services: \(data.servicesFound.joined(separator: ", "))") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Raw responses (debug section) - if !data.rawResponses.isEmpty { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Raw Responses") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Button { - let raw = data.rawResponses.joined(separator: "\n") - UIPasteboard.general.string = raw - showSnack("Copied to clipboard") - } label: { - Image(systemName: "doc.on.doc") - .font(.caption) - } - } - Text(data.rawResponses.joined(separator: "\n")) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - // Services/chars discovery info - if !data.characteristicsFound.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("BLE Discovery") - .font(.caption) - .foregroundColor(.secondary) - Text(data.characteristicsFound.joined(separator: "\n")) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - } - .padding() - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - } else { - Text("No beacon selected") - .foregroundColor(.secondary) - } - } - .padding() - } - .navigationTitle("Check Config") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - showCheckConfigSheet = false - selectedBeacon = nil - checkConfigData = nil - checkConfigError = nil - } - .disabled(isCheckingConfig) - } - } - } - .presentationDetents([.medium, .large]) - .interactiveDismissDisabled(isCheckingConfig) - } - - private func configRow(_ label: String, _ value: String) -> some View { - HStack(alignment: .top) { - Text(label) - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .leading) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - - // MARK: - Debug Log Sheet - - private var debugLogSheet: some View { - NavigationStack { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { idx, entry in - Text(entry) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - .id(idx) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - .onAppear { - if let last = debugLog.entries.indices.last { - proxy.scrollTo(last, anchor: .bottom) - } - } - } - .navigationTitle("Debug Log") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { showDebugLog = false } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - UIPasteboard.general.string = debugLog.allText - } label: { - Image(systemName: "doc.on.doc") - } - Button { - debugLog.clear() - } label: { - Image(systemName: "trash") - } - } - } - } - } - - // MARK: - Snack Overlay - - @ViewBuilder - private var snackOverlay: some View { - if let message = snackMessage { - Text(message) - .font(.callout) - .foregroundColor(.white) - .padding() - .background(Color(.darkGray)) - .cornerRadius(8) - .padding(.bottom, 100) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { - withAnimation { snackMessage = nil } - } - } - } - } - - private func showSnack(_ message: String) { - withAnimation { snackMessage = message } - } - - // MARK: - Actions - - private func loadServicePoints() { - Task { - // Load namespace (for display/debug) and service points - // Note: Namespace is no longer required for provisioning - we use get_beacon_config instead - do { - namespace = try await Api.shared.allocateBusinessNamespace(businessId: businessId) - DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)") - } catch { - DebugLog.shared.log("[ScanView] allocateBusinessNamespace error (non-critical): \(error)") - // Non-critical - provisioning will use get_beacon_config endpoint - } - - do { - servicePoints = try await Api.shared.listServicePoints(businessId: businessId) - DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points") - - // 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 = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? "" - return Int(numberStr) - }.max() ?? 0 - nextTableNumber = maxNumber + 1 - } catch { - DebugLog.shared.log("[ScanView] listServicePoints error: \(error)") - } - - // Auto-start scan - startScan() - } - } - - private func startScan() { - guard bleScanner.isBluetoothReady else { - showSnack("Bluetooth not available") - return - } - - // Start BLE scan for DX-Smart devices - bleScanner.startScanning() - - // Also start iBeacon ranging to detect configured beacons and their ownership - startIBeaconScan() - } - - private func startIBeaconScan() { - guard iBeaconScanner.hasPermissions() else { - // Request permission if needed - iBeaconScanner.requestPermission() - return - } - - // Start ranging for all Payfrit shard UUIDs - iBeaconScanner.startRanging(uuids: BeaconShardPool.uuids) - - // Collect results after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [self] in - let detected = iBeaconScanner.stopAndCollect() - detectedIBeacons = detected - DebugLog.shared.log("[ScanView] Detected \(detected.count) iBeacons") - - // Look up ownership for each detected iBeacon - Task { - await resolveBeaconOwnership(detected) - } - } - } - - private func resolveBeaconOwnership(_ beacons: [DetectedBeacon]) async { - for beacon in beacons { - let key = "\(beacon.uuid)|\(beacon.major)" - // Skip if we already have ownership info for this beacon - guard beaconOwnership[key] == nil else { continue } - - do { - // Format UUID with dashes for API call - let uuidWithDashes = beacon.uuid.uuidWithDashes - let result = try await Api.shared.resolveBusiness(uuid: uuidWithDashes, major: beacon.major) - await MainActor.run { - beaconOwnership[key] = (businessId: result.businessId, businessName: result.businessName) - DebugLog.shared.log("[ScanView] Resolved beacon \(beacon.major): \(result.businessName)") - } - } catch { - DebugLog.shared.log("[ScanView] Failed to resolve beacon \(beacon.major): \(error.localizedDescription)") - } - } - } - - /// Get ownership status for a detected iBeacon - private func getOwnershipStatus(for beacon: DetectedBeacon) -> (isOwned: Bool, displayText: String) { - let key = "\(beacon.uuid)|\(beacon.major)" - if let ownership = beaconOwnership[key] { - if ownership.businessId == businessId { - return (true, ownership.businessName) - } - } - return (false, "Unconfigured") - } - - private func checkConfig(_ beacon: DiscoveredBeacon) { - isCheckingConfig = true - checkConfigError = nil - checkConfigData = nil - showCheckConfigSheet = true - - // Stop scanning to avoid BLE interference - bleScanner.stopScanning() - - provisioner.readConfig(beacon: beacon) { data, error in - Task { @MainActor in - isCheckingConfig = false - if let data = data { - checkConfigData = data - } - if let error = error { - checkConfigError = error - } - } - } - } - - private func reprovisionBeacon() { - guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } - - // Stop scanning - bleScanner.stopScanning() - - isProvisioning = true - provisioningProgress = "Preparing..." - provisioningError = nil - showAssignSheet = true // Reuse assign sheet to show progress - assignName = sp.name - - DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) beacon=\(beacon.displayName)") - - Task { - do { - // Use the new unified get_beacon_config endpoint - provisioningProgress = "Getting beacon config..." - let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: sp.servicePointId) - - DebugLog.shared.log("[ScanView] reprovisionBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)") - - // Build config using server-provided values (NOT hardcoded) - let deviceName = config.servicePointName.isEmpty ? sp.name : config.servicePointName - let beaconConfig = BeaconConfig( - uuid: config.uuid, - major: config.major, - minor: config.minor, - measuredPower: config.measuredPower, - advInterval: config.advInterval, - txPower: config.txPower, - deviceName: deviceName - ) - - provisioningProgress = "Provisioning beacon..." - - provisioner.provision(beacon: beacon, config: beaconConfig) { result in - Task { @MainActor in - switch result { - case .success(let macAddress): - do { - // Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable - let uuidWithDashes = config.uuid.uuidWithDashes - let hardwareId = macAddress ?? uuidWithDashes - DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)") - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: sp.servicePointId, - uuid: uuidWithDashes, - major: config.major, - minor: config.minor, - hardwareId: hardwareId, - macAddress: macAddress - ) - finishProvisioning(name: sp.name) - } catch { - failProvisioning(error.localizedDescription) - } - case .failure(let error): - failProvisioning(error) - case .failureWithCode(let code, let detail): - let msg = detail ?? code.errorDescription ?? code.rawValue - DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)") - failProvisioning(msg) - } - } - } - } catch { - failProvisioning(error.localizedDescription) - } - } - } - - private func saveBeacon() { - guard let beacon = selectedBeacon else { return } - let name = assignName.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return } - - isProvisioning = true - provisioningProgress = "Preparing..." - provisioningError = nil - - // Stop scanning to avoid BLE interference - bleScanner.stopScanning() - - DebugLog.shared.log("[ScanView] saveBeacon: name=\(name) beacon=\(beacon.displayName) businessId=\(businessId)") - - Task { - do { - // 1. Reuse existing service point if name matches, otherwise create new - var servicePoint: ServicePoint - if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { - DebugLog.shared.log("[ScanView] saveBeacon: found existing SP id=\(existing.servicePointId)") - servicePoint = existing - } else { - provisioningProgress = "Creating service point..." - DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...") - servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) - DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId)") - } - - // 2. Use the new unified get_beacon_config endpoint (replaces allocate_business_namespace + allocate_servicepoint_minor) - provisioningProgress = "Getting beacon config..." - let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId) - - DebugLog.shared.log("[ScanView] saveBeacon: config uuid=\(config.uuid) major=\(config.major) minor=\(config.minor) measuredPower=\(config.measuredPower) advInterval=\(config.advInterval) txPower=\(config.txPower)") - - // 3. Build config using server-provided values (NOT hardcoded) - let deviceName = config.servicePointName.isEmpty ? name : config.servicePointName - let beaconConfig = BeaconConfig( - uuid: config.uuid, - major: config.major, - minor: config.minor, - measuredPower: config.measuredPower, - advInterval: config.advInterval, - txPower: config.txPower, - deviceName: deviceName - ) - - provisioningProgress = "Provisioning beacon..." - - // 4. Provision the beacon via GATT - provisioner.provision(beacon: beacon, config: beaconConfig) { result in - Task { @MainActor in - switch result { - case .success(let macAddress): - // Register in backend (use UUID with dashes for API) - do { - // Use MAC address as hardware ID, fallback to iBeacon UUID if unavailable - let uuidWithDashes = config.uuid.uuidWithDashes - let hardwareId = macAddress ?? uuidWithDashes - DebugLog.shared.log("[ScanView] Registering beacon - MAC: \(macAddress ?? "nil"), hardwareId: \(hardwareId), uuid: \(uuidWithDashes)") - try await Api.shared.registerBeaconHardware( - businessId: businessId, - servicePointId: servicePoint.servicePointId, - uuid: uuidWithDashes, - major: config.major, - minor: config.minor, - hardwareId: hardwareId, - macAddress: macAddress - ) - finishProvisioning(name: name) - } catch { - failProvisioning(error.localizedDescription) - } - case .failure(let error): - failProvisioning(error) - case .failureWithCode(let code, let detail): - let msg = detail ?? code.errorDescription ?? code.rawValue - DebugLog.shared.log("[ScanView] Provisioning failed [\(code.rawValue)]: \(msg)") - failProvisioning(msg) - } - } - } - } catch { - failProvisioning(error.localizedDescription) - } - } - } - - private func finishProvisioning(name: String) { - isProvisioning = false - provisioningProgress = "" - showAssignSheet = false - selectedBeacon = nil - - // 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 { - nextTableNumber += 1 - } - } else { - nextTableNumber += 1 - } - - provisionedCount += 1 - showSnack("Saved \"\(name)\"") - - // Remove beacon from list - if let beacon = selectedBeacon { - bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id } - } - } - - private func failProvisioning(_ error: String) { - DebugLog.shared.log("[ScanView] Provisioning failed: \(error)") - isProvisioning = false - provisioningProgress = "" - provisioningError = error - } - - // UUID formatting now handled by String.uuidWithDashes (UUIDFormatting.swift) -} diff --git a/PayfritBeacon/ServicePointListView.swift b/PayfritBeacon/ServicePointListView.swift deleted file mode 100644 index d70f663..0000000 --- a/PayfritBeacon/ServicePointListView.swift +++ /dev/null @@ -1,338 +0,0 @@ -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 - - // Beacon scan - @State private var showScanView = false - - // Re-provision existing service point - @State private var tappedServicePoint: ServicePoint? - @State private var reprovisionServicePoint: ServicePoint? - - - 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(ns.uuid.uuidWithDashes) - .font(.system(.caption, design: .monospaced)) - Button { - UIPasteboard.general.string = ns.uuid.uuidWithDashes - } 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 - Button { - tappedServicePoint = sp - } label: { - HStack { - Text(sp.name) - .foregroundColor(.primary) - Spacer() - if let minor = sp.beaconMinor { - Text("Minor: \(minor)") - .font(.caption) - .foregroundColor(.secondary) - } - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - } - } - } - } header: { - Text("Service Points") - } - } - } - } - .navigationTitle(businessName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Back", action: onBack) - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - showScanView = true - } label: { - Image(systemName: "antenna.radiowaves.left.and.right") - } - Button { - showAddSheet = true - } label: { - Image(systemName: "plus") - } - } - } - } - .onAppear { loadData() } - .sheet(isPresented: $showAddSheet) { addServicePointSheet } - .fullScreenCover(isPresented: $showScanView) { - ScanView( - businessId: businessId, - businessName: businessName, - onBack: { - showScanView = false - loadData() - } - ) - } - .fullScreenCover(item: $reprovisionServicePoint) { sp in - ScanView( - businessId: businessId, - businessName: businessName, - reprovisionServicePoint: sp, - onBack: { - reprovisionServicePoint = nil - loadData() - } - ) - } - .confirmationDialog( - tappedServicePoint?.name ?? "Service Point", - isPresented: Binding( - get: { tappedServicePoint != nil }, - set: { if !$0 { tappedServicePoint = nil } } - ), - titleVisibility: .visible - ) { - Button("Re-provision Beacon") { - reprovisionServicePoint = tappedServicePoint - tappedServicePoint = nil - } - Button("Delete", role: .destructive) { - if let sp = tappedServicePoint { - deleteServicePoint(sp) - } - tappedServicePoint = nil - } - Button("Cancel", role: .cancel) { - tappedServicePoint = nil - } - } - } - - // 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(ns.uuid.uuidWithDashes) - .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 deleteServicePoint(_ sp: ServicePoint) { - Task { - do { - try await Api.shared.deleteServicePoint(businessId: businessId, servicePointId: sp.servicePointId) - servicePoints.removeAll { $0.servicePointId == sp.servicePointId } - } catch { - errorMessage = error.localizedDescription - } - } - } - - 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 - } - - // UUID formatting now handled by String.uuidWithDashes (UUIDFormatting.swift) -} - diff --git a/PayfritBeacon/Services/APIClient.swift b/PayfritBeacon/Services/APIClient.swift new file mode 100644 index 0000000..1c04081 --- /dev/null +++ b/PayfritBeacon/Services/APIClient.swift @@ -0,0 +1,392 @@ +import Foundation + +/// Errors from API calls +enum APIError: LocalizedError { + case network(Error) + case httpError(Int) + case decodingError(Error) + case serverError(String) + case unauthorized + case noData + + var errorDescription: String? { + switch self { + case .network(let e): return "Network error: \(e.localizedDescription)" + case .httpError(let code): return "HTTP \(code)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .serverError(let msg): return msg + case .unauthorized: return "Session expired. Please log in again." + case .noData: return "No data received" + } + } +} + +/// Lightweight API client — all Payfrit backend calls +actor APIClient { + static let shared = APIClient() + + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForResource = APIConfig.readTimeout + config.timeoutIntervalForRequest = APIConfig.connectTimeout + self.session = URLSession(configuration: config) + } + + // MARK: - Auth + + struct OTPResponse: Codable { + let uuid: String? + let UUID: String? + var otpUUID: String { uuid ?? UUID ?? "" } + } + + func sendOTP(phone: String) async throws -> String { + let body: [String: Any] = ["ContactNumber": phone] + let data = try await post(path: "/auth/loginOTP.php", body: body) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let payload = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to send OTP") + } + return payload.otpUUID + } + + struct LoginResponse: Codable { + let token: String? + let Token: String? + let userId: String? + let UserID: String? + var authToken: String { token ?? Token ?? "" } + var authUserId: String { userId ?? UserID ?? "" } + } + + func verifyOTP(uuid: String, code: String) async throws -> (token: String, userId: String) { + let body: [String: Any] = ["UUID": uuid, "Code": code] + let data = try await post(path: "/auth/verifyLoginOTP.php", body: body) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let payload = resp.data else { + throw APIError.serverError(resp.message ?? "Invalid OTP") + } + return (payload.authToken, payload.authUserId) + } + + // MARK: - Businesses + + func listBusinesses(token: String) async throws -> [Business] { + let data = try await post(path: "/businesses/list.php", body: [:], token: token) + let resp = try JSONDecoder().decode(APIResponse<[Business]>.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to load businesses") + } + return resp.data ?? [] + } + + // MARK: - Service Points + + func listServicePoints(businessId: String, token: String) async throws -> [ServicePoint] { + let body: [String: Any] = ["BusinessID": businessId] + let data = try await post(path: "/servicepoints/list.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse<[ServicePoint]>.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to load service points") + } + return resp.data ?? [] + } + + func createServicePoint(name: String, businessId: String, token: String) async throws -> ServicePoint { + let body: [String: Any] = ["Name": name, "BusinessID": businessId] + let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let sp = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to create service point") + } + return sp + } + + // MARK: - Beacon Sharding + + struct NamespaceResponse: Codable { + let uuid: String? + let UUID: String? + let major: Int? + let Major: Int? + var shardUUID: String { uuid ?? UUID ?? "" } + var shardMajor: Int { major ?? Major ?? 0 } + } + + func allocateBusinessNamespace(businessId: String, token: String) async throws -> (uuid: String, major: Int) { + let body: [String: Any] = ["BusinessID": businessId] + let data = try await post(path: "/beacon-sharding/allocate_business_namespace.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let ns = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to allocate namespace") + } + return (ns.shardUUID, ns.shardMajor) + } + + struct MinorResponse: Codable { + let minor: Int? + let Minor: Int? + var allocated: Int { minor ?? Minor ?? 0 } + } + + func allocateMinor(businessId: String, servicePointId: String, token: String) async throws -> Int { + let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId] + let data = try await post(path: "/beacon-sharding/allocate_servicepoint_minor.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let m = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to allocate minor") + } + return m.allocated + } + + func registerBeaconHardware( + businessId: String, + servicePointId: String, + uuid: String, + major: Int, + minor: Int, + macAddress: String?, + beaconType: String, + token: String + ) async throws { + var body: [String: Any] = [ + "BusinessID": businessId, + "ServicePointID": servicePointId, + "UUID": uuid, + "Major": major, + "Minor": minor, + "BeaconType": beaconType + ] + if let mac = macAddress { body["MacAddress"] = mac } + let data = try await post(path: "/beacon-sharding/register_beacon_hardware.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to register beacon") + } + } + + func verifyBeaconBroadcast( + uuid: String, + major: Int, + minor: Int, + token: String + ) async throws { + let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor] + let data = try await post(path: "/beacon-sharding/verify_beacon_broadcast.php", body: body, token: token) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to verify broadcast") + } + } + + struct ResolveResponse: Codable { + let businessName: String? + let BusinessName: String? + var name: String { businessName ?? BusinessName ?? "Unknown" } + } + + func resolveBusiness(uuid: String, major: Int, token: String) async throws -> String { + let body: [String: Any] = ["UUID": uuid, "Major": major] + let data = try await post(path: "/beacon-sharding/resolve_business.php", body: body, token: token) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + return resp.data?.name ?? "Unknown" + } + + // MARK: - Service Point Management + + func deleteServicePoint(servicePointId: String, businessId: String, token: String) async throws { + let body: [String: Any] = ["ID": servicePointId, "BusinessID": businessId] + let data = try await post(path: "/servicepoints/delete.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to delete service point") + } + } + + func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws { + let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId] + let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to update service point") + } + } + + // MARK: - Beacon Management + + struct BeaconListResponse: Codable { + let id: String? + let ID: String? + let uuid: String? + let UUID: String? + let major: Int? + let Major: Int? + let minor: Int? + let Minor: Int? + let macAddress: String? + let MacAddress: String? + let beaconType: String? + let BeaconType: String? + let servicePointId: String? + let ServicePointID: String? + let isVerified: Bool? + let IsVerified: Bool? + } + + func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] { + let body: [String: Any] = ["BusinessID": businessId] + let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to list beacons") + } + return resp.data ?? [] + } + + func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws { + let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId] + let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success else { + throw APIError.serverError(resp.message ?? "Failed to decommission beacon") + } + } + + func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? { + let body: [String: Any] = ["MacAddress": macAddress] + let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + return resp.data + } + + func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? { + let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor] + let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + return resp.data + } + + // MARK: - Beacon Config (server-configured values) + + struct BeaconConfigResponse: Codable { + let uuid: String? + let UUID: String? + let major: Int? + let Major: Int? + let minor: Int? + let Minor: Int? + let txPower: Int? + let TxPower: Int? + let measuredPower: Int? + let MeasuredPower: Int? + let advInterval: Int? + let AdvInterval: Int? + + var configUUID: String { uuid ?? UUID ?? "" } + var configMajor: Int { major ?? Major ?? 0 } + var configMinor: Int { minor ?? Minor ?? 0 } + var configTxPower: Int { txPower ?? TxPower ?? 1 } + var configMeasuredPower: Int { measuredPower ?? MeasuredPower ?? -100 } + var configAdvInterval: Int { advInterval ?? AdvInterval ?? 2 } + } + + func getBeaconConfig(businessId: String, servicePointId: String, token: String) async throws -> BeaconConfigResponse { + let body: [String: Any] = ["BusinessID": businessId, "ServicePointID": servicePointId] + let data = try await post(path: "/beacon-sharding/get_beacon_config.php", body: body, token: token, businessId: businessId) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let config = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to get beacon config") + } + return config + } + + // MARK: - User Profile + + struct UserProfile: Codable { + let id: String? + let ID: String? + let firstName: String? + let FirstName: String? + let lastName: String? + let LastName: String? + let contactNumber: String? + let ContactNumber: String? + } + + func getProfile(token: String) async throws -> UserProfile { + let data = try await post(path: "/users/profile.php", body: [:], token: token) + let resp = try JSONDecoder().decode(APIResponse.self, from: data) + guard resp.success, let profile = resp.data else { + throw APIError.serverError(resp.message ?? "Failed to load profile") + } + return profile + } + + // MARK: - Internal + + private struct EmptyData: Codable {} + + private struct APIResponse: Codable { + let success: Bool + let message: String? + let data: T? + + enum CodingKeys: String, CodingKey { + case success = "Success" + case message = "Message" + case data = "Data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Handle both bool and int/string for Success + if let b = try? container.decode(Bool.self, forKey: .success) { + success = b + } else if let i = try? container.decode(Int.self, forKey: .success) { + success = i != 0 + } else { + success = false + } + message = try? container.decode(String.self, forKey: .message) + data = try? container.decode(T.self, forKey: .data) + } + } + + private func post( + path: String, + body: [String: Any], + token: String? = nil, + businessId: String? = nil + ) async throws -> Data { + guard let url = URL(string: APIConfig.baseURL + path) else { + throw APIError.serverError("Invalid URL: \(path)") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let token = token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(token, forHTTPHeaderField: "X-User-Token") + } + if let bizId = businessId { + request.setValue(bizId, forHTTPHeaderField: "X-Business-Id") + } + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { throw APIError.unauthorized } + guard (200...299).contains(http.statusCode) else { + throw APIError.httpError(http.statusCode) + } + } + + return data + } +} diff --git a/PayfritBeacon/Services/APIConfig.swift b/PayfritBeacon/Services/APIConfig.swift new file mode 100644 index 0000000..44e1884 --- /dev/null +++ b/PayfritBeacon/Services/APIConfig.swift @@ -0,0 +1,17 @@ +import Foundation + +/// API configuration — matches Android's dev/prod flavor setup +enum APIConfig { + #if DEBUG + static let baseURL = "https://dev.payfrit.com/api" + static let imageBaseURL = "https://dev.payfrit.com" + static let isDev = true + #else + static let baseURL = "https://biz.payfrit.com/api" + static let imageBaseURL = "https://biz.payfrit.com" + static let isDev = false + #endif + + static let connectTimeout: TimeInterval = 10 + static let readTimeout: TimeInterval = 30 +} diff --git a/PayfritBeacon/Services/BLEManager.swift b/PayfritBeacon/Services/BLEManager.swift new file mode 100644 index 0000000..b633bfb --- /dev/null +++ b/PayfritBeacon/Services/BLEManager.swift @@ -0,0 +1,202 @@ +import Foundation +import CoreBluetooth +import Combine + +/// Central BLE manager — handles scanning and beacon type detection +/// Matches Android's BeaconScanner.kt behavior +@MainActor +final class BLEManager: NSObject, ObservableObject { + + // MARK: - Published State + + @Published var isScanning = false + @Published var discoveredBeacons: [DiscoveredBeacon] = [] + @Published var bluetoothState: CBManagerState = .unknown + + // MARK: - Constants (matching Android) + + static let scanDuration: TimeInterval = 5.0 + static let verifyScanDuration: TimeInterval = 15.0 + static let verifyPollInterval: TimeInterval = 0.5 + + // GATT Service UUIDs + static let ffe0Service = CBUUID(string: "0000FFE0-0000-1000-8000-00805F9B34FB") + static let fff0Service = CBUUID(string: "0000FFF0-0000-1000-8000-00805F9B34FB") + + // MARK: - Private + + private(set) var centralManager: CBCentralManager! + private var scanTimer: Timer? + private var scanContinuation: CheckedContinuation<[DiscoveredBeacon], Never>? + + // MARK: - Init + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: .main) + } + + // MARK: - Scanning + + /// Scan for beacons for the given duration. Returns discovered beacons sorted by RSSI. + func scan(duration: TimeInterval = scanDuration) async -> [DiscoveredBeacon] { + guard bluetoothState == .poweredOn else { return [] } + + discoveredBeacons = [] + isScanning = true + + let results = await withCheckedContinuation { (continuation: CheckedContinuation<[DiscoveredBeacon], Never>) in + scanContinuation = continuation + + centralManager.scanForPeripherals(withServices: nil, options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: true + ]) + + scanTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.stopScan() + } + } + } + + return results.sorted { $0.rssi > $1.rssi } + } + + func stopScan() { + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + isScanning = false + + let results = discoveredBeacons + if let cont = scanContinuation { + scanContinuation = nil + cont.resume(returning: results) + } + } + + /// Verify a beacon is broadcasting expected iBeacon values. + /// Scans for up to `verifyScanDuration` looking for matching UUID/Major/Minor. + func verifyBroadcast(uuid: String, major: UInt16, minor: UInt16) async -> VerifyResult { + // TODO: Implement iBeacon region monitoring via CLLocationManager + // CoreLocation's CLBeaconRegion is the iOS-native way to detect iBeacon broadcasts + // For now, return a placeholder that prompts manual verification + return VerifyResult( + found: false, + rssi: nil, + message: "iBeacon verification requires CLLocationManager — coming soon" + ) + } + + // MARK: - Beacon Type Detection (matches Android BeaconScanner.kt) + // NOTE: Android also detects DX-Smart by MAC OUI prefix 48:87:2D. + // CoreBluetooth does not expose raw MAC addresses, so this detection + // path is unavailable on iOS. We rely on service UUID + device name instead. + + func detectBeaconType( + name: String?, + serviceUUIDs: [CBUUID]?, + manufacturerData: Data? + ) -> BeaconType { + let deviceName = (name ?? "").lowercased() + + // 1. Service UUID matching + if let services = serviceUUIDs { + let serviceStrings = services.map { $0.uuidString.uppercased() } + if serviceStrings.contains(where: { $0.hasPrefix("0000FFF0") }) { + return .bluecharm + } + if serviceStrings.contains(where: { $0.hasPrefix("0000FFE0") }) { + // Could be KBeacon or DXSmart — check name to differentiate + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx") || deviceName.contains("pddaxlque") { + return .dxsmart + } + return .kbeacon + } + } + + // 2. Device name patterns + if deviceName.contains("kbeacon") || deviceName.contains("kbpro") || + deviceName.hasPrefix("kb") { + return .kbeacon + } + if deviceName.contains("bluecharm") || deviceName.hasPrefix("bc") || + deviceName.hasPrefix("table-") { + return .bluecharm + } + if deviceName.contains("cp28") || deviceName.contains("cp-28") || + deviceName.contains("dx-smart") || deviceName.contains("dxsmart") || + deviceName.contains("pddaxlque") { + return .dxsmart + } + + // 3. Generic beacon patterns + if deviceName.contains("ibeacon") || deviceName.contains("beacon") || + deviceName.hasPrefix("ble") { + return .dxsmart // Default to DXSmart like Android + } + + // 4. Check manufacturer data for iBeacon advertisement + if let mfgData = manufacturerData, mfgData.count >= 23 { + // Apple iBeacon prefix: 0x4C00 0215 + if mfgData.count >= 4 && mfgData[0] == 0x4C && mfgData[1] == 0x00 && + mfgData[2] == 0x02 && mfgData[3] == 0x15 { + // Extract minor (bytes 22-23) — high minors suggest DXSmart factory defaults + if mfgData.count >= 24 { + let minorVal = UInt16(mfgData[22]) << 8 | UInt16(mfgData[23]) + if minorVal > 10000 { return .dxsmart } + } + return .kbeacon + } + } + + return .unknown + } +} + +// MARK: - CBCentralManagerDelegate + +extension BLEManager: CBCentralManagerDelegate { + + nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { + Task { @MainActor in + bluetoothState = central.state + } + } + + nonisolated func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + Task { @MainActor in + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "" + let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] + let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + + let type = detectBeaconType(name: name, serviceUUIDs: serviceUUIDs, manufacturerData: mfgData) + + // Only show recognized beacons + guard type != .unknown else { return } + + if let idx = discoveredBeacons.firstIndex(where: { $0.id == peripheral.identifier }) { + // Update existing + discoveredBeacons[idx].rssi = RSSI.intValue + discoveredBeacons[idx].lastSeen = Date() + } else { + // New beacon + let beacon = DiscoveredBeacon( + id: peripheral.identifier, + peripheral: peripheral, + name: name, + type: type, + rssi: RSSI.intValue, + lastSeen: Date() + ) + discoveredBeacons.append(beacon) + } + } + } +} diff --git a/PayfritBeacon/Services/SecureStorage.swift b/PayfritBeacon/Services/SecureStorage.swift new file mode 100644 index 0000000..83b792a --- /dev/null +++ b/PayfritBeacon/Services/SecureStorage.swift @@ -0,0 +1,68 @@ +import Foundation +import Security + +/// Keychain-backed secure storage for auth tokens +enum SecureStorage { + + private static let service = "com.payfrit.beacon" + + struct Session { + let token: String + let userId: String + } + + static func saveSession(token: String, userId: String) { + save(key: "authToken", value: token) + save(key: "userId", value: userId) + } + + static func loadSession() -> Session? { + guard let token = load(key: "authToken"), + let userId = load(key: "userId") else { return nil } + return Session(token: token, userId: userId) + } + + static func clearSession() { + delete(key: "authToken") + delete(key: "userId") + } + + // MARK: - Keychain Helpers + + private static func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + delete(key: key) // Remove old value first + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + SecItemAdd(query as CFDictionary, nil) + } + + private static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + 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 static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/PayfritBeacon/UUIDFormatting.swift b/PayfritBeacon/UUIDFormatting.swift deleted file mode 100644 index 27d141d..0000000 --- a/PayfritBeacon/UUIDFormatting.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -// MARK: - UUID Formatting Utilities -// Consolidated UUID normalization and formatting logic. -// Previously duplicated across Api.swift, BeaconScanner.swift, ScanView.swift, -// ServicePointListView.swift, and BeaconBanList.swift. - -extension String { - - /// Normalize a UUID string: strip dashes, uppercase. - /// Input: "e2c56db5-dffb-48d2-b060-d0f5a71096e0" or "E2C56DB5DFFB48D2B060D0F5A71096E0" - /// Output: "E2C56DB5DFFB48D2B060D0F5A71096E0" - var normalizedUUID: String { - replacingOccurrences(of: "-", with: "").uppercased() - } - - /// Format a 32-char hex string into standard UUID format (8-4-4-4-12). - /// Input: "E2C56DB5DFFB48D2B060D0F5A71096E0" - /// Output: "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0" - /// Returns the original string unchanged if it's not exactly 32 hex chars after normalization. - var uuidWithDashes: String { - let clean = normalizedUUID - guard clean.count == 32 else { return self } - let c = Array(clean) - return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" - } -} diff --git a/PayfritBeacon/BeaconBanList.swift b/PayfritBeacon/Utils/BeaconBanList.swift similarity index 54% rename from PayfritBeacon/BeaconBanList.swift rename to PayfritBeacon/Utils/BeaconBanList.swift index facfec6..1d39e64 100644 --- a/PayfritBeacon/BeaconBanList.swift +++ b/PayfritBeacon/Utils/BeaconBanList.swift @@ -1,72 +1,57 @@ import Foundation +/// Factory-default UUIDs that indicate an unconfigured beacon +/// Matches Android BeaconBanList.kt exactly enum BeaconBanList { - /// Known default UUID prefixes (first 8 hex chars of the 32-char UUID). - private static let BANNED_PREFIXES: [String: String] = [ - // Apple AirLocate / Minew factory default + /// UUID prefixes (first 8 hex chars) that are factory defaults + /// Key = uppercase prefix, Value = reason + private static let bannedPrefixes: [String: String] = [ "E2C56DB5": "Apple AirLocate / Minew factory default", - // Kontakt.io default "F7826DA6": "Kontakt.io factory default", - // Radius Networks default "2F234454": "Radius Networks default", - // Estimote default "B9407F30": "Estimote factory default", - // Generic Chinese bulk manufacturer defaults (also Feasycom) "FDA50693": "Generic bulk / Feasycom factory default", "74278BDA": "Generic bulk manufacturer default", "8492E75F": "Generic bulk manufacturer default", "A0B13730": "Generic bulk manufacturer default", - // JAALEE default "EBEFD083": "JAALEE factory default", - // April Brother default "B5B182C7": "April Brother factory default", - // BlueCharm / unconfigured "00000000": "Unconfigured / zeroed UUID", "FFFFFFFF": "Unconfigured / max UUID", ] - /// Full UUIDs that are known defaults (exact match on 32-char uppercase hex). - private static let BANNED_FULL_UUIDS: [String: String] = [ + /// Full UUIDs that are known defaults (exact match on 32-char uppercase hex, no dashes) + private static let bannedFullUUIDs: [String: String] = [ "E2C56DB5DFFB48D2B060D0F5A71096E0": "Apple AirLocate sample UUID", "B9407F30F5F8466EAFF925556B57FE6D": "Estimote factory default", "2F234454CF6D4A0FADF2F4911BA9FFA6": "Radius Networks default", "FDA50693A4E24FB1AFCFC6EB07647825": "Generic Chinese bulk default", "74278BDAB64445208F0C720EAF059935": "Generic bulk default", - "00000000000000000000000000000000": "Zeroed UUID \u{2014} unconfigured hardware", + "00000000000000000000000000000000": "Zeroed UUID — unconfigured hardware", ] - /// Check if a UUID is on the ban list. + /// Check if a UUID is a factory default static func isBanned(_ uuid: String) -> Bool { let normalized = uuid.normalizedUUID // Check full UUID match - if BANNED_FULL_UUIDS[normalized] != nil { return true } + if bannedFullUUIDs[normalized] != nil { return true } - // Check prefix match (first 8 chars) + // Check prefix match let prefix = String(normalized.prefix(8)) - if BANNED_PREFIXES[prefix] != nil { return true } - - return false + return bannedPrefixes[prefix] != nil } - /// Get the reason a UUID is banned, or nil if not banned. + /// Get the reason a UUID is banned, or nil if not banned static func getBanReason(_ uuid: String) -> String? { let normalized = uuid.normalizedUUID // Check full UUID match first - if let reason = BANNED_FULL_UUIDS[normalized] { return reason } + if let reason = bannedFullUUIDs[normalized] { return reason } // Check prefix let prefix = String(normalized.prefix(8)) - if let reason = BANNED_PREFIXES[prefix] { return reason } - - return nil - } - - /// Format a raw UUID string into standard UUID format with dashes. - /// Delegates to String.uuidWithDashes (UUIDFormatting.swift). - static func formatUuid(_ uuid: String) -> String { - uuid.uuidWithDashes + return bannedPrefixes[prefix] } } diff --git a/PayfritBeacon/Utils/BeaconShardPool.swift b/PayfritBeacon/Utils/BeaconShardPool.swift new file mode 100644 index 0000000..eff8b53 --- /dev/null +++ b/PayfritBeacon/Utils/BeaconShardPool.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Pre-allocated Payfrit shard UUIDs for business namespace allocation +/// Exact copy of Android's BeaconShardPool.kt (64 UUIDs) +enum BeaconShardPool { + + static let shardUUIDs: [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", + ] + + /// Check if a UUID is a Payfrit shard + static func isPayfrit(_ uuid: String) -> Bool { + shardUUIDs.contains(where: { $0.caseInsensitiveCompare(uuid) == .orderedSame }) + } +} diff --git a/PayfritBeacon/Utils/UUIDFormatting.swift b/PayfritBeacon/Utils/UUIDFormatting.swift new file mode 100644 index 0000000..d486a1a --- /dev/null +++ b/PayfritBeacon/Utils/UUIDFormatting.swift @@ -0,0 +1,32 @@ +import Foundation + +extension String { + + /// Strip dashes, uppercase: "e2c56db5-..." → "E2C56DB5..." + var normalizedUUID: String { + replacingOccurrences(of: "-", with: "").uppercased() + } + + /// Format 32 hex chars → standard UUID (8-4-4-4-12) + var uuidWithDashes: String { + let clean = normalizedUUID + guard clean.count == 32 else { return self } + let c = Array(clean) + return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20..<32]))" + } + + /// Convert hex string to byte array + var hexToBytes: [UInt8] { + let clean = normalizedUUID + var bytes: [UInt8] = [] + var i = clean.startIndex + while i < clean.endIndex { + let next = clean.index(i, offsetBy: 2, limitedBy: clean.endIndex) ?? clean.endIndex + if let byte = UInt8(clean[i.. Void + + @State private var scannedCode: String? + @State private var isFlashOn = false + @State private var cameraPermission: CameraPermission = .undetermined + + enum CameraPermission { + case undetermined, granted, denied + } + + var body: some View { + NavigationStack { + ZStack { + if cameraPermission == .granted { + CameraPreview(onCodeDetected: handleDetection, isFlashOn: $isFlashOn) + .ignoresSafeArea() + + // Scan overlay + scanOverlay + } else if cameraPermission == .denied { + cameraPermissionDenied + } else { + Color.black.ignoresSafeArea() + .overlay { + ProgressView("Requesting camera…") + .tint(.white) + .foregroundStyle(.white) + } + } + } + .navigationTitle("Scan QR / Barcode") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + isFlashOn.toggle() + } label: { + Image(systemName: isFlashOn ? "flashlight.on.fill" : "flashlight.off.fill") + .foregroundStyle(isFlashOn ? .yellow : .white) + } + } + } + .task { + await checkCameraPermission() + } + .alert("Code Detected", isPresented: .init( + get: { scannedCode != nil }, + set: { if !$0 { scannedCode = nil } } + )) { + Button("Use This") { + if let code = scannedCode { + onScan(code) + dismiss() + } + } + Button("Scan Again", role: .cancel) { + scannedCode = nil + } + } message: { + Text(scannedCode ?? "") + } + } + } + + // MARK: - Scan Overlay + + private var scanOverlay: some View { + VStack { + Spacer() + + // Viewfinder frame + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.8), lineWidth: 2) + .frame(width: 280, height: 280) + .overlay { + // Corner accents + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let corner: CGFloat = 30 + let thickness: CGFloat = 4 + + // Top-left + Path { p in + p.move(to: CGPoint(x: 0, y: corner)) + p.addLine(to: CGPoint(x: 0, y: 0)) + p.addLine(to: CGPoint(x: corner, y: 0)) + } + .stroke(.blue, lineWidth: thickness) + + // Top-right + Path { p in + p.move(to: CGPoint(x: w - corner, y: 0)) + p.addLine(to: CGPoint(x: w, y: 0)) + p.addLine(to: CGPoint(x: w, y: corner)) + } + .stroke(.blue, lineWidth: thickness) + + // Bottom-left + Path { p in + p.move(to: CGPoint(x: 0, y: h - corner)) + p.addLine(to: CGPoint(x: 0, y: h)) + p.addLine(to: CGPoint(x: corner, y: h)) + } + .stroke(.blue, lineWidth: thickness) + + // Bottom-right + Path { p in + p.move(to: CGPoint(x: w - corner, y: h)) + p.addLine(to: CGPoint(x: w, y: h)) + p.addLine(to: CGPoint(x: w, y: h - corner)) + } + .stroke(.blue, lineWidth: thickness) + } + } + + Spacer() + + // Instructions + Text("Point camera at beacon QR code or barcode") + .font(.subheadline) + .foregroundStyle(.white) + .padding() + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, 40) + } + } + + private var cameraPermissionDenied: some View { + ContentUnavailableView { + Label("Camera Access Required", systemImage: "camera.fill") + } description: { + Text("Open Settings and enable camera access for Payfrit Beacon.") + } actions: { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Actions + + private func checkCameraPermission() async { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + cameraPermission = .granted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + cameraPermission = granted ? .granted : .denied + default: + cameraPermission = .denied + } + } + + private func handleDetection(_ code: String) { + guard scannedCode == nil else { return } // Debounce + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + scannedCode = code + } +} + +// MARK: - Camera Preview (UIViewRepresentable) + +/// AVFoundation camera preview with barcode/QR detection via Vision framework +struct CameraPreview: UIViewRepresentable { + let onCodeDetected: (String) -> Void + @Binding var isFlashOn: Bool + + func makeUIView(context: Context) -> CameraPreviewUIView { + let view = CameraPreviewUIView(onCodeDetected: onCodeDetected) + return view + } + + func updateUIView(_ uiView: CameraPreviewUIView, context: Context) { + uiView.setFlash(isFlashOn) + } +} + +final class CameraPreviewUIView: UIView { + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private let onCodeDetected: (String) -> Void + private var lastDetectionTime: Date = .distantPast + private let debounceInterval: TimeInterval = 1.5 + + init(onCodeDetected: @escaping (String) -> Void) { + self.onCodeDetected = onCodeDetected + super.init(frame: .zero) + setupCamera() + } + + required init?(coder: NSCoder) { fatalError() } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } + + func setFlash(_ on: Bool) { + guard let device = AVCaptureDevice.default(for: .video), + device.hasTorch else { return } + try? device.lockForConfiguration() + device.torchMode = on ? .on : .off + device.unlockForConfiguration() + } + + private func setupCamera() { + let session = AVCaptureSession() + session.sessionPreset = .high + + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let input = try? AVCaptureDeviceInput(device: device) else { return } + + if session.canAddInput(input) { + session.addInput(input) + } + + // Use AVCaptureMetadataOutput for barcode detection (more reliable than Vision for barcodes) + let metadataOutput = AVCaptureMetadataOutput() + if session.canAddOutput(metadataOutput) { + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [ + .qr, + .ean8, .ean13, + .code128, .code39, .code93, + .dataMatrix, + .pdf417, + .aztec + ] + } + + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + layer.addSublayer(previewLayer) + self.previewLayer = previewLayer + + self.captureSession = session + + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + deinit { + captureSession?.stopRunning() + setFlash(false) + } +} + +// MARK: - AVCaptureMetadataOutputObjectsDelegate + +extension CameraPreviewUIView: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = metadata.stringValue, + !value.isEmpty else { return } + + // Debounce rapid detections + let now = Date() + guard now.timeIntervalSince(lastDetectionTime) > debounceInterval else { return } + lastDetectionTime = now + + onCodeDetected(value) + } +} diff --git a/PayfritBeacon/Views/RootView.swift b/PayfritBeacon/Views/RootView.swift new file mode 100644 index 0000000..e878705 --- /dev/null +++ b/PayfritBeacon/Views/RootView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// Root navigation — switches between Login, BusinessList, and Scan screens +struct RootView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + ZStack { + switch appState.currentScreen { + case .login: + LoginView() + .transition(.opacity) + + case .businessList: + BusinessListView() + .transition(.move(edge: .trailing)) + + case .scan(let business): + ScanView(business: business) + .transition(.move(edge: .trailing)) + } + } + .animation(.easeInOut(duration: 0.3), value: screenKey) + .overlay(alignment: .top) { + if APIConfig.isDev { + DevBanner() + } + } + } + + /// Stable key for animation + private var screenKey: String { + switch appState.currentScreen { + case .login: return "login" + case .businessList: return "businesses" + case .scan(let b): return "scan-\(b.id)" + } + } +} diff --git a/PayfritBeacon/Views/ScanView.swift b/PayfritBeacon/Views/ScanView.swift new file mode 100644 index 0000000..86bfc41 --- /dev/null +++ b/PayfritBeacon/Views/ScanView.swift @@ -0,0 +1,770 @@ +import SwiftUI +import CoreBluetooth + +/// Main provisioning screen (matches Android ScanActivity) +/// Flow: Select/create service point → Scan for beacons → Connect → Provision → Verify +struct ScanView: View { + @EnvironmentObject var appState: AppState + + let business: Business + + @StateObject private var bleManager = BLEManager() + + // MARK: - State + + @State private var servicePoints: [ServicePoint] = [] + @State private var selectedServicePoint: ServicePoint? + @State private var showCreateServicePoint = false + @State private var newServicePointName = "" + + @State private var namespace: (uuid: String, major: Int)? + @State private var isLoadingNamespace = false + + // Provisioning flow + @State private var selectedBeacon: DiscoveredBeacon? + @State private var provisioningState: ProvisioningState = .idle + @State private var statusMessage = "" + @State private var errorMessage: String? + @State private var showQRScanner = false + @State private var scannedMAC: String? + + enum ProvisioningState { + case idle + case scanning + case connecting + case connected // DXSmart: beacon flashing, waiting for user to tap "Write" + case writing + case verifying + case done + case failed + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Service Point Picker + servicePointSection + + Divider() + + // Beacon Scanner / Provisioning + if selectedServicePoint != nil { + if namespace != nil { + beaconSection + } else { + namespaceLoadingView + } + } else { + selectServicePointPrompt + } + } + .navigationTitle(business.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Back") { + appState.backToBusinessList() + } + } + } + .sheet(isPresented: $showCreateServicePoint) { + createServicePointSheet + } + .sheet(isPresented: $showQRScanner) { + QRScannerView { code in + handleQRScan(code) + } + } + } + .task { + await loadServicePoints() + await loadNamespace() + } + } + + // MARK: - Service Point Section + + private var servicePointSection: some View { + VStack(spacing: 12) { + HStack { + Text("Service Point") + .font(.headline) + Spacer() + Button { + suggestServicePointName() + showCreateServicePoint = true + } label: { + Label("Add", systemImage: "plus") + .font(.subheadline) + } + } + + if servicePoints.isEmpty { + Text("No service points — create one to get started") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(servicePoints) { sp in + Button { + selectedServicePoint = sp + resetProvisioningState() + } label: { + Text(sp.name) + .font(.subheadline.weight(selectedServicePoint?.id == sp.id ? .semibold : .regular)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + selectedServicePoint?.id == sp.id ? Color.blue : Color(.systemGray5), + in: Capsule() + ) + .foregroundStyle(selectedServicePoint?.id == sp.id ? .white : .primary) + } + } + } + } + } + } + .padding() + } + + private var selectServicePointPrompt: some View { + ContentUnavailableView( + "Select a Service Point", + systemImage: "mappin.and.ellipse", + description: Text("Choose or create a service point (table) to provision a beacon for.") + ) + } + + // MARK: - Namespace Loading + + private var namespaceLoadingView: some View { + VStack(spacing: 16) { + if isLoadingNamespace { + ProgressView("Allocating beacon namespace…") + } else { + ContentUnavailableView { + Label("Namespace Error", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage ?? "Failed to allocate beacon namespace") + } actions: { + Button("Retry") { Task { await loadNamespace() } } + } + } + } + .frame(maxHeight: .infinity) + } + + // MARK: - Beacon Scanner Section + + private var beaconSection: some View { + VStack(spacing: 0) { + // Status bar + statusBar + + // Beacon list or provisioning progress + switch provisioningState { + case .idle, .scanning: + beaconList + + case .connecting: + progressView(title: "Connecting…", message: statusMessage) + + case .connected: + // DXSmart: beacon is flashing, show write button + dxsmartConnectedView + + case .writing: + progressView(title: "Writing Config…", message: statusMessage) + + case .verifying: + progressView(title: "Verifying…", message: statusMessage) + + case .done: + successView + + case .failed: + failedView + } + } + } + + private var statusBar: some View { + HStack { + if let ns = namespace { + VStack(alignment: .leading, spacing: 2) { + Text("Shard: \(ns.uuid.prefix(8))… / Major: \(ns.major)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + + Spacer() + + if bleManager.bluetoothState != .poweredOn { + Label("Bluetooth Off", systemImage: "bluetooth.slash") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + } + + // MARK: - Beacon List + + private var beaconList: some View { + VStack(spacing: 0) { + // Scan buttons + HStack(spacing: 12) { + Button { + Task { await startScan() } + } label: { + HStack { + Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass") + Text(bleManager.isScanning ? "Scanning…" : "BLE Scan") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn) + + Button { + showQRScanner = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder") + Text("QR Scan") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding() + + if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning { + ContentUnavailableView( + "No Beacons Found", + systemImage: "antenna.radiowaves.left.and.right.slash", + description: Text("Tap Scan to search for nearby beacons") + ) + } else { + List(bleManager.discoveredBeacons) { beacon in + Button { + selectedBeacon = beacon + Task { await startProvisioning(beacon) } + } label: { + BeaconRow(beacon: beacon) + } + .disabled(provisioningState != .idle) + } + .listStyle(.plain) + } + } + } + + // MARK: - DXSmart Connected View + + private var dxsmartConnectedView: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "light.beacon.max") + .font(.system(size: 64)) + .foregroundStyle(.orange) + .symbolEffect(.pulse) + + Text("Beacon is Flashing") + .font(.title2.bold()) + + Text("Confirm the beacon LED is flashing, then tap Write Config to program it.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + Task { await writeConfigToConnectedBeacon() } + } label: { + HStack { + Image(systemName: "arrow.down.doc") + Text("Write Config") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .controlSize(.large) + .padding(.horizontal, 32) + + Button("Cancel") { + cancelProvisioning() + } + .foregroundStyle(.secondary) + + Spacer() + } + } + + // MARK: - Progress / Success / Failed Views + + private func progressView(title: String, message: String) -> some View { + VStack(spacing: 16) { + Spacer() + ProgressView() + .scaleEffect(1.5) + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + } + + private var successView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + Text("Beacon Provisioned!") + .font(.title2.bold()) + Text(statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Provision Another") { + resetProvisioningState() + } + .buttonStyle(.borderedProminent) + Spacer() + } + } + + private var failedView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "xmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.red) + Text("Provisioning Failed") + .font(.title2.bold()) + Text(errorMessage ?? "Unknown error") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + HStack(spacing: 16) { + Button("Try Again") { + if let beacon = selectedBeacon { + Task { await startProvisioning(beacon) } + } + } + .buttonStyle(.borderedProminent) + + Button("Register Anyway") { + Task { await registerAnywayAfterFailure() } + } + .buttonStyle(.bordered) + } + + Button("Cancel") { + resetProvisioningState() + } + .foregroundStyle(.secondary) + Spacer() + } + } + + // MARK: - Create Service Point Sheet + + private var createServicePointSheet: some View { + NavigationStack { + Form { + TextField("Name (e.g. Table 1)", text: $newServicePointName) + } + .navigationTitle("New Service Point") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showCreateServicePoint = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await createServicePoint() } + } + .disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + .presentationDetents([.medium]) + } + + // MARK: - Actions + + private func loadServicePoints() async { + guard let token = appState.token else { return } + do { + servicePoints = try await APIClient.shared.listServicePoints( + businessId: business.id, token: token + ) + } catch { + errorMessage = error.localizedDescription + } + } + + private func loadNamespace() async { + guard let token = appState.token else { return } + isLoadingNamespace = true + do { + let ns = try await APIClient.shared.allocateBusinessNamespace( + businessId: business.id, token: token + ) + namespace = ns + } catch { + errorMessage = error.localizedDescription + } + isLoadingNamespace = false + } + + private func createServicePoint() async { + guard let token = appState.token else { return } + let name = newServicePointName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + + do { + let sp = try await APIClient.shared.createServicePoint( + name: name, businessId: business.id, token: token + ) + servicePoints.append(sp) + selectedServicePoint = sp + showCreateServicePoint = false + newServicePointName = "" + } catch { + errorMessage = error.localizedDescription + } + } + + /// Smart-increment: parse existing names, suggest "Table N+1" + private func suggestServicePointName() { + let numbers = servicePoints.compactMap { sp -> Int? in + let parts = sp.name.split(separator: " ") + guard parts.count >= 2, parts[0].lowercased() == "table" else { return nil } + return Int(parts[1]) + } + let next = (numbers.max() ?? 0) + 1 + newServicePointName = "Table \(next)" + } + + private func startScan() async { + provisioningState = .scanning + let _ = await bleManager.scan() + if provisioningState == .scanning { + provisioningState = .idle + } + } + + private func startProvisioning(_ beacon: DiscoveredBeacon) async { + guard let sp = selectedServicePoint, + let ns = namespace, + let token = appState.token else { return } + + provisioningState = .connecting + statusMessage = "Connecting to \(beacon.displayName)…" + errorMessage = nil + + do { + // Allocate minor for this service point + let minor = try await APIClient.shared.allocateMinor( + businessId: business.id, servicePointId: sp.id, token: token + ) + + let config = BeaconConfig( + uuid: ns.uuid.normalizedUUID, + major: UInt16(ns.major), + minor: UInt16(minor), + measuredPower: BeaconConfig.defaultMeasuredPower, + advInterval: BeaconConfig.defaultAdvInterval, + txPower: BeaconConfig.defaultTxPower, + servicePointName: sp.name, + businessName: business.name + ) + + // Create appropriate provisioner + let provisioner = makeProvisioner(for: beacon) + + statusMessage = "Authenticating with \(beacon.type.rawValue)…" + try await provisioner.connect() + + // DXSmart: stop at connected state, wait for user to confirm flashing + if beacon.type == .dxsmart { + provisioningState = .connected + // Store config and provisioner for later use + pendingConfig = config + pendingProvisioner = provisioner + return + } + + // KBeacon / BlueCharm: write immediately + provisioningState = .writing + statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)" + try await provisioner.writeConfig(config) + provisioner.disconnect() + + // Register with backend + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: minor, + macAddress: nil, + beaconType: beacon.type.rawValue, + token: token + ) + + // Verify broadcast + provisioningState = .verifying + statusMessage = "Waiting for beacon to restart…" + try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000)) + + statusMessage = "Scanning for broadcast…" + let verifyResult = await bleManager.verifyBroadcast( + uuid: ns.uuid, major: config.major, minor: config.minor + ) + + if verifyResult.found { + try await APIClient.shared.verifyBeaconBroadcast( + uuid: ns.uuid, major: ns.major, minor: minor, token: token + ) + } + + provisioningState = .done + statusMessage = "\(sp.name) — \(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" + + } catch { + provisioningState = .failed + errorMessage = error.localizedDescription + } + } + + // Store for DXSmart two-phase flow + @State private var pendingConfig: BeaconConfig? + @State private var pendingProvisioner: (any BeaconProvisioner)? + + private func writeConfigToConnectedBeacon() async { + guard let config = pendingConfig, + let provisioner = pendingProvisioner, + let sp = selectedServicePoint, + let ns = namespace, + let token = appState.token else { return } + + provisioningState = .writing + statusMessage = "Writing config to DX-Smart…" + + do { + try await provisioner.writeConfig(config) + provisioner.disconnect() + + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: Int(config.minor), + macAddress: nil, + beaconType: BeaconType.dxsmart.rawValue, + token: token + ) + + provisioningState = .done + statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" + + } catch { + provisioningState = .failed + errorMessage = error.localizedDescription + } + + pendingConfig = nil + pendingProvisioner = nil + } + + private func registerAnywayAfterFailure() async { + guard let sp = selectedServicePoint, + let ns = namespace, + let config = pendingConfig ?? makeCurrentConfig(), + let token = appState.token else { + resetProvisioningState() + return + } + + do { + try await APIClient.shared.registerBeaconHardware( + businessId: business.id, + servicePointId: sp.id, + uuid: ns.uuid, + major: ns.major, + minor: Int(config.minor), + macAddress: nil, + beaconType: selectedBeacon?.type.rawValue ?? "Unknown", + token: token + ) + provisioningState = .done + statusMessage = "Registered (broadcast unverified)\n\(sp.name) — Minor: \(config.minor)" + } catch { + errorMessage = "Registration failed: \(error.localizedDescription)" + } + } + + private func makeCurrentConfig() -> BeaconConfig? { + // Only used for "Register Anyway" fallback + return nil + } + + private func cancelProvisioning() { + pendingProvisioner?.disconnect() + pendingProvisioner = nil + pendingConfig = nil + resetProvisioningState() + } + + /// Handle QR/barcode scan result — could be MAC address, UUID, or other identifier + private func handleQRScan(_ code: String) { + let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if it looks like a MAC address (AA:BB:CC:DD:EE:FF or AABBCCDDEEFF) + let macPattern = #"^([0-9A-Fa-f]{2}[:-]?){5}[0-9A-Fa-f]{2}$"# + if cleaned.range(of: macPattern, options: .regularExpression) != nil { + scannedMAC = cleaned.replacingOccurrences(of: "-", with: ":").uppercased() + statusMessage = "Scanned MAC: \(scannedMAC ?? cleaned)" + // Look up the beacon by MAC + Task { await lookupScannedMAC() } + return + } + + // Check if it looks like a UUID + let uuidPattern = #"^[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}$"# + if cleaned.range(of: uuidPattern, options: .regularExpression) != nil { + statusMessage = "Scanned UUID: \(cleaned)" + return + } + + // Generic code — just show it + statusMessage = "Scanned: \(cleaned)" + } + + private func lookupScannedMAC() async { + guard let mac = scannedMAC, let token = appState.token else { return } + do { + if let existing = try await APIClient.shared.lookupByMac(macAddress: mac, token: token) { + let beaconType = existing.beaconType ?? existing.BeaconType ?? "Unknown" + statusMessage = "MAC \(mac) — already registered as \(beaconType)" + } else { + statusMessage = "MAC \(mac) — not yet registered. Scan BLE to find and provision." + } + } catch { + statusMessage = "MAC \(mac) — lookup failed: \(error.localizedDescription)" + } + } + + private func resetProvisioningState() { + provisioningState = .idle + statusMessage = "" + errorMessage = nil + selectedBeacon = nil + pendingConfig = nil + pendingProvisioner = nil + scannedMAC = nil + } + + private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { + switch beacon.type { + case .kbeacon: + return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .dxsmart: + return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .bluecharm: + return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + case .unknown: + // Try all provisioners in sequence (matches Android fallback behavior) + return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) + } + } +} + +// MARK: - Beacon Row + +struct BeaconRow: View { + let beacon: DiscoveredBeacon + + var body: some View { + HStack(spacing: 12) { + // Signal strength indicator + Image(systemName: signalIcon) + .font(.title2) + .foregroundStyle(signalColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(beacon.displayName) + .font(.headline) + + HStack(spacing: 8) { + Text(beacon.type.rawValue) + .font(.caption.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(typeColor.opacity(0.15), in: Capsule()) + .foregroundStyle(typeColor) + + Text("\(beacon.rssi) dBm") + .font(.caption) + .foregroundStyle(.secondary) + + Text(beacon.signalDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + + private var signalIcon: String { + switch beacon.rssi { + case -50...0: return "wifi" + case -65 ... -51: return "wifi" + case -80 ... -66: return "wifi.exclamationmark" + default: return "wifi.slash" + } + } + + private var signalColor: Color { + switch beacon.rssi { + case -50...0: return .green + case -65 ... -51: return .blue + case -80 ... -66: return .orange + default: return .red + } + } + + private var typeColor: Color { + switch beacon.type { + case .kbeacon: return .blue + case .dxsmart: return .orange + case .bluecharm: return .purple + case .unknown: return .gray + } + } +} + diff --git a/PayfritBeacon/en.lproj/InfoPlist.strings b/PayfritBeacon/en.lproj/InfoPlist.strings deleted file mode 100644 index ce805e8..0000000 --- a/PayfritBeacon/en.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -CFBundleDisplayName = "Payfrit Beacon"; -CFBundleName = "Payfrit Beacon"; diff --git a/PayfritBeacon/payfrit-favicon-light-outlines.svg b/PayfritBeacon/payfrit-favicon-light-outlines.svg deleted file mode 100644 index 9007140..0000000 --- a/PayfritBeacon/payfrit-favicon-light-outlines.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/Podfile b/Podfile deleted file mode 100644 index 9aad56c..0000000 --- a/Podfile +++ /dev/null @@ -1,15 +0,0 @@ -platform :ios, '16.0' -use_frameworks! - -target 'PayfritBeacon' do - pod 'Kingfisher', '~> 7.0' - pod 'SVGKit', '~> 3.0' -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' - end - end -end diff --git a/Podfile.lock b/Podfile.lock deleted file mode 100644 index 377b246..0000000 --- a/Podfile.lock +++ /dev/null @@ -1,26 +0,0 @@ -PODS: - - CocoaLumberjack (3.9.0): - - CocoaLumberjack/Core (= 3.9.0) - - CocoaLumberjack/Core (3.9.0) - - Kingfisher (7.12.0) - - SVGKit (3.0.0): - - CocoaLumberjack (~> 3.0) - -DEPENDENCIES: - - Kingfisher (~> 7.0) - - SVGKit (~> 3.0) - -SPEC REPOS: - trunk: - - CocoaLumberjack - - Kingfisher - - SVGKit - -SPEC CHECKSUMS: - CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - Kingfisher: 53a10ea35051a436b5fb626ca2dd8f3144d755e9 - SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea - -PODFILE CHECKSUM: 548c7a88f61dd6e8448dba1f369d199e3053e0b6 - -COCOAPODS: 1.16.2 diff --git a/QA_WALKTHROUGH.md b/QA_WALKTHROUGH.md deleted file mode 100644 index 1ce41ba..0000000 --- a/QA_WALKTHROUGH.md +++ /dev/null @@ -1,329 +0,0 @@ -# Payfrit Beacon iOS — QA Walkthrough Test Document - -## Overview - -**App:** Payfrit Beacon iOS (SwiftUI) -**Purpose:** BLE beacon management for business locations -**Auth:** Token-based with Keychain storage + biometric -**Environment:** Dev (`dev.payfrit.com`) / Prod (`biz.payfrit.com`) — hardcoded in `APIService.swift:50` - ---- - -## Pre-Test Setup - -- [ ] Device has iOS 14+ -- [ ] Bluetooth enabled in Settings -- [ ] Location Services enabled in Settings -- [ ] Stable network connection (Wi-Fi or LTE) -- [ ] A physical BLE beacon available for scanner tests -- [ ] Test account with at least 1 business, 1 beacon, 1 service point - ---- - -## 1. Authentication Flow - -### 1.1 First-Time Login - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Launch app (fresh install) | LoginScreen shown | -| 2 | Leave fields empty, tap "Sign In" | Button disabled (name/password empty) | -| 3 | Enter email only, tap "Sign In" | Error: "Please enter username and password" | -| 4 | Enter valid email + wrong password | Error: "Invalid email/phone or password" | -| 5 | Enter valid credentials, tap "Sign In" | Loading spinner on button, navigates to BusinessSelectionScreen | -| 6 | Kill and relaunch app | Saved auth loaded, skips login | - -### 1.2 Biometric Re-Auth (Device Only, Skipped on Simulator) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Relaunch app with saved auth | Face ID / Touch ID prompt appears | -| 2 | Authenticate successfully | Navigates to BusinessSelectionScreen | -| 3 | Relaunch, cancel biometric | Still loads saved auth (fallback) | - -### 1.3 Logout - -| Step | Action | Expected | -|------|--------|----------| -| 1 | From BusinessSelectionScreen, tap logout (top-right arrow icon) | Clears Keychain + UserDefaults, returns to LoginScreen | -| 2 | Kill and relaunch | LoginScreen shown (no saved auth) | - -### 1.4 Session Expiry (401) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Login, wait for token to expire (or invalidate server-side) | Next API call returns 401 | -| 2 | Try any action (list beacons, etc.) | Auto-logout, returns to LoginScreen | - -### 1.5 Dev Mode Indicator - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Launch in dev environment | LoginScreen shows red "DEV MODE — password: 123456" | -| 2 | After login, check bottom-left of RootView | Orange "DEV" banner visible | - ---- - -## 2. Business Selection - -### 2.1 Load Businesses - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Login successfully | BusinessSelectionScreen loads with spinner | -| 2 | Wait for load | List of businesses with name + city | -| 3 | Tap a business | Pushes to BeaconDashboard, shows "Loading..." then TabView | - -### 2.2 Edge Cases - -| Scenario | Expected | -|----------|----------| -| User has no businesses | "No businesses found" message, can only logout | -| Network error during load | Error message + "Retry" button | -| Tap Retry | Re-fetches business list | -| 401 during load | Auto-logout to LoginScreen | - ---- - -## 3. Beacon Management (Beacons Tab) - -### 3.1 View Beacon List - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Beacons" tab | List loads with spinner | -| 2 | Verify beacon rows | Each shows: name, formatted UUID (XXXXXXXX-XXXX-...), active badge (green check / red X) | -| 3 | Pull down to refresh | Spinner appears, list refreshes | -| 4 | Empty list | "No beacons yet" + "Tap + to add" message | - -### 3.2 Create Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap + button | BeaconEditSheet modal appears | -| 2 | Leave fields empty, tap Save | Button disabled | -| 3 | Enter name only | Button disabled (UUID required) | -| 4 | Enter name + valid 32-char hex UUID | Save enabled | -| 5 | Tap Save | Modal dismisses, list refreshes with new beacon | -| 6 | Network error during save | Error shown in sheet, sheet stays open | - -### 3.3 Edit Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap a beacon row | Pushes to BeaconDetailScreen | -| 2 | Verify pre-filled fields | Name, UUID, Active toggle match beacon data | -| 3 | Verify read-only fields | ID, Business ID, Created, Updated dates shown | -| 4 | Change name, tap "Save Changes" | Returns to list, beacon updated | -| 5 | Toggle Active off, save | Badge changes from green check to red X | -| 6 | Clear name, try save | Button disabled | - -### 3.4 Delete Beacon (Detail Screen) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | From BeaconDetailScreen, tap "Delete Beacon" | Alert: "Delete Beacon?" with warning about service points | -| 2 | Tap "Cancel" | Nothing happens | -| 3 | Tap "Delete" | API call, returns to list, beacon removed | -| 4 | Network error during delete | Error shown, stays on detail screen | - -### 3.5 Delete Beacon (Swipe) - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Swipe left on beacon row | Delete action appears | -| 2 | Tap delete | Beacon removed immediately (optimistic) | -| 3 | If API fails | Beacon re-added to list, error shown | - -### 3.6 Rapid Delete Stress Test - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Swipe-delete 3 beacons quickly | All removed from UI | -| 2 | Wait for API responses | Failed deletes restored, successful ones stay removed | - ---- - -## 4. Service Points (Service Points Tab) - -### 4.1 View Service Points - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Service Points" tab | List loads (fetches both service points AND beacons) | -| 2 | Verify rows | Name, type, active indicator (green/red circle), beacon assignment | -| 3 | Pull down to refresh | Refreshes both lists | -| 4 | Empty list | "No service points" message | - -### 4.2 Assign Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with "No beacon assigned" | "Assign" button visible | -| 2 | Tap "Assign" | BeaconPickerSheet opens with all beacons | -| 3 | Tap a beacon | Sheet dismisses, spinner on row, then beacon name in green | - -### 4.3 Change Beacon Assignment - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with assigned beacon | "Change" button + X button visible | -| 2 | Tap "Change" | Picker opens, current beacon has checkmark | -| 3 | Select different beacon | Assignment updated | - -### 4.4 Unassign Beacon - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Find service point with assigned beacon | X button visible | -| 2 | Tap X button | API called with nil beaconId, shows "No beacon assigned" | - -### 4.5 Edge Cases - -| Scenario | Expected | -|----------|----------| -| No beacons exist | Picker opens with empty list | -| Network error during assign | Error shown, assignment reverted | -| 401 during any operation | Auto-logout | - ---- - -## 5. Scanner (Scanner Tab) - -### 5.1 Basic Scanning Flow - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Tap "Scanner" tab | Beacon picker loads at top | -| 2 | No beacon selected | "Start Scanning" button disabled | -| 3 | Select a beacon from picker | Button becomes enabled | -| 4 | Tap "Start Scanning" | Status: "Scanning..." (blue antenna icon) | -| 5 | Move close to matching beacon | RSSI value appears, samples count up (X/5) | -| 6 | Stay close for 5+ samples with RSSI >= -75 | "Beacon Detected! (avg -XX dBm)" (green checkmark) | -| 7 | Tap "Stop Scanning" | Status resets to "Select a beacon to scan" | - -### 5.2 Signal Strength Visualization - -| RSSI Range | Bar Color | Signal Quality | -|------------|-----------|---------------| -| -30 to -51 | Green | Strong | -| -52 to -72 | Yellow | Medium | -| -73 to -100 | Red | Weak | - -### 5.3 RSSI Threshold Behavior - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Scanning, RSSI >= -75 | Sample appended, count increments | -| 2 | RSSI drops below -75 | All samples cleared, count resets to 0 | -| 3 | RSSI returns above -75 | Samples start accumulating again from 0 | -| 4 | Beacon disappears entirely (RSSI = 0) | Samples cleared, "Searching for beacon signal..." | - -### 5.4 Permission Handling - -| Scenario | Expected | -|----------|----------| -| Location not determined | System prompt shown, scanning waits | -| Location granted after prompt | Scanning starts automatically | -| Location denied | "Location Permission Denied" (red), scanning stops | -| Location denied in Settings | Same as above on next scan attempt | -| Bluetooth off | "Bluetooth is OFF" (orange), scanning stops | -| Bluetooth turned off mid-scan | Detected within 5 seconds, scanning stops | - -### 5.5 Beacon Change During Scan - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Start scanning for Beacon A | Scanning active | -| 2 | Change picker to Beacon B | Scan stops immediately | -| 3 | Tap "Start Scanning" | New scan starts for Beacon B | - -### 5.6 Screen Lock Prevention - -| Step | Action | Expected | -|------|--------|----------| -| 1 | Start scanning | Screen stays awake (idle timer disabled) | -| 2 | Stop scanning | Idle timer re-enabled, screen can lock normally | - -### 5.7 Error Cases - -| Scenario | Expected | -|----------|----------| -| Invalid UUID format on beacon | Error: "Invalid beacon UUID format" | -| Ranging failure (CLLocationManager error) | Error: "Beacon ranging failed: [description]" | -| Beacon list fails to load | Picker empty, scanner still functional if UUID known | - ---- - -## 6. Cross-Cutting Concerns - -### 6.1 Network Error Handling (All Screens) - -| Scenario | Expected | -|----------|----------| -| Airplane mode during API call | Network error displayed | -| Server returns 500 | "HTTP 500" error shown | -| Server returns non-JSON | "Decoding error: Non-JSON response" | -| Server returns `{"OK": false, "ERROR": "..."}` | Error message from server shown | -| 401 on any authenticated endpoint | Auto-logout to LoginScreen | - -### 6.2 Navigation - -| Test | Expected | -|------|----------| -| Back button from BeaconDetailScreen | Returns to BeaconListScreen | -| Back button from BeaconDashboard | Returns to BusinessSelectionScreen | -| Tab switching in BeaconDashboard | Beacons / Service Points / Scanner tabs all functional | -| Deep link: Business > Beacon > Detail > Back > Back | Full nav stack unwinds cleanly | - -### 6.3 Data Consistency - -| Test | Expected | -|------|----------| -| Add beacon, switch to Service Points tab | New beacon available in picker | -| Delete beacon assigned to service point | Service point shows "No beacon assigned" on refresh | -| Edit beacon name | Updated name shows in list and service point rows | - ---- - -## 7. API Endpoints Reference - -| Action | Method | Endpoint | -|--------|--------|----------| -| Login | POST | `/auth/login.cfm` | -| List businesses | POST | `/workers/myBusinesses.cfm` | -| List beacons | POST | `/beacons/list.cfm` | -| Get beacon | POST | `/beacons/get.cfm` | -| Create beacon | POST | `/beacons/create.cfm` | -| Update beacon | POST | `/beacons/update.cfm` | -| Delete beacon | POST | `/beacons/delete.cfm` | -| List service points | POST | `/servicePoints/list.cfm` | -| List SP types | POST | `/servicePoints/types.cfm` | -| Assign beacon to SP | POST | `/servicePoints/assignBeacon.cfm` | - -All requests include headers: -- `Content-Type: application/json` -- `X-User-Token: ` (after login) -- `X-Business-ID: ` (after business selection) - ---- - -## 8. Permissions Checklist - -| Permission | Info.plist Key | When Prompted | -|-----------|----------------|---------------| -| Location (When In Use) | `NSLocationWhenInUseUsageDescription` | First beacon scan | -| Location (Always) | `NSLocationAlwaysAndWhenInUseUsageDescription` | Background scanning | -| Bluetooth | `NSBluetoothAlwaysUsageDescription` | First beacon scan | -| Face ID | `NSFaceIDUsageDescription` | App relaunch with saved auth | - ---- - -## 9. Known Limitations - -1. **Environment switching** requires code change + recompile (`APIService.swift:50`) -2. **Service points are read-only** — can only assign/unassign beacons, not create/edit/delete SPs -3. **No token refresh** — expired tokens force full re-login -4. **Single scanner** — only one scan at a time, changing beacon stops previous -5. **Background scanning** depends on iOS version and background mode support -6. **Photo URLs** resolved but not cached across sessions diff --git a/_backup/Models/Beacon.swift b/_backup/Models/Beacon.swift deleted file mode 100644 index 555d978..0000000 --- a/_backup/Models/Beacon.swift +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 00b0ded..0000000 --- a/_backup/Models/Employment.swift +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 6229394..0000000 --- a/_backup/Models/ServicePoint.swift +++ /dev/null @@ -1,47 +0,0 @@ -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/Services/APIService.swift b/_backup/Services/APIService.swift deleted file mode 100644 index b6dfbbb..0000000 --- a/_backup/Services/APIService.swift +++ /dev/null @@ -1,416 +0,0 @@ -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 deleted file mode 100644 index 04a165a..0000000 --- a/_backup/Services/AuthStorage.swift +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 4363653..0000000 --- a/_backup/Services/BeaconScanner.swift +++ /dev/null @@ -1,262 +0,0 @@ -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 deleted file mode 100644 index 4fbec6c..0000000 --- a/_backup/ViewModels/AppState.swift +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index e8e148e..0000000 --- a/_backup/Views/BeaconDashboard.swift +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 1b4aac9..0000000 --- a/_backup/Views/BeaconDetailScreen.swift +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index d3ffc9b..0000000 --- a/_backup/Views/BeaconEditSheet.swift +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 4d9b1c7..0000000 --- a/_backup/Views/BeaconListScreen.swift +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index c410417..0000000 --- a/_backup/Views/BusinessSelectionScreen.swift +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index af5db0f..0000000 --- a/_backup/Views/LoginScreen.swift +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 0cfd3d6..0000000 --- a/_backup/Views/RootView.swift +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 8466cb8..0000000 --- a/_backup/Views/ScannerScreen.swift +++ /dev/null @@ -1,225 +0,0 @@ -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 deleted file mode 100644 index e86bc85..0000000 --- a/_backup/Views/ServicePointListScreen.swift +++ /dev/null @@ -1,232 +0,0 @@ -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() } - } - } - } - } -}