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:
John Pinkyfloyd 2026-02-10 19:38:11 -08:00
parent c013c8fcd7
commit 8c2320da44
32 changed files with 3751 additions and 694 deletions

14
ExportOptions.plist Normal file
View 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
View 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>

View file

@ -7,16 +7,13 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; };
D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; }; D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; };
D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.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 */; }; D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; };
D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.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 */; }; D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; 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 */; }; D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; };
@ -25,7 +22,11 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PayfritBeacon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
@ -62,7 +63,6 @@
AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */, AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */,
F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */, F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -76,6 +76,14 @@
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C05000000009 /* Products */ = {
isa = PBXGroup;
children = (
C03000000001 /* PayfritBeacon.app */,
);
name = Products;
sourceTree = "<group>";
};
D05000000002 /* PayfritBeacon */ = { D05000000002 /* PayfritBeacon */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -93,18 +101,14 @@
D02000000060 /* Assets.xcassets */, D02000000060 /* Assets.xcassets */,
D02000000070 /* payfrit-favicon-light-outlines.svg */, D02000000070 /* payfrit-favicon-light-outlines.svg */,
D02000000080 /* InfoPlist.strings */, D02000000080 /* InfoPlist.strings */,
964B63D1857877BBEE73F1D1 /* BeaconShardPool.swift */,
8710F334FDFE10F0625EB86D /* BLEBeaconScanner.swift */,
C7C275738594E225BE7D5740 /* BeaconProvisioner.swift */,
E1775119CBC98A753AE26D84 /* ServicePointListView.swift */,
); );
path = PayfritBeacon; path = PayfritBeacon;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C05000000009 /* Products */ = {
isa = PBXGroup;
children = (
C03000000001 /* PayfritBeacon.app */,
);
name = Products;
sourceTree = "<group>";
};
EEC06FED6BE78CF9357F3158 /* Frameworks */ = { EEC06FED6BE78CF9357F3158 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -143,7 +147,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500; LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500; LastUpgradeCheck = 1620;
TargetAttributes = { TargetAttributes = {
C06000000001 = { C06000000001 = {
CreatedOnToolsVersion = 15.0; CreatedOnToolsVersion = 15.0;
@ -230,14 +234,11 @@
files = ( files = (
D01000000001 /* PayfritBeaconApp.swift in Sources */, D01000000001 /* PayfritBeaconApp.swift in Sources */,
D01000000002 /* Api.swift in Sources */, D01000000002 /* Api.swift in Sources */,
D01000000003 /* BeaconBanList.swift in Sources */,
D01000000004 /* BeaconScanner.swift in Sources */,
D01000000005 /* DevBanner.swift in Sources */, D01000000005 /* DevBanner.swift in Sources */,
D01000000006 /* LoginView.swift in Sources */, D01000000006 /* LoginView.swift in Sources */,
D01000000007 /* BusinessListView.swift in Sources */, D01000000007 /* BusinessListView.swift in Sources */,
D01000000008 /* ScanView.swift in Sources */,
D01000000009 /* QrScanView.swift in Sources */,
D0100000000A /* RootView.swift in Sources */, D0100000000A /* RootView.swift in Sources */,
281CC856DD918C4CFA00EB67 /* ServicePointListView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -379,7 +380,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;
@ -412,7 +413,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritBeacon/Info.plist; INFOPLIST_FILE = PayfritBeacon/Info.plist;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1500" LastUpgradeVersion = "1620"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View file

@ -4,7 +4,7 @@ class Api {
static let shared = Api() static let shared = Api()
// DEV toggle: flip to false for production // DEV toggle: flip to false for production
static let IS_DEV = true static let IS_DEV = false
private static var BASE_URL: String { private static var BASE_URL: String {
IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api"
@ -274,6 +274,174 @@ class Api {
return true 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 // HELPERS
// ========================================================================= // =========================================================================
@ -363,3 +531,26 @@ struct MacLookupResult {
let macAddress: String let macAddress: String
let servicePointName: 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
}

View file

@ -5,6 +5,12 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "appicon.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
} }
], ],
"info" : { "info" : {

View 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 }
}
}

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

View file

@ -13,7 +13,15 @@ class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
private var locationManager: CLLocationManager! private var locationManager: CLLocationManager!
private var activeRegions: [CLBeaconRegion] = [] 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() { override init() {
super.init() super.init()
@ -74,10 +82,16 @@ class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
stopRanging() stopRanging()
var results: [DetectedBeacon] = [] var results: [DetectedBeacon] = []
for (uuid, samples) in beaconSamples { for (_, data) in beaconSamples {
let avgRssi = samples.reduce(0, +) / max(samples.count, 1) let avgRssi = data.rssiSamples.reduce(0, +) / max(data.rssiSamples.count, 1)
NSLog("\(BeaconScanner.TAG): Beacon \(uuid) - avgRssi=\(avgRssi), samples=\(samples.count)") NSLog("\(BeaconScanner.TAG): Beacon \(data.uuid) major=\(data.major) minor=\(data.minor) - avgRssi=\(avgRssi), samples=\(data.rssiSamples.count)")
results.append(DetectedBeacon(uuid: uuid, rssi: avgRssi, samples: samples.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 } results.sort { $0.rssi > $1.rssi }
@ -108,11 +122,14 @@ class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue } guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue }
let uuid = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased() 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 { if beaconSamples[key] == nil {
beaconSamples[uuid] = [] 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 { struct DetectedBeacon {
let uuid: String // 32-char uppercase hex, no dashes let uuid: String // 32-char uppercase hex, no dashes
let major: UInt16
let minor: UInt16
let rssi: Int let rssi: Int
let samples: 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]))"
}
} }

View file

@ -7,72 +7,72 @@ import CoreLocation
/// If we need more capacity, ship more UUIDs in a future app version. /// If we need more capacity, ship more UUIDs in a future app version.
enum BeaconShardPool { enum BeaconShardPool {
/// All Payfrit shard UUIDs as strings. /// All Payfrit shard UUIDs as strings (from BeaconShards database table).
static let uuidStrings: [String] = [ static let uuidStrings: [String] = [
"f7826da6-4fa2-4e98-8024-bc5b71e0893e", "34b8cd87-1905-47a9-a7b7-fad8a4c011a1",
"2f234454-cf6d-4a0f-adf2-f4911ba9ffa6", "5ee62089-8599-46f7-a399-d40c2f398712",
"b9407f30-f5f8-466e-aff9-25556b57fe6d", "fd3790ac-33eb-4091-b1c7-1e0615e68a87",
"e2c56db5-dffb-48d2-b060-d0f5a71096e0", "bfce1bc4-ad2a-462a-918b-df26752c378d",
"d0d3fa86-ca76-45ec-9bd9-6af4fac1e268", "845b64c7-0c91-41cd-9c30-ac56b6ae5ca1",
"a7ae2eb7-1f00-4168-b99b-a749bac36c92", "7de0b2fb-69a3-4dbb-9808-8f33e2566661",
"8deefbb9-f738-4297-8040-96668bb44281", "54c34c7e-5a9b-4738-a4b4-2e228337ae3c",
"5a4bcfce-174e-4bac-a814-092978f50e04", "70f9fc09-25e6-46ec-8395-72f487877a1a",
"74278bda-b644-4520-8f0c-720eaf059935", "f8cef0af-6bef-4ba6-8a5d-599d647b628c",
"e7eb6d67-2b4f-4c1b-8b6e-8c3b3f5d8b9a", "41868a10-7fd6-41c6-9b14-b5ca33c11471",
"1f3c5d7e-9a2b-4c8d-ae6f-0b1c2d3e4f5a", "25e1044d-446b-4403-9abd-1e15f806dfe9",
"a1b2c3d4-e5f6-4789-abcd-ef0123456789", "cdeefaf0-bf95-4dab-8bd5-f7b261f9935d",
"98765432-10fe-4cba-9876-543210fedcba", "bf3b156a-a0fb-4bad-b3fd-0b408ffc9d6e",
"deadbeef-cafe-4bab-dead-beefcafebabe", "11b7c63e-a61d-4530-a447-2cb8e6a30a45",
"c0ffee00-dead-4bee-f000-ba5eba11fade", "d0519f2d-97a1-4484-a2c2-57135affe427",
"0a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d", "d2d1caa9-aa89-4c9d-93b1-d6fe1527f622",
"12345678-90ab-4def-1234-567890abcdef", "6f65071f-e060-44e7-a6f9-5dae49cbf095",
"fedcba98-7654-4210-fedc-ba9876543210", "4492bbbb-8584-421a-8f26-cb20c7727ead",
"abcd1234-ef56-4789-abcd-1234ef567890", "73bc2b23-9cf8-4a93-8bfc-5cf44f03a973",
"11111111-2222-4333-4444-555566667777", "70129c14-78ed-447e-ab9e-638243f8bdae",
"88889999-aaaa-4bbb-cccc-ddddeeeeefff", "6956f91b-e581-48a5-b364-181662cb2f9f",
"01234567-89ab-4cde-f012-3456789abcde", "39fc9b45-d1b3-4d97-aa82-a52457bf808f",
"a0a0a0a0-b1b1-4c2c-d3d3-e4e4e4e4f5f5", "ef150f40-5e24-4267-a1d0-b5ea5ce66c99",
"f0e0d0c0-b0a0-4908-0706-050403020100", "ac504bbd-fbdb-46d0-83c0-cdf53e82cdf8",
"13579bdf-2468-4ace-1357-9bdf2468ace0", "bbecefd2-7317-4fd4-93d9-2661df8c4762",
"fdb97531-eca8-4642-0fdb-97531eca8642", "b252557a-e998-4b28-9d7d-bc9a8c907441",
"aabbccdd-eeff-4011-2233-445566778899", "527b504c-d363-438a-bb65-2db1db6cb487",
"99887766-5544-4332-2110-ffeeddccbbaa", "ea5eef55-b7e9-4866-a4a1-8b4a1f6ea79d",
"a1a2a3a4-b5b6-4c7c-8d9d-e0e1f2f3f4f5", "40a5d0c8-727a-47db-8ffd-154bfc36e03d",
"5f4f3f2f-1f0f-4efe-dfcf-bfaf9f8f7f6f", "4d90467e-5f68-41ef-b4ec-2b2c8ec1adce",
"00112233-4455-4667-7889-9aabbccddeef", "1cc513ee-627a-4cfe-b162-7cea3cb1374e",
"feeddccb-baa9-4887-7665-5443322110ff", "2913ab6e-ab0d-4666-bff1-7fe3169c4f55",
"1a2b3c4d-5e6f-4a0b-1c2d-3e4f5a6b7c8d", "7371381a-f2aa-4a40-b497-b06e66d51a31",
"d8c7b6a5-9483-4726-1504-f3e2d1c0b9a8", "e890450f-0b8d-4a5a-973e-5af37233c13b",
"0f1e2d3c-4b5a-4697-8879-6a5b4c3d2e1f", "d190eef0-59ee-44bc-a459-e0d5b767b26f",
"f1e2d3c4-b5a6-4978-8697-a5b4c3d2e1f0", "76ebe90f-f4b2-45d4-9887-841b1ddd3ca9",
"12ab34cd-56ef-4789-0abc-def123456789", "7fbed5b0-a212-4497-9b54-9831e433491b",
"987654fe-dcba-4098-7654-321fedcba098", "3d41e7b0-5d91-4178-81c1-de42ab6b3466",
"abcdef01-2345-4678-9abc-def012345678", "5befd90a-7967-4fe5-89ba-7a9de617d507",
"876543fe-dcba-4210-9876-543fedcba210", "e033235c-f69d-4018-a197-78e7df59dfa3",
"0a0b0c0d-0e0f-4101-1121-314151617181", "71edc8b9-b120-415a-a1d4-77bdd8e30f14",
"91a1b1c1-d1e1-4f10-2030-405060708090", "521de568-a0e6-4ec9-bf1c-bdb7b9f9cae2",
"a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d", "a28db91b-c18f-4b4b-804f-38664d3456cc",
"d4c3b2a1-0f9e-48d7-c6b5-a49382716050", "5738e431-25bc-4cc1-a4e2-da8c562075b3",
"50607080-90a0-4b0c-0d0e-0f1011121314", "f90b7c87-324b-4fd5-b2ff-acf6800f6bd0",
"14131211-100f-4e0d-0c0b-0a0908070605", "bd4ea89c-f99d-4440-8e27-d295836fd09d",
"a1b2c3d4-e5f6-4718-293a-4b5c6d7e8f90", "b5c2d016-1143-4bb2-992b-f08fb073ef2c",
"09f8e7d6-c5b4-4a39-2817-0615f4e3d2c1", "0bb16d1a-f970-4baf-b410-76e5f1ff7c9e",
"11223344-5566-4778-899a-abbccddeeff0", "b4f22e62-4052-4c58-a18b-38e2a5c04b9a",
"ffeeddc0-bbaa-4988-7766-554433221100", "b8150be6-0fbd-4bb9-9993-a8a2992d5003",
"a1a1b2b2-c3c3-4d4d-e5e5-f6f6a7a7b8b8", "50d2d4c6-1907-4789-afe2-3b28baa3c679",
"b8b8a7a7-f6f6-4e5e-5d4d-4c3c3b2b2a1a", "ee42778d-53c9-42c9-8dfa-87a509799990",
"12341234-5678-4567-89ab-89abcdefcdef", "6001ee07-fc35-45f7-8ef6-afc30371bd73",
"fedcfedc-ba98-4ba9-8765-87654321d321", "0761bede-deb6-4b08-bfbb-10675060164a",
"0a1a2a3a-4a5a-4a6a-7a8a-9aaabacadaea", "c03ac1de-a7ea-490a-b3a9-7cc5e8ab4dd1",
"eadacaba-aa9a-48a7-a6a5-a4a3a2a1a0af", "57ecd21d-76b1-4016-8c86-7c5a861aae67",
"01020304-0506-4708-090a-0b0c0d0e0f10", "f119066c-a4e2-4b2e-aef3-6a0bf6b288bc",
"100f0e0d-0c0b-4a09-0807-060504030201", "e2c2ccff-d651-488f-9d74-4ecf4a0487e0",
"aabbccdd-1122-4334-4556-6778899aabbc", "7d5ba66c-d8f8-4d54-9900-4c52b5667682",
"cbba9988-7766-4554-4332-2110ddccbbaa", "1b0f57f9-0c02-43a5-9740-63acbc9574a0",
"f0f1f2f3-f4f5-4f6f-7f8f-9fafbfcfdfef", "314fdc08-fbfd-4bd8-aaae-e579d9ef567d",
"efdfcfbf-af9f-48f7-f6f5-f4f3f2f1f0ee", "5835398b-95ac-44ba-af78-a5d3dc4fc0ad",
"a0a1a2a3-a4a5-4a6a-7a8a-9a0b1b2b3b4b", "3eb1baca-84bb-4d85-8860-42a9df3b820e",
"4b3b2b1b-0a9a-48a7-a6a5-a4a3a2a1a0ff" "da73ba99-976c-4e81-894a-d799e05f9186"
] ]
/// All Payfrit shard UUIDs as UUID objects. /// All Payfrit shard UUIDs as UUID objects.

View file

@ -62,7 +62,8 @@ struct BusinessListView: View {
private func businessRow(_ business: Business) -> some View { private func businessRow(_ business: Business) -> some View {
HStack(spacing: 12) { HStack(spacing: 12) {
if let ext = business.headerImageExtension, !ext.isEmpty { 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) KFImage(imageUrl)
.resizable() .resizable()
.placeholder { .placeholder {
@ -100,7 +101,8 @@ struct BusinessListView: View {
private var addBusinessButton: some View { private var addBusinessButton: some View {
Button { 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) UIApplication.shared.open(url)
} }
} label: { } label: {

View file

@ -20,20 +20,28 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <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> <key>NSFaceIDUsageDescription</key>
<string>Payfrit Beacon uses Face ID for quick sign-in.</string> <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> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
<false/> <false/>
</dict> </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> </dict>
</plist> </plist>

View file

@ -31,7 +31,7 @@ struct RootView: View {
} }
) )
.fullScreenCover(item: $selectedBusiness) { business in .fullScreenCover(item: $selectedBusiness) { business in
ScanView( ServicePointListView(
businessId: business.businessId, businessId: business.businessId,
businessName: business.name, businessName: business.name,
onBack: { selectedBusiness = nil } onBack: { selectedBusiness = nil }

File diff suppressed because it is too large Load diff

View 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]))"
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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)
}

View 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
}
}

View 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)
}
}

View 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?()
}
}
}
}

View 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)
}
}

View 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
}
}
}

View 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
}
}
}

View 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
}
}
}

View 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) }
}

View 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
}
}
}

View 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
}
}
}

View 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
}
}

View 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)
}
}
}
}

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