Merge schwifty/koda-review-fixes: complete iOS beacon rebuild with code review fixes
This commit is contained in:
commit
0c4542e62f
67 changed files with 3529 additions and 7262 deletions
|
|
@ -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 = "<group>"; };
|
||||
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
||||
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
|
||||
D02000000009 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = "<group>"; };
|
||||
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
11
PayfritBeacon/App/AppPrefs.swift
Normal file
11
PayfritBeacon/App/AppPrefs.swift
Normal file
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
48
PayfritBeacon/App/AppState.swift
Normal file
48
PayfritBeacon/App/AppState.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,3 @@ struct PayfritBeaconApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(APP_DISPLAY_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$(APP_DISPLAY_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Payfrit Beacon uses Bluetooth to discover and configure nearby beacons.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Payfrit Beacon uses your location to detect nearby iBeacons and verify beacon ownership.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Payfrit Beacon uses Face ID for quick sign-in.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
34
PayfritBeacon/Models/BeaconConfig.swift
Normal file
34
PayfritBeacon/Models/BeaconConfig.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
33
PayfritBeacon/Models/BeaconType.swift
Normal file
33
PayfritBeacon/Models/BeaconType.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
PayfritBeacon/Models/Business.swift
Normal file
18
PayfritBeacon/Models/Business.swift
Normal file
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
13
PayfritBeacon/Models/ServicePoint.swift
Normal file
13
PayfritBeacon/Models/ServicePoint.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
397
PayfritBeacon/Provisioners/BlueCharmProvisioner.swift
Normal file
397
PayfritBeacon/Provisioners/BlueCharmProvisioner.swift
Normal file
|
|
@ -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<Void, Error>?
|
||||
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
||||
private var writeContinuation: CheckedContinuation<Data, Error>?
|
||||
private var writeOKContinuation: CheckedContinuation<Void, Error>?
|
||||
|
||||
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<Void, Error>) 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<Void, Error>) 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<Void, Error>) 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
|
||||
}
|
||||
}
|
||||
405
PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Normal file
405
PayfritBeacon/Provisioners/DXSmartProvisioner.swift
Normal file
|
|
@ -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<Void, Error>?
|
||||
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
||||
private var responseContinuation: CheckedContinuation<Data, Error>?
|
||||
private var writeContinuation: CheckedContinuation<Void, Error>?
|
||||
|
||||
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<Void, Error>) 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<Void, Error>) 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<Data, Error>) 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<Void, Error>) 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<Data, Error>) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
PayfritBeacon/Provisioners/FallbackProvisioner.swift
Normal file
56
PayfritBeacon/Provisioners/FallbackProvisioner.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
262
PayfritBeacon/Provisioners/KBeaconProvisioner.swift
Normal file
262
PayfritBeacon/Provisioners/KBeaconProvisioner.swift
Normal file
|
|
@ -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<Void, Error>?
|
||||
private var serviceContinuation: CheckedContinuation<Void, Error>?
|
||||
private var writeContinuation: CheckedContinuation<Data, Error>?
|
||||
|
||||
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<Void, Error>) 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<Void, Error>) 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<Data, Error>) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
PayfritBeacon/Provisioners/ProvisionError.swift
Normal file
30
PayfritBeacon/Provisioners/ProvisionError.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
PayfritBeacon/Provisioners/ProvisionerProtocol.swift
Normal file
40
PayfritBeacon/Provisioners/ProvisionerProtocol.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
|
||||
392
PayfritBeacon/Services/APIClient.swift
Normal file
392
PayfritBeacon/Services/APIClient.swift
Normal file
|
|
@ -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<OTPResponse>.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<LoginResponse>.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<ServicePoint>.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<NamespaceResponse>.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<MinorResponse>.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<EmptyData>.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<EmptyData>.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<ResolveResponse>.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<EmptyData>.self, from: data)
|
||||
guard resp.success else {
|
||||
throw APIError.serverError(resp.message ?? "Failed to delete service point")
|
||||
}
|
||||
}
|
||||
|
||||
func updateServicePoint(servicePointId: String, name: String, businessId: String, token: String) async throws {
|
||||
let body: [String: Any] = ["ID": servicePointId, "Name": name, "BusinessID": businessId]
|
||||
let data = try await post(path: "/servicepoints/save.php", body: body, token: token, businessId: businessId)
|
||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
||||
guard resp.success else {
|
||||
throw APIError.serverError(resp.message ?? "Failed to update service point")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beacon Management
|
||||
|
||||
struct BeaconListResponse: Codable {
|
||||
let id: String?
|
||||
let ID: String?
|
||||
let uuid: String?
|
||||
let UUID: String?
|
||||
let major: Int?
|
||||
let Major: Int?
|
||||
let minor: Int?
|
||||
let Minor: Int?
|
||||
let macAddress: String?
|
||||
let MacAddress: String?
|
||||
let beaconType: String?
|
||||
let BeaconType: String?
|
||||
let servicePointId: String?
|
||||
let ServicePointID: String?
|
||||
let isVerified: Bool?
|
||||
let IsVerified: Bool?
|
||||
}
|
||||
|
||||
func listBeacons(businessId: String, token: String) async throws -> [BeaconListResponse] {
|
||||
let body: [String: Any] = ["BusinessID": businessId]
|
||||
let data = try await post(path: "/beacon-sharding/list_beacons.php", body: body, token: token, businessId: businessId)
|
||||
let resp = try JSONDecoder().decode(APIResponse<[BeaconListResponse]>.self, from: data)
|
||||
guard resp.success else {
|
||||
throw APIError.serverError(resp.message ?? "Failed to list beacons")
|
||||
}
|
||||
return resp.data ?? []
|
||||
}
|
||||
|
||||
func decommissionBeacon(beaconId: String, businessId: String, token: String) async throws {
|
||||
let body: [String: Any] = ["ID": beaconId, "BusinessID": businessId]
|
||||
let data = try await post(path: "/beacon-sharding/decommission_beacon.php", body: body, token: token, businessId: businessId)
|
||||
let resp = try JSONDecoder().decode(APIResponse<EmptyData>.self, from: data)
|
||||
guard resp.success else {
|
||||
throw APIError.serverError(resp.message ?? "Failed to decommission beacon")
|
||||
}
|
||||
}
|
||||
|
||||
func lookupByMac(macAddress: String, token: String) async throws -> BeaconListResponse? {
|
||||
let body: [String: Any] = ["MacAddress": macAddress]
|
||||
let data = try await post(path: "/beacon-sharding/lookup_by_mac.php", body: body, token: token)
|
||||
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
func getBeaconStatus(uuid: String, major: Int, minor: Int, token: String) async throws -> BeaconListResponse? {
|
||||
let body: [String: Any] = ["UUID": uuid, "Major": major, "Minor": minor]
|
||||
let data = try await post(path: "/beacon-sharding/beacon_status.php", body: body, token: token)
|
||||
let resp = try JSONDecoder().decode(APIResponse<BeaconListResponse>.self, from: data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// MARK: - 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<BeaconConfigResponse>.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<UserProfile>.self, from: data)
|
||||
guard resp.success, let profile = resp.data else {
|
||||
throw APIError.serverError(resp.message ?? "Failed to load profile")
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private struct EmptyData: Codable {}
|
||||
|
||||
private struct APIResponse<T: Codable>: 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
|
||||
}
|
||||
}
|
||||
17
PayfritBeacon/Services/APIConfig.swift
Normal file
17
PayfritBeacon/Services/APIConfig.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
202
PayfritBeacon/Services/BLEManager.swift
Normal file
202
PayfritBeacon/Services/BLEManager.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
PayfritBeacon/Services/SecureStorage.swift
Normal file
68
PayfritBeacon/Services/SecureStorage.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]))"
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
78
PayfritBeacon/Utils/BeaconShardPool.swift
Normal file
78
PayfritBeacon/Utils/BeaconShardPool.swift
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
32
PayfritBeacon/Utils/UUIDFormatting.swift
Normal file
32
PayfritBeacon/Utils/UUIDFormatting.swift
Normal file
|
|
@ -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..<next], radix: 16) {
|
||||
bytes.append(byte)
|
||||
}
|
||||
i = next
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
120
PayfritBeacon/Views/BusinessListView.swift
Normal file
120
PayfritBeacon/Views/BusinessListView.swift
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Business selector screen (matches Android MainActivity)
|
||||
struct BusinessListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
@State private var businesses: [Business] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading businesses…")
|
||||
} else if let error = errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Error", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error)
|
||||
} actions: {
|
||||
Button("Retry") { Task { await loadBusinesses() } }
|
||||
}
|
||||
} else if businesses.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Businesses",
|
||||
systemImage: "building.2",
|
||||
description: Text("You don't have any businesses yet.")
|
||||
)
|
||||
} else {
|
||||
List(businesses) { business in
|
||||
Button {
|
||||
appState.selectBusiness(business)
|
||||
} label: {
|
||||
BusinessRow(business: business)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Business")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Logout") {
|
||||
appState.logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadBusinesses()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBusinesses() async {
|
||||
guard let token = appState.token else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let list = try await APIClient.shared.listBusinesses(token: token)
|
||||
businesses = list
|
||||
|
||||
// Auto-navigate if only one business (like Android)
|
||||
if list.count == 1, let only = list.first {
|
||||
appState.selectBusiness(only)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-navigate to last used business
|
||||
if let lastId = AppPrefs.lastBusinessId,
|
||||
let last = list.first(where: { $0.id == lastId }) {
|
||||
appState.selectBusiness(last)
|
||||
return
|
||||
}
|
||||
} catch let e as APIError where e.errorDescription == APIError.unauthorized.errorDescription {
|
||||
appState.logout()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Business Row
|
||||
|
||||
struct BusinessRow: View {
|
||||
let business: Business
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Business header image
|
||||
AsyncImage(url: business.headerImageURL) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Image(systemName: "building.2")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(business.name)
|
||||
.font(.headline)
|
||||
Text("ID: \(business.id)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
14
PayfritBeacon/Views/DevBanner.swift
Normal file
14
PayfritBeacon/Views/DevBanner.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Orange DEV banner overlay (matches Android's DevBanner)
|
||||
struct DevBanner: View {
|
||||
var body: some View {
|
||||
Text("DEV")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(.orange, in: Capsule())
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
141
PayfritBeacon/Views/LoginView.swift
Normal file
141
PayfritBeacon/Views/LoginView.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
/// OTP Login screen + biometric auth (matches Android LoginActivity)
|
||||
struct LoginView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
@State private var phone = ""
|
||||
@State private var otpCode = ""
|
||||
@State private var otpUUID = ""
|
||||
@State private var showOTPField = false
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
// Logo
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Payfrit Beacon")
|
||||
.font(.title.bold())
|
||||
|
||||
Text("Provision beacons for your business")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Phone input
|
||||
VStack(spacing: 16) {
|
||||
TextField("Phone number", text: $phone)
|
||||
.keyboardType(.phonePad)
|
||||
.textContentType(.telephoneNumber)
|
||||
.padding()
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.disabled(showOTPField)
|
||||
|
||||
if showOTPField {
|
||||
TextField("Enter OTP code", text: $otpCode)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.padding()
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button(action: handleAction) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(showOTPField ? "Verify OTP" : "Send OTP")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(isLoading || (showOTPField ? otpCode.isEmpty : phone.isEmpty))
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
// Try biometric login on appear
|
||||
await tryBiometricLogin()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAction() {
|
||||
Task {
|
||||
if showOTPField {
|
||||
await verifyOTP()
|
||||
} else {
|
||||
await sendOTP()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendOTP() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let uuid = try await APIClient.shared.sendOTP(phone: phone)
|
||||
otpUUID = uuid
|
||||
showOTPField = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func verifyOTP() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let result = try await APIClient.shared.verifyOTP(uuid: otpUUID, code: otpCode)
|
||||
appState.didLogin(token: result.token, userId: result.userId)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func tryBiometricLogin() async {
|
||||
guard let session = SecureStorage.loadSession() else { return }
|
||||
|
||||
let context = LAContext()
|
||||
var authError: NSError?
|
||||
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let success = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Log in to Payfrit Beacon"
|
||||
)
|
||||
if success {
|
||||
appState.didLogin(token: session.token, userId: session.userId)
|
||||
}
|
||||
} catch {
|
||||
// Biometric failed — show manual login
|
||||
}
|
||||
}
|
||||
}
|
||||
290
PayfritBeacon/Views/QRScannerView.swift
Normal file
290
PayfritBeacon/Views/QRScannerView.swift
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Vision
|
||||
|
||||
/// QR/Barcode scanner for reading beacon MAC addresses and UUIDs
|
||||
/// Matches Android QRScannerActivity — camera preview + barcode detection
|
||||
struct QRScannerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called with the scanned string (MAC address, UUID, or other barcode)
|
||||
let onScan: (String) -> Void
|
||||
|
||||
@State private var scannedCode: String?
|
||||
@State private var isFlashOn = false
|
||||
@State private var cameraPermission: CameraPermission = .undetermined
|
||||
|
||||
enum CameraPermission {
|
||||
case undetermined, granted, denied
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
if cameraPermission == .granted {
|
||||
CameraPreview(onCodeDetected: handleDetection, isFlashOn: $isFlashOn)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Scan overlay
|
||||
scanOverlay
|
||||
} else if cameraPermission == .denied {
|
||||
cameraPermissionDenied
|
||||
} else {
|
||||
Color.black.ignoresSafeArea()
|
||||
.overlay {
|
||||
ProgressView("Requesting camera…")
|
||||
.tint(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan QR / Barcode")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
isFlashOn.toggle()
|
||||
} label: {
|
||||
Image(systemName: isFlashOn ? "flashlight.on.fill" : "flashlight.off.fill")
|
||||
.foregroundStyle(isFlashOn ? .yellow : .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await checkCameraPermission()
|
||||
}
|
||||
.alert("Code Detected", isPresented: .init(
|
||||
get: { scannedCode != nil },
|
||||
set: { if !$0 { scannedCode = nil } }
|
||||
)) {
|
||||
Button("Use This") {
|
||||
if let code = scannedCode {
|
||||
onScan(code)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Button("Scan Again", role: .cancel) {
|
||||
scannedCode = nil
|
||||
}
|
||||
} message: {
|
||||
Text(scannedCode ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scan Overlay
|
||||
|
||||
private var scanOverlay: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
// Viewfinder frame
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(.white.opacity(0.8), lineWidth: 2)
|
||||
.frame(width: 280, height: 280)
|
||||
.overlay {
|
||||
// Corner accents
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
let corner: CGFloat = 30
|
||||
let thickness: CGFloat = 4
|
||||
|
||||
// Top-left
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: 0, y: corner))
|
||||
p.addLine(to: CGPoint(x: 0, y: 0))
|
||||
p.addLine(to: CGPoint(x: corner, y: 0))
|
||||
}
|
||||
.stroke(.blue, lineWidth: thickness)
|
||||
|
||||
// Top-right
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: w - corner, y: 0))
|
||||
p.addLine(to: CGPoint(x: w, y: 0))
|
||||
p.addLine(to: CGPoint(x: w, y: corner))
|
||||
}
|
||||
.stroke(.blue, lineWidth: thickness)
|
||||
|
||||
// Bottom-left
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: 0, y: h - corner))
|
||||
p.addLine(to: CGPoint(x: 0, y: h))
|
||||
p.addLine(to: CGPoint(x: corner, y: h))
|
||||
}
|
||||
.stroke(.blue, lineWidth: thickness)
|
||||
|
||||
// Bottom-right
|
||||
Path { p in
|
||||
p.move(to: CGPoint(x: w - corner, y: h))
|
||||
p.addLine(to: CGPoint(x: w, y: h))
|
||||
p.addLine(to: CGPoint(x: w, y: h - corner))
|
||||
}
|
||||
.stroke(.blue, lineWidth: thickness)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Instructions
|
||||
Text("Point camera at beacon QR code or barcode")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private var cameraPermissionDenied: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Camera Access Required", systemImage: "camera.fill")
|
||||
} description: {
|
||||
Text("Open Settings and enable camera access for Payfrit Beacon.")
|
||||
} actions: {
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func checkCameraPermission() async {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
cameraPermission = .granted
|
||||
case .notDetermined:
|
||||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraPermission = granted ? .granted : .denied
|
||||
default:
|
||||
cameraPermission = .denied
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDetection(_ code: String) {
|
||||
guard scannedCode == nil else { return } // Debounce
|
||||
// Haptic feedback
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
scannedCode = code
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Camera Preview (UIViewRepresentable)
|
||||
|
||||
/// AVFoundation camera preview with barcode/QR detection via Vision framework
|
||||
struct CameraPreview: UIViewRepresentable {
|
||||
let onCodeDetected: (String) -> Void
|
||||
@Binding var isFlashOn: Bool
|
||||
|
||||
func makeUIView(context: Context) -> CameraPreviewUIView {
|
||||
let view = CameraPreviewUIView(onCodeDetected: onCodeDetected)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
|
||||
uiView.setFlash(isFlashOn)
|
||||
}
|
||||
}
|
||||
|
||||
final class CameraPreviewUIView: UIView {
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private let onCodeDetected: (String) -> Void
|
||||
private var lastDetectionTime: Date = .distantPast
|
||||
private let debounceInterval: TimeInterval = 1.5
|
||||
|
||||
init(onCodeDetected: @escaping (String) -> Void) {
|
||||
self.onCodeDetected = onCodeDetected
|
||||
super.init(frame: .zero)
|
||||
setupCamera()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
previewLayer?.frame = bounds
|
||||
}
|
||||
|
||||
func setFlash(_ on: Bool) {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
device.hasTorch else { return }
|
||||
try? device.lockForConfiguration()
|
||||
device.torchMode = on ? .on : .off
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
|
||||
private func setupCamera() {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
||||
let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||
|
||||
if session.canAddInput(input) {
|
||||
session.addInput(input)
|
||||
}
|
||||
|
||||
// Use AVCaptureMetadataOutput for barcode detection (more reliable than Vision for barcodes)
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
if session.canAddOutput(metadataOutput) {
|
||||
session.addOutput(metadataOutput)
|
||||
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
||||
metadataOutput.metadataObjectTypes = [
|
||||
.qr,
|
||||
.ean8, .ean13,
|
||||
.code128, .code39, .code93,
|
||||
.dataMatrix,
|
||||
.pdf417,
|
||||
.aztec
|
||||
]
|
||||
}
|
||||
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
layer.addSublayer(previewLayer)
|
||||
self.previewLayer = previewLayer
|
||||
|
||||
self.captureSession = session
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
captureSession?.stopRunning()
|
||||
setFlash(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
||||
|
||||
extension CameraPreviewUIView: AVCaptureMetadataOutputObjectsDelegate {
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = metadata.stringValue,
|
||||
!value.isEmpty else { return }
|
||||
|
||||
// Debounce rapid detections
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastDetectionTime) > debounceInterval else { return }
|
||||
lastDetectionTime = now
|
||||
|
||||
onCodeDetected(value)
|
||||
}
|
||||
}
|
||||
39
PayfritBeacon/Views/RootView.swift
Normal file
39
PayfritBeacon/Views/RootView.swift
Normal file
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
770
PayfritBeacon/Views/ScanView.swift
Normal file
770
PayfritBeacon/Views/ScanView.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
CFBundleDisplayName = "Payfrit Beacon";
|
||||
CFBundleName = "Payfrit Beacon";
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="960px" height="560px" viewBox="0 0 960 560" enable-background="new 0 0 960 560" xml:space="preserve">
|
||||
<rect x="9.109" y="12.221" fill="#22B24B" width="942.891" height="68.608"/>
|
||||
<g enable-background="new ">
|
||||
<path d="M9.256,291.354V123.396h54.42c20.623,0,34.064,0.842,40.328,2.521c9.624,2.521,17.682,8.002,24.174,16.44
|
||||
c6.491,8.441,9.738,19.345,9.738,32.71c0,10.311-1.873,18.98-5.614,26.007c-3.743,7.028-8.498,12.545-14.264,16.556
|
||||
c-5.768,4.01-11.628,6.664-17.586,7.962c-8.097,1.604-19.82,2.406-35.173,2.406H43.168v63.356H9.256z M43.168,151.809v47.661h18.56
|
||||
c13.365,0,22.302-0.878,26.81-2.636c4.505-1.756,8.038-4.506,10.597-8.249c2.558-3.741,3.838-8.095,3.838-13.061
|
||||
c0-6.109-1.795-11.15-5.385-15.123c-3.591-3.971-8.134-6.453-13.634-7.447c-4.049-0.763-12.184-1.146-24.403-1.146H43.168z"/>
|
||||
<path d="M299.803,291.354h-36.891l-14.666-38.151h-67.137l-13.863,38.151h-35.975l65.419-167.958h35.86L299.803,291.354z
|
||||
M237.363,224.904l-23.144-62.325l-22.685,62.325H237.363z"/>
|
||||
<path d="M340.36,291.354v-70.688l-61.523-97.27h39.756l39.525,66.45l38.725-66.45h39.068l-61.753,97.498v70.459H340.36z"/>
|
||||
<path d="M452.98,291.354V123.396h115.142v28.413h-81.229v39.756h70.116v28.413h-70.116v71.375H452.98z"/>
|
||||
<path d="M596.192,291.354V123.396h71.376c17.948,0,30.991,1.51,39.125,4.525c8.135,3.019,14.646,8.384,19.534,16.098
|
||||
c4.887,7.715,7.332,16.537,7.332,26.465c0,12.603-3.705,23.011-11.112,31.22c-7.41,8.212-18.485,13.387-33.226,15.524
|
||||
c7.333,4.278,13.385,8.976,18.159,14.092c4.772,5.118,11.208,14.207,19.305,27.268l20.508,32.766h-40.557l-24.518-36.547
|
||||
c-8.708-13.061-14.665-21.29-17.873-24.689c-3.208-3.397-6.607-5.729-10.196-6.988c-3.592-1.261-9.28-1.891-17.071-1.891h-6.874
|
||||
v70.116H596.192z M630.104,194.429h25.091c16.269,0,26.426-0.688,30.475-2.063c4.048-1.375,7.218-3.741,9.51-7.104
|
||||
c2.291-3.36,3.437-7.562,3.437-12.603c0-5.651-1.509-10.215-4.525-13.69c-3.018-3.475-7.274-5.672-12.774-6.588
|
||||
c-2.749-0.382-10.998-0.573-24.746-0.573h-26.466V194.429z"/>
|
||||
<path d="M764.494,291.354V123.396h33.912v167.958H764.494z"/>
|
||||
<path d="M868.522,291.354V151.809h-49.838v-28.413h133.473v28.413h-49.723v139.544H868.522z"/>
|
||||
</g>
|
||||
<rect x="9.109" y="334.846" fill="#22B24B" width="942.891" height="68.609"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
15
Podfile
15
Podfile
|
|
@ -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
|
||||
26
Podfile.lock
26
Podfile.lock
|
|
@ -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
|
||||
|
|
@ -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: <token>` (after login)
|
||||
- `X-Business-ID: <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
|
||||
|
|
@ -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..<clean.index(i, offsetBy: 8)]
|
||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
||||
}
|
||||
|
||||
// MARK: - Parse Helpers
|
||||
|
||||
static func parseInt(_ value: Any?) -> 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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..<clean.index(i, offsetBy: 8)]
|
||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
||||
}
|
||||
|
||||
// MARK: - Start/Stop
|
||||
|
||||
func startScanning() {
|
||||
guard !isScanning else { return }
|
||||
|
||||
let formatted = formatUUID(targetUUID)
|
||||
guard let uuid = UUID(uuidString: formatted) else {
|
||||
onError?("Invalid beacon UUID format")
|
||||
return
|
||||
}
|
||||
|
||||
let lm = CLLocationManager()
|
||||
lm.delegate = self
|
||||
locationManager = lm
|
||||
|
||||
let status = lm.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
isPendingPermission = true
|
||||
lm.requestWhenInUseAuthorization()
|
||||
// Delegate will call locationManagerDidChangeAuthorization
|
||||
return
|
||||
}
|
||||
|
||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||
onPermissionDenied?()
|
||||
return
|
||||
}
|
||||
|
||||
beginRanging(uuid: uuid)
|
||||
}
|
||||
|
||||
private func beginRanging(uuid: UUID) {
|
||||
guard let lm = locationManager else { return }
|
||||
|
||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||
activeConstraint = constraint
|
||||
lm.startRangingBeacons(satisfying: constraint)
|
||||
|
||||
isScanning = true
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
|
||||
// Monitor Bluetooth power state with a real CBCentralManager
|
||||
bluetoothManager = CBCentralManager(delegate: self, queue: .main)
|
||||
|
||||
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkBluetoothState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBluetoothState() {
|
||||
if let bm = bluetoothManager, bm.state == .poweredOff {
|
||||
stopScanning()
|
||||
onBluetoothOff?()
|
||||
}
|
||||
if CBCentralManager.authorization == .denied ||
|
||||
CBCentralManager.authorization == .restricted {
|
||||
stopScanning()
|
||||
onBluetoothOff?()
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
isPendingPermission = false
|
||||
guard isScanning else { return }
|
||||
isScanning = false
|
||||
if let constraint = activeConstraint {
|
||||
locationManager?.stopRangingBeacons(satisfying: constraint)
|
||||
}
|
||||
activeConstraint = nil
|
||||
checkTimer?.invalidate()
|
||||
checkTimer = nil
|
||||
bluetoothManager = nil
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
|
||||
func resetSamples() {
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
stopScanning()
|
||||
locationManager?.delegate = nil
|
||||
locationManager = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Safety net: clean up resources
|
||||
checkTimer?.invalidate()
|
||||
locationManager?.delegate = nil
|
||||
Task { @MainActor in
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate handling (called on main thread, forwarded from nonisolated delegate)
|
||||
|
||||
fileprivate func handleRangedBeacons(_ beacons: [CLBeacon]) {
|
||||
guard isScanning, !hasConfirmed else { return }
|
||||
|
||||
var foundThisCycle = false
|
||||
|
||||
for beacon in beacons {
|
||||
let rssi = beacon.rssi
|
||||
guard rssi != 0 else { continue }
|
||||
|
||||
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard detectedUUID == normalizedTargetUUID else { continue }
|
||||
|
||||
foundThisCycle = true
|
||||
|
||||
if rssi >= 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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue