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