Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- Flatten project structure: remove Models/, Services/, ViewModels/, Views/ subdirs - Replace APIService actor with simpler Api class, IS_DEV flag controls dev vs prod URL - Rewrite BeaconScanner to use CoreLocation (CLBeaconRegion ranging) instead of CoreBluetooth — iOS blocks iBeacon data from CBCentralManager - Add SVG logo on login page with proper scaling (was showing green square) - Make login page scrollable, add "enter 6-digit code" OTP instruction - Fix text input visibility (white on white) with .foregroundColor(.primary) - Add diagonal orange DEV ribbon banner (lower-left corner), gated on Api.IS_DEV - Update app icon: logo 10% larger, wifi icon closer - Add en.lproj/InfoPlist.strings for display name localization - Fix scan flash: keep isScanning=true until enrichment completes - Add Podfile with SVGKit, Kingfisher, CocoaLumberjack dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4faec5499d
commit
962a767863
34 changed files with 2236 additions and 2391 deletions
|
|
@ -7,71 +7,41 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
/* App Entry */
|
7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; };
|
||||||
C01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000001; };
|
D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; };
|
||||||
|
D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.swift */; };
|
||||||
/* Models */
|
D01000000003 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000003 /* BeaconBanList.swift */; };
|
||||||
C01000000010 /* Beacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000010; };
|
D01000000004 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000004 /* BeaconScanner.swift */; };
|
||||||
C01000000011 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000011; };
|
D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; };
|
||||||
C01000000012 /* Employment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000012; };
|
D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; };
|
||||||
|
D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; };
|
||||||
/* ViewModels */
|
D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; };
|
||||||
C01000000020 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000020; };
|
D01000000009 /* QrScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QrScanView.swift */; };
|
||||||
|
D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; };
|
||||||
/* Services */
|
D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; };
|
||||||
C01000000030 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000030; };
|
D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; };
|
||||||
C01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000031; };
|
D01000000080 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D02000000080 /* InfoPlist.strings */; };
|
||||||
C01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000032; };
|
|
||||||
|
|
||||||
/* Views */
|
|
||||||
C01000000040 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000040; };
|
|
||||||
C01000000041 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000041; };
|
|
||||||
C01000000042 /* BusinessSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000042; };
|
|
||||||
C01000000043 /* BeaconDashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000043; };
|
|
||||||
C01000000044 /* BeaconListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000044; };
|
|
||||||
C01000000045 /* BeaconDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000045; };
|
|
||||||
C01000000046 /* BeaconEditSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000046; };
|
|
||||||
C01000000047 /* ServicePointListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000047; };
|
|
||||||
C01000000048 /* ScannerScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000048; };
|
|
||||||
|
|
||||||
/* Resources */
|
|
||||||
C01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C02000000060; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
/* Product */
|
7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PayfritBeacon.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
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>"; };
|
||||||
|
D02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000002 /* Api.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000003 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000004 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000005 /* DevBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBanner.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000009 /* QrScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScanView.swift; sourceTree = "<group>"; };
|
||||||
|
D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
|
D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = "<group>"; };
|
||||||
|
D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
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>"; };
|
||||||
/* App Entry */
|
|
||||||
C02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
|
||||||
|
|
||||||
/* Models */
|
|
||||||
C02000000010 /* Beacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Beacon.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000011 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000012 /* Employment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Employment.swift; sourceTree = "<group>"; };
|
|
||||||
|
|
||||||
/* ViewModels */
|
|
||||||
C02000000020 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
|
||||||
|
|
||||||
/* Services */
|
|
||||||
C02000000030 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
|
|
||||||
|
|
||||||
/* Views */
|
|
||||||
C02000000040 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000041 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000042 /* BusinessSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessSelectionScreen.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000043 /* BeaconDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDashboard.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000044 /* BeaconListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconListScreen.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000045 /* BeaconDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDetailScreen.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000046 /* BeaconEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconEditSheet.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000047 /* ServicePointListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePointListScreen.swift; sourceTree = "<group>"; };
|
|
||||||
C02000000048 /* ScannerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerScreen.swift; sourceTree = "<group>"; };
|
|
||||||
|
|
||||||
/* Resources */
|
|
||||||
C02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -79,86 +49,54 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
04996117E2F5D5BB2D86CD46 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */,
|
||||||
|
F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C05000000001 = {
|
C05000000001 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
C05000000002 /* PayfritBeacon */,
|
D05000000002 /* PayfritBeacon */,
|
||||||
C05000000009 /* Products */,
|
C05000000009 /* Products */,
|
||||||
|
04996117E2F5D5BB2D86CD46 /* Pods */,
|
||||||
|
EEC06FED6BE78CF9357F3158 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
C05000000002 /* PayfritBeacon */ = {
|
D05000000002 /* PayfritBeacon */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
C02000000001 /* PayfritBeaconApp.swift */,
|
D02000000001 /* PayfritBeaconApp.swift */,
|
||||||
C02000000002 /* Info.plist */,
|
D0200000000A /* RootView.swift */,
|
||||||
C05000000003 /* Models */,
|
D02000000002 /* Api.swift */,
|
||||||
C05000000004 /* ViewModels */,
|
D02000000003 /* BeaconBanList.swift */,
|
||||||
C05000000005 /* Services */,
|
D02000000004 /* BeaconScanner.swift */,
|
||||||
C05000000006 /* Views */,
|
D02000000005 /* DevBanner.swift */,
|
||||||
C05000000007 /* Resources */,
|
D02000000006 /* LoginView.swift */,
|
||||||
|
D02000000007 /* BusinessListView.swift */,
|
||||||
|
D02000000008 /* ScanView.swift */,
|
||||||
|
D02000000009 /* QrScanView.swift */,
|
||||||
|
D02000000010 /* Info.plist */,
|
||||||
|
D02000000060 /* Assets.xcassets */,
|
||||||
|
D02000000070 /* payfrit-favicon-light-outlines.svg */,
|
||||||
|
D02000000080 /* InfoPlist.strings */,
|
||||||
);
|
);
|
||||||
path = PayfritBeacon;
|
path = PayfritBeacon;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
C05000000003 /* Models */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C02000000010 /* Beacon.swift */,
|
|
||||||
C02000000011 /* ServicePoint.swift */,
|
|
||||||
C02000000012 /* Employment.swift */,
|
|
||||||
);
|
|
||||||
path = Models;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C05000000004 /* ViewModels */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C02000000020 /* AppState.swift */,
|
|
||||||
);
|
|
||||||
path = ViewModels;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C05000000005 /* Services */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C02000000030 /* APIService.swift */,
|
|
||||||
C02000000031 /* AuthStorage.swift */,
|
|
||||||
C02000000032 /* BeaconScanner.swift */,
|
|
||||||
);
|
|
||||||
path = Services;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C05000000006 /* Views */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C02000000040 /* RootView.swift */,
|
|
||||||
C02000000041 /* LoginScreen.swift */,
|
|
||||||
C02000000042 /* BusinessSelectionScreen.swift */,
|
|
||||||
C02000000043 /* BeaconDashboard.swift */,
|
|
||||||
C02000000044 /* BeaconListScreen.swift */,
|
|
||||||
C02000000045 /* BeaconDetailScreen.swift */,
|
|
||||||
C02000000046 /* BeaconEditSheet.swift */,
|
|
||||||
C02000000047 /* ServicePointListScreen.swift */,
|
|
||||||
C02000000048 /* ScannerScreen.swift */,
|
|
||||||
);
|
|
||||||
path = Views;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C05000000007 /* Resources */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C02000000060 /* Assets.xcassets */,
|
|
||||||
);
|
|
||||||
name = Resources;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
C05000000009 /* Products */ = {
|
C05000000009 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -167,6 +105,14 @@
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
EEC06FED6BE78CF9357F3158 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
|
@ -174,9 +120,11 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = C08000000003 /* Build configuration list for PBXNativeTarget "PayfritBeacon" */;
|
buildConfigurationList = C08000000003 /* Build configuration list for PBXNativeTarget "PayfritBeacon" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
744B96DEA84C89E13D29B8B7 /* [CP] Check Pods Manifest.lock */,
|
||||||
C07000000001 /* Sources */,
|
C07000000001 /* Sources */,
|
||||||
C04000000001 /* Frameworks */,
|
C04000000001 /* Frameworks */,
|
||||||
C09000000001 /* Resources */,
|
C09000000001 /* Resources */,
|
||||||
|
66702B40BAEAF5430876D7CE /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
|
@ -225,39 +173,87 @@
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C01000000060 /* Assets.xcassets in Resources */,
|
D01000000060 /* Assets.xcassets in Resources */,
|
||||||
|
D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */,
|
||||||
|
D01000000080 /* InfoPlist.strings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
66702B40BAEAF5430876D7CE /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PayfritBeacon/Pods-PayfritBeacon-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
744B96DEA84C89E13D29B8B7 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-PayfritBeacon-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
C07000000001 /* Sources */ = {
|
C07000000001 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C01000000001 /* PayfritBeaconApp.swift in Sources */,
|
D01000000001 /* PayfritBeaconApp.swift in Sources */,
|
||||||
C01000000010 /* Beacon.swift in Sources */,
|
D01000000002 /* Api.swift in Sources */,
|
||||||
C01000000011 /* ServicePoint.swift in Sources */,
|
D01000000003 /* BeaconBanList.swift in Sources */,
|
||||||
C01000000012 /* Employment.swift in Sources */,
|
D01000000004 /* BeaconScanner.swift in Sources */,
|
||||||
C01000000020 /* AppState.swift in Sources */,
|
D01000000005 /* DevBanner.swift in Sources */,
|
||||||
C01000000030 /* APIService.swift in Sources */,
|
D01000000006 /* LoginView.swift in Sources */,
|
||||||
C01000000031 /* AuthStorage.swift in Sources */,
|
D01000000007 /* BusinessListView.swift in Sources */,
|
||||||
C01000000032 /* BeaconScanner.swift in Sources */,
|
D01000000008 /* ScanView.swift in Sources */,
|
||||||
C01000000040 /* RootView.swift in Sources */,
|
D01000000009 /* QrScanView.swift in Sources */,
|
||||||
C01000000041 /* LoginScreen.swift in Sources */,
|
D0100000000A /* RootView.swift in Sources */,
|
||||||
C01000000042 /* BusinessSelectionScreen.swift in Sources */,
|
|
||||||
C01000000043 /* BeaconDashboard.swift in Sources */,
|
|
||||||
C01000000044 /* BeaconListScreen.swift in Sources */,
|
|
||||||
C01000000045 /* BeaconDetailScreen.swift in Sources */,
|
|
||||||
C01000000046 /* BeaconEditSheet.swift in Sources */,
|
|
||||||
C01000000047 /* ServicePointListScreen.swift in Sources */,
|
|
||||||
C01000000048 /* ScannerScreen.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
D02000000080 /* InfoPlist.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
D02000000081 /* en */,
|
||||||
|
);
|
||||||
|
name = InfoPlist.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
C0B000000001 /* Debug */ = {
|
C0B000000001 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
|
@ -296,7 +292,7 @@
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
|
@ -358,7 +354,7 @@
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
|
@ -378,6 +374,7 @@
|
||||||
};
|
};
|
||||||
C0B000000003 /* Debug */ = {
|
C0B000000003 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
|
@ -410,6 +407,7 @@
|
||||||
};
|
};
|
||||||
C0B000000004 /* Release */ = {
|
C0B000000004 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
|
@ -462,7 +460,6 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
};
|
};
|
||||||
rootObject = C0A000000001 /* Project object */;
|
rootObject = C0A000000001 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
365
PayfritBeacon/Api.swift
Normal file
365
PayfritBeacon/Api.swift
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Api {
|
||||||
|
static let shared = Api()
|
||||||
|
|
||||||
|
// ── DEV toggle: flip to false for production ──
|
||||||
|
static let IS_DEV = true
|
||||||
|
|
||||||
|
private static var BASE_URL: String {
|
||||||
|
IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api"
|
||||||
|
}
|
||||||
|
|
||||||
|
private let session: URLSession
|
||||||
|
private var authToken: String?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
config.timeoutIntervalForResource = 30
|
||||||
|
session = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAuthToken(_ token: String?) {
|
||||||
|
authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthToken() -> String? {
|
||||||
|
return authToken
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequest(endpoint: String) -> URLRequest {
|
||||||
|
let url = URL(string: "\(Api.BASE_URL)\(endpoint)")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
if let token = authToken {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] {
|
||||||
|
var request = buildRequest(endpoint: endpoint)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
for (key, value) in extraHeaders {
|
||||||
|
request.setValue(value, forHTTPHeaderField: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw ApiException("Invalid response")
|
||||||
|
}
|
||||||
|
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
||||||
|
throw ApiException("Request failed: \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
throw ApiException("Empty response")
|
||||||
|
}
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw ApiException("Invalid JSON response")
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getRequest(endpoint: String) async throws -> [String: Any] {
|
||||||
|
var request = buildRequest(endpoint: endpoint)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw ApiException("Invalid response")
|
||||||
|
}
|
||||||
|
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
||||||
|
throw ApiException("Request failed: \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
throw ApiException("Empty response")
|
||||||
|
}
|
||||||
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
throw ApiException("Invalid JSON response")
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AUTH
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
func sendLoginOtp(phone: String) async throws -> OtpResponse {
|
||||||
|
let json = try await postRequest(endpoint: "/auth/loginOTP.cfm", body: ["Phone": phone])
|
||||||
|
|
||||||
|
guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else {
|
||||||
|
throw ApiException("Server error - please try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
return OtpResponse(uuid: uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
|
||||||
|
let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.cfm", body: [
|
||||||
|
"UUID": uuid,
|
||||||
|
"OTP": otp
|
||||||
|
])
|
||||||
|
|
||||||
|
let ok = parseBool(json["OK"] ?? json["ok"])
|
||||||
|
if !ok {
|
||||||
|
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code"
|
||||||
|
throw ApiException(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0,
|
||||||
|
token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "",
|
||||||
|
userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// BUSINESSES
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
func listBusinesses() async throws -> [Business] {
|
||||||
|
let json = try await postRequest(endpoint: "/businesses/list.cfm", body: [:])
|
||||||
|
|
||||||
|
guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return businesses.compactMap { b in
|
||||||
|
guard let businessId = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? ""
|
||||||
|
let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String
|
||||||
|
return Business(businessId: businessId, name: name, headerImageExtension: headerImageExtension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// BEACONS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
func listAllBeacons() async throws -> [String: Int] {
|
||||||
|
let json = try await getRequest(endpoint: "/beacons/list_all.cfm")
|
||||||
|
|
||||||
|
guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [String: Int] = [:]
|
||||||
|
for item in items {
|
||||||
|
guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)?
|
||||||
|
.replacingOccurrences(of: "-", with: "").uppercased(),
|
||||||
|
let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[uuid] = beaconId
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] {
|
||||||
|
if uuids.isEmpty { return [] }
|
||||||
|
|
||||||
|
let json = try await postRequest(endpoint: "/beacons/lookup.cfm", body: ["UUIDs": uuids])
|
||||||
|
|
||||||
|
guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return beacons.compactMap { b in
|
||||||
|
guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)?
|
||||||
|
.replacingOccurrences(of: "-", with: "").uppercased() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return BeaconLookupResult(
|
||||||
|
uuid: uuid,
|
||||||
|
beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0,
|
||||||
|
businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0,
|
||||||
|
businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "",
|
||||||
|
servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "",
|
||||||
|
beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listBeacons(businessId: Int) async throws -> [BeaconInfo] {
|
||||||
|
let json = try await postRequest(
|
||||||
|
endpoint: "/beacons/list.cfm",
|
||||||
|
body: ["BusinessID": businessId],
|
||||||
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return beacons.compactMap { b in
|
||||||
|
let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0
|
||||||
|
let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? ""
|
||||||
|
let uuid = ((b["UUID"] ?? b["uuid"]) as? String)?
|
||||||
|
.replacingOccurrences(of: "-", with: "").uppercased() ?? ""
|
||||||
|
let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true)
|
||||||
|
return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon {
|
||||||
|
var params: [String: Any] = [
|
||||||
|
"BusinessID": businessId,
|
||||||
|
"Name": name,
|
||||||
|
"UUID": uuid
|
||||||
|
]
|
||||||
|
if let mac = macAddress, !mac.isEmpty {
|
||||||
|
params["MACAddress"] = mac
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = try await postRequest(
|
||||||
|
endpoint: "/beacons/save.cfm",
|
||||||
|
body: params,
|
||||||
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
||||||
|
)
|
||||||
|
|
||||||
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
||||||
|
throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon")
|
||||||
|
}
|
||||||
|
|
||||||
|
let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any]
|
||||||
|
|
||||||
|
return SavedBeacon(
|
||||||
|
beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0,
|
||||||
|
name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name,
|
||||||
|
uuid: uuid,
|
||||||
|
macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupByMac(macAddress: String) async throws -> MacLookupResult? {
|
||||||
|
let json = try await postRequest(endpoint: "/beacons/lookupByMac.cfm", body: ["MACAddress": macAddress])
|
||||||
|
|
||||||
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return MacLookupResult(
|
||||||
|
beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0,
|
||||||
|
businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0,
|
||||||
|
businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "",
|
||||||
|
beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "",
|
||||||
|
uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "",
|
||||||
|
macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "",
|
||||||
|
servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool {
|
||||||
|
let json = try await postRequest(
|
||||||
|
endpoint: "/beacons/wipe.cfm",
|
||||||
|
body: ["BusinessID": businessId, "BeaconID": beaconId],
|
||||||
|
extraHeaders: ["X-Business-Id": String(businessId)]
|
||||||
|
)
|
||||||
|
|
||||||
|
if !parseBool(json["OK"] ?? json["ok"]) {
|
||||||
|
let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon"
|
||||||
|
let message = (json["MESSAGE"] ?? json["message"]) as? String
|
||||||
|
throw ApiException(message ?? error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HELPERS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private func parseBool(_ value: Any?) -> Bool {
|
||||||
|
switch value {
|
||||||
|
case nil:
|
||||||
|
return false
|
||||||
|
case let b as Bool:
|
||||||
|
return b
|
||||||
|
case let n as NSNumber:
|
||||||
|
return n.intValue != 0
|
||||||
|
case let s as String:
|
||||||
|
return ["true", "1"].contains(s.lowercased())
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseIntValue(_ value: Any?) -> Int? {
|
||||||
|
if let n = value as? NSNumber {
|
||||||
|
return n.intValue
|
||||||
|
}
|
||||||
|
if let s = value as? String, let i = Int(s) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiException: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
init(_ message: String) { self.message = message }
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DATA MODELS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
struct OtpResponse {
|
||||||
|
let uuid: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse {
|
||||||
|
let userId: Int
|
||||||
|
let token: String
|
||||||
|
let userFirstName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Business: Identifiable {
|
||||||
|
var id: Int { businessId }
|
||||||
|
let businessId: Int
|
||||||
|
let name: String
|
||||||
|
let headerImageExtension: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BeaconLookupResult {
|
||||||
|
let uuid: String
|
||||||
|
let beaconId: Int
|
||||||
|
let businessId: Int
|
||||||
|
let businessName: String
|
||||||
|
let servicePointName: String
|
||||||
|
let beaconName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BeaconInfo {
|
||||||
|
let beaconId: Int
|
||||||
|
let name: String
|
||||||
|
let uuid: String
|
||||||
|
let isActive: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavedBeacon {
|
||||||
|
let beaconId: Int
|
||||||
|
let name: String
|
||||||
|
let uuid: String
|
||||||
|
let macAddress: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MacLookupResult {
|
||||||
|
let beaconId: Int
|
||||||
|
let businessId: Int
|
||||||
|
let businessName: String
|
||||||
|
let beaconName: String
|
||||||
|
let uuid: String
|
||||||
|
let macAddress: String
|
||||||
|
let servicePointName: String
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "appicon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
|
||||||
BIN
PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png
Normal file
BIN
PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
79
PayfritBeacon/BeaconBanList.swift
Normal file
79
PayfritBeacon/BeaconBanList.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BeaconBanList {
|
||||||
|
|
||||||
|
/// Known default UUID prefixes (first 8 hex chars of the 32-char UUID).
|
||||||
|
private static let BANNED_PREFIXES: [String: String] = [
|
||||||
|
// Apple AirLocate / Minew factory default
|
||||||
|
"E2C56DB5": "Apple AirLocate / Minew factory default",
|
||||||
|
// Kontakt.io default
|
||||||
|
"F7826DA6": "Kontakt.io factory default",
|
||||||
|
// Radius Networks default
|
||||||
|
"2F234454": "Radius Networks default",
|
||||||
|
// Estimote default
|
||||||
|
"B9407F30": "Estimote factory default",
|
||||||
|
// Generic Chinese bulk manufacturer defaults (also Feasycom)
|
||||||
|
"FDA50693": "Generic bulk / Feasycom factory default",
|
||||||
|
"74278BDA": "Generic bulk manufacturer default",
|
||||||
|
"8492E75F": "Generic bulk manufacturer default",
|
||||||
|
"A0B13730": "Generic bulk manufacturer default",
|
||||||
|
// JAALEE default
|
||||||
|
"EBEFD083": "JAALEE factory default",
|
||||||
|
// April Brother default
|
||||||
|
"B5B182C7": "April Brother factory default",
|
||||||
|
// BlueCharm / unconfigured
|
||||||
|
"00000000": "Unconfigured / zeroed UUID",
|
||||||
|
"FFFFFFFF": "Unconfigured / max UUID",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Full UUIDs that are known defaults (exact match on 32-char uppercase hex).
|
||||||
|
private static let BANNED_FULL_UUIDS: [String: String] = [
|
||||||
|
"E2C56DB5DFFB48D2B060D0F5A71096E0": "Apple AirLocate sample UUID",
|
||||||
|
"B9407F30F5F8466EAFF925556B57FE6D": "Estimote factory default",
|
||||||
|
"2F234454CF6D4A0FADF2F4911BA9FFA6": "Radius Networks default",
|
||||||
|
"FDA50693A4E24FB1AFCFC6EB07647825": "Generic Chinese bulk default",
|
||||||
|
"74278BDAB64445208F0C720EAF059935": "Generic bulk default",
|
||||||
|
"00000000000000000000000000000000": "Zeroed UUID \u{2014} unconfigured hardware",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Check if a UUID is on the ban list.
|
||||||
|
static func isBanned(_ uuid: String) -> Bool {
|
||||||
|
let normalized = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||||
|
|
||||||
|
// Check full UUID match
|
||||||
|
if BANNED_FULL_UUIDS[normalized] != nil { return true }
|
||||||
|
|
||||||
|
// Check prefix match (first 8 chars)
|
||||||
|
let prefix = String(normalized.prefix(8))
|
||||||
|
if BANNED_PREFIXES[prefix] != nil { return true }
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the reason a UUID is banned, or nil if not banned.
|
||||||
|
static func getBanReason(_ uuid: String) -> String? {
|
||||||
|
let normalized = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||||
|
|
||||||
|
// Check full UUID match first
|
||||||
|
if let reason = BANNED_FULL_UUIDS[normalized] { return reason }
|
||||||
|
|
||||||
|
// Check prefix
|
||||||
|
let prefix = String(normalized.prefix(8))
|
||||||
|
if let reason = BANNED_PREFIXES[prefix] { return reason }
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a raw UUID string (32 hex chars) into standard UUID format with dashes.
|
||||||
|
static func formatUuid(_ uuid: String) -> String {
|
||||||
|
let hex = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||||
|
guard hex.count == 32 else { return uuid }
|
||||||
|
let s = hex
|
||||||
|
let i0 = s.startIndex
|
||||||
|
let i8 = s.index(i0, offsetBy: 8)
|
||||||
|
let i12 = s.index(i0, offsetBy: 12)
|
||||||
|
let i16 = s.index(i0, offsetBy: 16)
|
||||||
|
let i20 = s.index(i0, offsetBy: 20)
|
||||||
|
return "\(s[i0..<i8])-\(s[i8..<i12])-\(s[i12..<i16])-\(s[i16..<i20])-\(s[i20...])"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
PayfritBeacon/BeaconScanner.swift
Normal file
128
PayfritBeacon/BeaconScanner.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Native beacon scanner using CoreLocation for iBeacon detection.
|
||||||
|
/// Based on the proven BeaconManager from payfrit-user-ios.
|
||||||
|
class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
|
private static let TAG = "BeaconScanner"
|
||||||
|
private static let MIN_RSSI: Int = -90
|
||||||
|
|
||||||
|
@Published var isScanning = false
|
||||||
|
@Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||||
|
|
||||||
|
private var locationManager: CLLocationManager!
|
||||||
|
private var activeRegions: [CLBeaconRegion] = []
|
||||||
|
private var beaconSamples: [String: [Int]] = [:]
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
if Thread.isMainThread {
|
||||||
|
setupLocationManager()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.sync {
|
||||||
|
setupLocationManager()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupLocationManager() {
|
||||||
|
locationManager = CLLocationManager()
|
||||||
|
locationManager.delegate = self
|
||||||
|
authorizationStatus = locationManager.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPermission() {
|
||||||
|
locationManager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPermissions() -> Bool {
|
||||||
|
let status = locationManager.authorizationStatus
|
||||||
|
return status == .authorizedWhenInUse || status == .authorizedAlways
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start ranging for the given UUIDs. Call stopAndCollect() after your desired duration.
|
||||||
|
func startRanging(uuids: [UUID]) {
|
||||||
|
NSLog("\(BeaconScanner.TAG): startRanging called with \(uuids.count) UUIDs")
|
||||||
|
|
||||||
|
stopRanging()
|
||||||
|
beaconSamples.removeAll()
|
||||||
|
|
||||||
|
guard !uuids.isEmpty else {
|
||||||
|
NSLog("\(BeaconScanner.TAG): No target UUIDs provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isScanning = true
|
||||||
|
|
||||||
|
for uuid in uuids {
|
||||||
|
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||||
|
let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString)
|
||||||
|
activeRegions.append(region)
|
||||||
|
|
||||||
|
NSLog("\(BeaconScanner.TAG): Starting ranging for UUID: \(uuid.uuidString)")
|
||||||
|
locationManager.startRangingBeacons(satisfying: constraint)
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("\(BeaconScanner.TAG): Ranging started for \(activeRegions.count) regions")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop ranging and return collected results sorted by signal strength.
|
||||||
|
func stopAndCollect() -> [DetectedBeacon] {
|
||||||
|
NSLog("\(BeaconScanner.TAG): stopAndCollect - beaconSamples has \(beaconSamples.count) entries")
|
||||||
|
|
||||||
|
stopRanging()
|
||||||
|
|
||||||
|
var results: [DetectedBeacon] = []
|
||||||
|
for (uuid, samples) in beaconSamples {
|
||||||
|
let avgRssi = samples.reduce(0, +) / max(samples.count, 1)
|
||||||
|
NSLog("\(BeaconScanner.TAG): Beacon \(uuid) - avgRssi=\(avgRssi), samples=\(samples.count)")
|
||||||
|
results.append(DetectedBeacon(uuid: uuid, rssi: avgRssi, samples: samples.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort { $0.rssi > $1.rssi }
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopRanging() {
|
||||||
|
for region in activeRegions {
|
||||||
|
let constraint = CLBeaconIdentityConstraint(uuid: region.uuid)
|
||||||
|
locationManager.stopRangingBeacons(satisfying: constraint)
|
||||||
|
}
|
||||||
|
activeRegions.removeAll()
|
||||||
|
isScanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
NSLog("\(BeaconScanner.TAG): Authorization changed to \(status.rawValue)")
|
||||||
|
authorizationStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
||||||
|
satisfying constraint: CLBeaconIdentityConstraint) {
|
||||||
|
for beacon in beacons {
|
||||||
|
let rssiValue = beacon.rssi
|
||||||
|
guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue }
|
||||||
|
|
||||||
|
let uuid = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
||||||
|
|
||||||
|
if beaconSamples[uuid] == nil {
|
||||||
|
beaconSamples[uuid] = []
|
||||||
|
}
|
||||||
|
beaconSamples[uuid]?.append(rssiValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
||||||
|
NSLog("\(BeaconScanner.TAG): Ranging FAILED for \(constraint.uuid): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DetectedBeacon {
|
||||||
|
let uuid: String // 32-char uppercase hex, no dashes
|
||||||
|
let rssi: Int
|
||||||
|
let samples: Int
|
||||||
|
}
|
||||||
142
PayfritBeacon/BusinessListView.swift
Normal file
142
PayfritBeacon/BusinessListView.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct BusinessListView: View {
|
||||||
|
@Binding var hasAutoSelected: Bool
|
||||||
|
var onBusinessSelected: (Business) -> Void
|
||||||
|
var onLogout: () -> Void
|
||||||
|
|
||||||
|
@State private var businesses: [Business] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack {
|
||||||
|
if isLoading {
|
||||||
|
Spacer()
|
||||||
|
ProgressView("Loading businesses...")
|
||||||
|
Spacer()
|
||||||
|
} else if let error = errorMessage {
|
||||||
|
Spacer()
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.errorRed)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding()
|
||||||
|
addBusinessButton
|
||||||
|
Spacer()
|
||||||
|
} else if businesses.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
Text("No businesses found")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
addBusinessButton
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
List(businesses) { business in
|
||||||
|
Button {
|
||||||
|
onBusinessSelected(business)
|
||||||
|
} label: {
|
||||||
|
businessRow(business)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
|
||||||
|
addBusinessButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Business")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Log Out") {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
loadBusinesses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func businessRow(_ business: Business) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let ext = business.headerImageExtension, !ext.isEmpty {
|
||||||
|
let imageUrl = URL(string: "https://dev.payfrit.com/uploads/businesses/\(business.businessId)/header.\(ext)")
|
||||||
|
KFImage(imageUrl)
|
||||||
|
.resizable()
|
||||||
|
.placeholder {
|
||||||
|
Image(systemName: "building.2")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
Image(systemName: "building.2")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(business.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("Tap to configure beacons")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addBusinessButton: some View {
|
||||||
|
Button {
|
||||||
|
if let url = URL(string: "https://dev.payfrit.com/portal/index.html") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Add Business via Portal")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadBusinesses() {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await Api.shared.listBusinesses()
|
||||||
|
businesses = result
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
if businesses.count == 1 && !hasAutoSelected {
|
||||||
|
hasAutoSelected = true
|
||||||
|
onBusinessSelected(businesses[0])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logout() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: "token")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "userId")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "firstName")
|
||||||
|
Api.shared.setAuthToken(nil)
|
||||||
|
onLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
29
PayfritBeacon/DevBanner.swift
Normal file
29
PayfritBeacon/DevBanner.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DevRibbon: View {
|
||||||
|
var body: some View {
|
||||||
|
if Api.IS_DEV {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack {
|
||||||
|
Text("DEV")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.tracking(2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 140, height: 22)
|
||||||
|
.background(Color(red: 255/255, green: 152/255, blue: 0/255))
|
||||||
|
.rotationEffect(.degrees(45))
|
||||||
|
.position(x: 35, y: geo.size.height - 35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DevBanner: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay {
|
||||||
|
DevRibbon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,9 @@
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>Payfrit Beacon</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Payfrit Beacon</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
|
|
@ -26,11 +28,8 @@
|
||||||
<string>Payfrit Beacon uses Bluetooth to scan for and manage nearby beacons.</string>
|
<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>UIBackgroundModes</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<array>
|
<string>Payfrit Beacon uses the camera to scan QR codes on beacon stickers.</string>
|
||||||
<string>bluetooth-central</string>
|
|
||||||
<string>location</string>
|
|
||||||
</array>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|
|
||||||
236
PayfritBeacon/LoginView.swift
Normal file
236
PayfritBeacon/LoginView.swift
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import SwiftUI
|
||||||
|
import LocalAuthentication
|
||||||
|
import SVGKit
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
var onLoginSuccess: (String, Int) -> Void
|
||||||
|
|
||||||
|
@State private var otpUuid: String?
|
||||||
|
@State private var phone = ""
|
||||||
|
@State private var otp = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var showPhoneInput = false
|
||||||
|
@State private var showOtpInput = false
|
||||||
|
@State private var hasCheckedAuth = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer(minLength: 80)
|
||||||
|
|
||||||
|
// SVG Logo
|
||||||
|
SVGLogoView(width: 200)
|
||||||
|
.frame(width: 200, height: 117)
|
||||||
|
|
||||||
|
Text("Payfrit Beacon")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.errorRed)
|
||||||
|
.font(.callout)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showPhoneInput {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
TextField("Phone number", text: $phone)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.textContentType(.telephoneNumber)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Button(action: sendOtp) {
|
||||||
|
Text("Send Code")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.payfritGreen)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showOtpInput {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Please enter the 6-digit code sent to your phone")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
TextField("Verification code", text: $otp)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Button(action: verifyOtp) {
|
||||||
|
Text("Verify")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.payfritGreen)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !hasCheckedAuth else { return }
|
||||||
|
hasCheckedAuth = true
|
||||||
|
checkSavedAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkSavedAuth() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let savedToken = defaults.string(forKey: "token")
|
||||||
|
let savedUserId = defaults.integer(forKey: "userId")
|
||||||
|
|
||||||
|
if let token = savedToken, !token.isEmpty, savedUserId > 0 {
|
||||||
|
if canUseBiometrics() {
|
||||||
|
showBiometricPrompt(token: token, userId: savedUserId)
|
||||||
|
} else {
|
||||||
|
loginSuccess(token: token, userId: savedUserId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showPhoneInput = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canUseBiometrics() -> Bool {
|
||||||
|
let context = LAContext()
|
||||||
|
var error: NSError?
|
||||||
|
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showBiometricPrompt(token: String, userId: Int) {
|
||||||
|
let context = LAContext()
|
||||||
|
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
|
||||||
|
localizedReason: "Sign in to Payfrit Beacon") { success, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if success {
|
||||||
|
loginSuccess(token: token, userId: userId)
|
||||||
|
} else {
|
||||||
|
showPhoneInput = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendOtp() {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 10 else {
|
||||||
|
errorMessage = "Enter a valid phone number"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await Api.shared.sendLoginOtp(phone: trimmed)
|
||||||
|
otpUuid = response.uuid
|
||||||
|
|
||||||
|
showPhoneInput = false
|
||||||
|
showOtpInput = true
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyOtp() {
|
||||||
|
guard let uuid = otpUuid else { return }
|
||||||
|
let trimmed = otp.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 4 else {
|
||||||
|
errorMessage = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await Api.shared.verifyLoginOtp(uuid: uuid, otp: trimmed)
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(response.token, forKey: "token")
|
||||||
|
defaults.set(response.userId, forKey: "userId")
|
||||||
|
defaults.set(response.userFirstName, forKey: "firstName")
|
||||||
|
|
||||||
|
loginSuccess(token: response.token, userId: response.userId)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginSuccess(token: String, userId: Int) {
|
||||||
|
Api.shared.setAuthToken(token)
|
||||||
|
onLoginSuccess(token, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SVG Logo UIKit wrapper
|
||||||
|
|
||||||
|
struct SVGLogoView: UIViewRepresentable {
|
||||||
|
var width: CGFloat = 200
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
container.clipsToBounds = true
|
||||||
|
|
||||||
|
let svgPath = Bundle.main.path(forResource: "payfrit-favicon-light-outlines", ofType: "svg") ?? ""
|
||||||
|
guard let svgImage = SVGKImage(contentsOfFile: svgPath) else { return container }
|
||||||
|
|
||||||
|
// Scale SVG proportionally based on width
|
||||||
|
let nativeW = svgImage.size.width
|
||||||
|
let nativeH = svgImage.size.height
|
||||||
|
let scale = width / nativeW
|
||||||
|
svgImage.size = CGSize(width: width, height: nativeH * scale)
|
||||||
|
|
||||||
|
let imageView = SVGKLayeredImageView(svgkImage: svgImage) ?? UIImageView()
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
container.addSubview(imageView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
imageView.widthAnchor.constraint(equalToConstant: svgImage.size.width),
|
||||||
|
imageView.heightAnchor.constraint(equalToConstant: svgImage.size.height),
|
||||||
|
imageView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||||
|
imageView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Beacon: Identifiable {
|
|
||||||
let id: Int
|
|
||||||
let businessId: Int
|
|
||||||
let name: String
|
|
||||||
let uuid: String
|
|
||||||
let namespaceId: String
|
|
||||||
let instanceId: String
|
|
||||||
let isActive: Bool
|
|
||||||
let createdAt: Date?
|
|
||||||
let updatedAt: Date?
|
|
||||||
|
|
||||||
init(json: [String: Any]) {
|
|
||||||
id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0
|
|
||||||
businessId = Self.parseInt(json["BusinessID"]) ?? 0
|
|
||||||
name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? ""
|
|
||||||
uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? ""
|
|
||||||
namespaceId = (json["NamespaceId"] as? String) ?? ""
|
|
||||||
instanceId = (json["InstanceId"] as? String) ?? ""
|
|
||||||
isActive = Self.parseBool(json["IsActive"]) ?? true
|
|
||||||
createdAt = Self.parseDate(json["CreatedAt"])
|
|
||||||
updatedAt = Self.parseDate(json["UpdatedAt"])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format
|
|
||||||
var formattedUUID: String {
|
|
||||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
|
||||||
guard clean.count == 32 else { return uuid }
|
|
||||||
let i = clean.startIndex
|
|
||||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
|
||||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
|
||||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
|
||||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
|
||||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
|
||||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Parse Helpers
|
|
||||||
|
|
||||||
static func parseInt(_ value: Any?) -> Int? {
|
|
||||||
guard let value = value else { return nil }
|
|
||||||
if let v = value as? Int { return v }
|
|
||||||
if let v = value as? Double { return Int(v) }
|
|
||||||
if let v = value as? NSNumber { return v.intValue }
|
|
||||||
if let v = value as? String, let i = Int(v) { return i }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseBool(_ value: Any?) -> Bool? {
|
|
||||||
guard let value = value else { return nil }
|
|
||||||
if let b = value as? Bool { return b }
|
|
||||||
if let i = value as? Int { return i == 1 }
|
|
||||||
if let s = value as? String {
|
|
||||||
let lower = s.lowercased()
|
|
||||||
if lower == "true" || lower == "1" || lower == "yes" { return true }
|
|
||||||
if lower == "false" || lower == "0" || lower == "no" { return false }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseDate(_ value: Any?) -> Date? {
|
|
||||||
guard let value = value else { return nil }
|
|
||||||
if let d = value as? Date { return d }
|
|
||||||
if let s = value as? String { return APIService.parseDate(s) }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Employment: Identifiable {
|
|
||||||
/// Composite ID to avoid collisions when same employee works at multiple businesses
|
|
||||||
var id: String { "\(employeeId)-\(businessId)" }
|
|
||||||
let employeeId: Int
|
|
||||||
let businessId: Int
|
|
||||||
let businessName: String
|
|
||||||
let businessAddress: String
|
|
||||||
let businessCity: String
|
|
||||||
let employeeStatusId: Int
|
|
||||||
let pendingTaskCount: Int
|
|
||||||
|
|
||||||
var statusName: String {
|
|
||||||
switch employeeStatusId {
|
|
||||||
case 1: return "Active"
|
|
||||||
case 2: return "Suspended"
|
|
||||||
case 3: return "Terminated"
|
|
||||||
default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(json: [String: Any]) {
|
|
||||||
employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0
|
|
||||||
businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
|
|
||||||
businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? ""
|
|
||||||
businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? ""
|
|
||||||
businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? ""
|
|
||||||
employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0
|
|
||||||
pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct ServicePoint: Identifiable {
|
|
||||||
let id: Int
|
|
||||||
let businessId: Int
|
|
||||||
let name: String
|
|
||||||
let typeId: Int
|
|
||||||
let typeName: String
|
|
||||||
let code: String
|
|
||||||
let description: String
|
|
||||||
let sortOrder: Int
|
|
||||||
let isActive: Bool
|
|
||||||
let isClosedToNewMembers: Bool
|
|
||||||
let beaconId: Int?
|
|
||||||
let assignedByUserId: Int?
|
|
||||||
let createdAt: Date?
|
|
||||||
let updatedAt: Date?
|
|
||||||
|
|
||||||
init(json: [String: Any]) {
|
|
||||||
id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0
|
|
||||||
businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
|
|
||||||
name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? ""
|
|
||||||
typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0
|
|
||||||
typeName = (json["TypeName"] as? String) ?? ""
|
|
||||||
code = (json["Code"] as? String) ?? ""
|
|
||||||
description = (json["Description"] as? String) ?? ""
|
|
||||||
sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
|
|
||||||
isActive = Beacon.parseBool(json["IsActive"]) ?? true
|
|
||||||
isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false
|
|
||||||
beaconId = Beacon.parseInt(json["BeaconID"])
|
|
||||||
assignedByUserId = Beacon.parseInt(json["AssignedByUserID"])
|
|
||||||
createdAt = Beacon.parseDate(json["CreatedAt"])
|
|
||||||
updatedAt = Beacon.parseDate(json["UpdatedAt"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ServicePointType: Identifiable {
|
|
||||||
let id: Int
|
|
||||||
let name: String
|
|
||||||
let sortOrder: Int
|
|
||||||
|
|
||||||
init(json: [String: Any]) {
|
|
||||||
id = Beacon.parseInt(json["ID"]) ?? 0
|
|
||||||
name = (json["Name"] as? String) ?? ""
|
|
||||||
sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,16 +2,23 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct PayfritBeaconApp: App {
|
struct PayfritBeaconApp: App {
|
||||||
@StateObject private var appState = AppState()
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environmentObject(appState)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294)
|
static let payfritGreen = Color(red: 34/255, green: 178/255, blue: 75/255) // #22B24B
|
||||||
|
static let signalStrong = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50
|
||||||
|
static let signalMedium = Color(red: 249/255, green: 168/255, blue: 37/255) // #F9A825
|
||||||
|
static let signalWeak = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A
|
||||||
|
static let infoBlue = Color(red: 33/255, green: 150/255, blue: 243/255) // #2196F3
|
||||||
|
static let warningOrange = Color(red: 255/255, green: 152/255, blue: 0/255) // #FF9800
|
||||||
|
static let errorRed = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A
|
||||||
|
static let successGreen = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50
|
||||||
|
static let newBg = Color(red: 76/255, green: 175/255, blue: 80/255).opacity(0.12)
|
||||||
|
static let assignedBg = Color(red: 33/255, green: 150/255, blue: 243/255).opacity(0.12)
|
||||||
|
static let bannedBg = Color(red: 186/255, green: 26/255, blue: 26/255).opacity(0.12)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
192
PayfritBeacon/QrScanView.swift
Normal file
192
PayfritBeacon/QrScanView.swift
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
struct QrScanView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
var onResult: (String, String) -> Void // (value, type)
|
||||||
|
|
||||||
|
static let TYPE_MAC = "mac"
|
||||||
|
static let TYPE_UUID = "uuid"
|
||||||
|
static let TYPE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
CameraPreviewView(onQrDetected: { rawValue in
|
||||||
|
let parsed = parseQrData(rawValue)
|
||||||
|
onResult(parsed.value, parsed.type)
|
||||||
|
dismiss()
|
||||||
|
})
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Scan frame overlay
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.white.opacity(0.7), lineWidth: 2)
|
||||||
|
.frame(width: 250, height: 250)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar overlay
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("Scan QR Code")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
// Balance spacer
|
||||||
|
Color.clear.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.background(Color.black.opacity(0.3))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("Point camera at beacon sticker QR code")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.callout)
|
||||||
|
.padding()
|
||||||
|
.background(Color.black.opacity(0.5))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.bottom, 60)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(DevBanner())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseQrData(_ raw: String) -> (value: String, type: String) {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespaces).uppercased()
|
||||||
|
|
||||||
|
// MAC address with colons: AA:BB:CC:DD:EE:FF
|
||||||
|
if trimmed.range(of: "^[0-9A-F]{2}(:[0-9A-F]{2}){5}$", options: .regularExpression) != nil {
|
||||||
|
return (trimmed, QrScanView.TYPE_MAC)
|
||||||
|
}
|
||||||
|
// MAC address without separators: AABBCCDDEEFF (12 hex chars)
|
||||||
|
if trimmed.range(of: "^[0-9A-F]{12}$", options: .regularExpression) != nil {
|
||||||
|
return (formatMac(trimmed), QrScanView.TYPE_MAC)
|
||||||
|
}
|
||||||
|
// MAC address with dashes: AA-BB-CC-DD-EE-FF
|
||||||
|
if trimmed.range(of: "^[0-9A-F]{2}(-[0-9A-F]{2}){5}$", options: .regularExpression) != nil {
|
||||||
|
return (trimmed.replacingOccurrences(of: "-", with: ":"), QrScanView.TYPE_MAC)
|
||||||
|
}
|
||||||
|
// UUID with dashes
|
||||||
|
if trimmed.range(of: "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", options: .regularExpression) != nil {
|
||||||
|
return (trimmed, QrScanView.TYPE_UUID)
|
||||||
|
}
|
||||||
|
// UUID without dashes (32 hex chars)
|
||||||
|
if trimmed.range(of: "^[0-9A-F]{32}$", options: .regularExpression) != nil {
|
||||||
|
return (formatUuid(trimmed), QrScanView.TYPE_UUID)
|
||||||
|
}
|
||||||
|
return (trimmed, QrScanView.TYPE_UNKNOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMac(_ hex: String) -> String {
|
||||||
|
var result: [String] = []
|
||||||
|
var idx = hex.startIndex
|
||||||
|
for _ in 0..<6 {
|
||||||
|
let next = hex.index(idx, offsetBy: 2)
|
||||||
|
result.append(String(hex[idx..<next]))
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
return result.joined(separator: ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatUuid(_ hex: String) -> String {
|
||||||
|
return BeaconBanList.formatUuid(hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Preview UIKit wrapper
|
||||||
|
|
||||||
|
struct CameraPreviewView: UIViewControllerRepresentable {
|
||||||
|
var onQrDetected: (String) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> QrCameraViewController {
|
||||||
|
let vc = QrCameraViewController()
|
||||||
|
vc.onQrDetected = onQrDetected
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: QrCameraViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrCameraViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
var onQrDetected: ((String) -> Void)?
|
||||||
|
private var captureSession: AVCaptureSession?
|
||||||
|
private var hasScanned = false
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
let session = AVCaptureSession()
|
||||||
|
captureSession = session
|
||||||
|
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.canAddInput(input) {
|
||||||
|
session.addInput(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
if session.canAddOutput(output) {
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
previewLayer.frame = view.bounds
|
||||||
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
|
view.layer.addSublayer(previewLayer)
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
if let layer = view.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
|
||||||
|
layer.frame = view.bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection) {
|
||||||
|
guard !hasScanned else { return }
|
||||||
|
|
||||||
|
for object in metadataObjects {
|
||||||
|
guard let readableObject = object as? AVMetadataMachineReadableCodeObject,
|
||||||
|
let value = readableObject.stringValue, !value.isEmpty else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasScanned = true
|
||||||
|
captureSession?.stopRunning()
|
||||||
|
onQrDetected?(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
captureSession?.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
58
PayfritBeacon/RootView.swift
Normal file
58
PayfritBeacon/RootView.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@State private var isAuthenticated = false
|
||||||
|
@State private var isCheckingAuth = true
|
||||||
|
@State private var userId: Int = 0
|
||||||
|
@State private var selectedBusiness: Business?
|
||||||
|
@State private var hasAutoSelected = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isCheckingAuth {
|
||||||
|
VStack {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
} else if !isAuthenticated {
|
||||||
|
LoginView { token, uid in
|
||||||
|
userId = uid
|
||||||
|
isAuthenticated = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BusinessListView(
|
||||||
|
hasAutoSelected: $hasAutoSelected,
|
||||||
|
onBusinessSelected: { business in
|
||||||
|
selectedBusiness = business
|
||||||
|
},
|
||||||
|
onLogout: {
|
||||||
|
isAuthenticated = false
|
||||||
|
selectedBusiness = nil
|
||||||
|
hasAutoSelected = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.fullScreenCover(item: $selectedBusiness) { business in
|
||||||
|
ScanView(
|
||||||
|
businessId: business.businessId,
|
||||||
|
businessName: business.name,
|
||||||
|
onBack: { selectedBusiness = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(DevBanner())
|
||||||
|
.onAppear {
|
||||||
|
checkAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkAuth() {
|
||||||
|
let token = UserDefaults.standard.string(forKey: "token")
|
||||||
|
let savedUserId = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
|
||||||
|
if let token = token, !token.isEmpty, savedUserId > 0 {
|
||||||
|
isCheckingAuth = false
|
||||||
|
} else {
|
||||||
|
isCheckingAuth = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
779
PayfritBeacon/ScanView.swift
Normal file
779
PayfritBeacon/ScanView.swift
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Data Types
|
||||||
|
|
||||||
|
enum BeaconStatus {
|
||||||
|
case new, thisBusiness, otherBusiness, banned
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EnrichedBeacon: Identifiable {
|
||||||
|
var id: String { uuid }
|
||||||
|
let uuid: String
|
||||||
|
let rssi: Int
|
||||||
|
let samples: Int
|
||||||
|
let status: BeaconStatus
|
||||||
|
let assignedBusinessName: String?
|
||||||
|
let assignedBeaconName: String?
|
||||||
|
let assignedServicePointName: String?
|
||||||
|
let beaconId: Int
|
||||||
|
let banReason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ScanView
|
||||||
|
|
||||||
|
struct ScanView: View {
|
||||||
|
let businessId: Int
|
||||||
|
let businessName: String
|
||||||
|
var onBack: () -> Void
|
||||||
|
|
||||||
|
@State private var nextTableNumber: Int = 1
|
||||||
|
@State private var hasScannedOnce = false
|
||||||
|
@State private var savedUuids: Set<String> = []
|
||||||
|
@State private var scanResults: [EnrichedBeacon] = []
|
||||||
|
@State private var knownAssignments: [String: BeaconLookupResult] = [:]
|
||||||
|
@State private var pendingQrMac: String?
|
||||||
|
@State private var pendingQrBeacon: EnrichedBeacon?
|
||||||
|
@State private var isScanning = false
|
||||||
|
@State private var snackMessage: String?
|
||||||
|
@State private var showQrScanner = false
|
||||||
|
@State private var qrScanForBeacon: EnrichedBeacon?
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
@State private var showAssignSheet = false
|
||||||
|
@State private var assignBeacon: EnrichedBeacon?
|
||||||
|
@State private var assignName = ""
|
||||||
|
|
||||||
|
@State private var showBannedSheet = false
|
||||||
|
@State private var bannedBeacon: EnrichedBeacon?
|
||||||
|
@State private var generatedUuid: String?
|
||||||
|
|
||||||
|
@State private var showInfoAlert = false
|
||||||
|
@State private var infoBeacon: EnrichedBeacon?
|
||||||
|
|
||||||
|
@State private var showOptionsAlert = false
|
||||||
|
@State private var optionsBeacon: EnrichedBeacon?
|
||||||
|
|
||||||
|
@State private var showWipeAlert = false
|
||||||
|
@State private var wipeBeacon: EnrichedBeacon?
|
||||||
|
|
||||||
|
@State private var showMacLookupAlert = false
|
||||||
|
@State private var macLookupResult: MacLookupResult?
|
||||||
|
|
||||||
|
@State private var showBeaconPickerAlert = false
|
||||||
|
@State private var pickerMac: String?
|
||||||
|
|
||||||
|
@State private var showNoBeaconsScanAlert = false
|
||||||
|
@State private var noBeaconsMac: String?
|
||||||
|
|
||||||
|
@StateObject private var beaconScanner = BeaconScanner()
|
||||||
|
@State private var targetUUIDs: [UUID] = []
|
||||||
|
|
||||||
|
// Common fallback UUIDs for beacons that may not be registered yet
|
||||||
|
private static let fallbackUUIDs = [
|
||||||
|
"E2C56DB5DFFB48D2B060D0F5A71096E0",
|
||||||
|
"B9407F30F5F8466EAFF925556B57FE6D",
|
||||||
|
"F7826DA64FA24E988024BC5B71E0893E",
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Toolbar
|
||||||
|
HStack {
|
||||||
|
Button(action: onBack) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
|
Text("Beacon Scanner")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
|
||||||
|
// Info bar
|
||||||
|
HStack {
|
||||||
|
Text(businessName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("Next: Table \(nextTableNumber)")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Beacon list
|
||||||
|
if scanResults.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
if isScanning || !hasScannedOnce {
|
||||||
|
ProgressView("Scanning for beacons...")
|
||||||
|
} else {
|
||||||
|
Text("No beacons detected nearby.")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 8) {
|
||||||
|
ForEach(scanResults) { beacon in
|
||||||
|
beaconRow(beacon)
|
||||||
|
.onTapGesture { handleBeaconTap(beacon) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom action bar
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: requestPermissionsAndScan) {
|
||||||
|
Text(hasScannedOnce ? "Scan Next" : "Scan for Beacons")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color.payfritGreen)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.disabled(isScanning)
|
||||||
|
|
||||||
|
Button(action: { launchQrScan(forBeacon: nil) }) {
|
||||||
|
Text("QR")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemGray5))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onBack) {
|
||||||
|
Text("Done")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.payfritGreen, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.modifier(DevBanner())
|
||||||
|
.overlay(snackOverlay, alignment: .bottom)
|
||||||
|
.onAppear { loadExistingBeacons() }
|
||||||
|
.sheet(isPresented: $showAssignSheet) { assignSheet }
|
||||||
|
.sheet(isPresented: $showBannedSheet) { bannedSheet }
|
||||||
|
.sheet(isPresented: $showQrScanner) {
|
||||||
|
QrScanView { value, type in
|
||||||
|
showQrScanner = false
|
||||||
|
handleQrScanResult(qrResult: value, qrType: type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Beacon Info", isPresented: $showInfoAlert, presenting: infoBeacon) { _ in
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { beacon in
|
||||||
|
Text(infoMessage(beacon))
|
||||||
|
}
|
||||||
|
.confirmationDialog("Beacon Options", isPresented: $showOptionsAlert, presenting: optionsBeacon) { beacon in
|
||||||
|
Button("Reassign") {
|
||||||
|
openAssignDialog(beacon)
|
||||||
|
}
|
||||||
|
Button("Wipe", role: .destructive) {
|
||||||
|
wipeBeacon = beacon
|
||||||
|
showWipeAlert = true
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { beacon in
|
||||||
|
Text(optionsMessage(beacon))
|
||||||
|
}
|
||||||
|
.alert("Wipe Beacon?", isPresented: $showWipeAlert, presenting: wipeBeacon) { beacon in
|
||||||
|
Button("Wipe", role: .destructive) {
|
||||||
|
performWipe(beacon)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: { beacon in
|
||||||
|
let beaconName = beacon.assignedBeaconName ?? "unnamed"
|
||||||
|
let spName = beacon.assignedServicePointName ?? beaconName
|
||||||
|
let displayName = beaconName == spName ? beaconName : "\(beaconName) (\(spName))"
|
||||||
|
Text("This will unlink \"\(displayName)\" from its service point. The beacon will appear as NEW on the next scan.")
|
||||||
|
}
|
||||||
|
.alert("MAC Lookup Result", isPresented: $showMacLookupAlert, presenting: macLookupResult) { _ in
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { result in
|
||||||
|
Text("Beacon: \(result.beaconName)\nBusiness: \(result.businessName)\nService Point: \(result.servicePointName)\nMAC: \(result.macAddress)\nUUID: \(result.uuid.isEmpty ? "Not set" : result.uuid)")
|
||||||
|
}
|
||||||
|
.alert("MAC Scanned", isPresented: $showNoBeaconsScanAlert, presenting: noBeaconsMac) { _ in
|
||||||
|
Button("Scan") {
|
||||||
|
requestPermissionsAndScan()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingQrMac = nil
|
||||||
|
}
|
||||||
|
} message: { mac in
|
||||||
|
Text("Scanned MAC: \(mac)\n\nNo beacons detected yet. Scan for beacons first to link this MAC address.")
|
||||||
|
}
|
||||||
|
.confirmationDialog("Link MAC to Beacon", isPresented: $showBeaconPickerAlert, presenting: pickerMac) { _ in
|
||||||
|
ForEach(Array(scanResults.enumerated()), id: \.element.uuid) { index, beacon in
|
||||||
|
let statusLabel: String = {
|
||||||
|
switch beacon.status {
|
||||||
|
case .new: return "NEW"
|
||||||
|
case .thisBusiness: return beacon.assignedBeaconName ?? "This business"
|
||||||
|
case .otherBusiness: return beacon.assignedBusinessName ?? "Other business"
|
||||||
|
case .banned: return "BANNED"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
Button("\(statusLabel) (\(beacon.rssi) dBm)") {
|
||||||
|
openAssignDialog(scanResults[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingQrMac = nil
|
||||||
|
}
|
||||||
|
} message: { mac in
|
||||||
|
Text("Select a beacon to link with MAC \(mac)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Beacon Row
|
||||||
|
|
||||||
|
private func beaconRow(_ beacon: EnrichedBeacon) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Signal strength indicator
|
||||||
|
Rectangle()
|
||||||
|
.fill(signalColor(beacon.rssi))
|
||||||
|
.frame(width: 4)
|
||||||
|
.cornerRadius(2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(BeaconBanList.formatUuid(beacon.uuid))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
statusBadge(beacon)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(beacon.rssi) dBm")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signalColor(_ rssi: Int) -> Color {
|
||||||
|
if rssi >= -60 { return .signalStrong }
|
||||||
|
if rssi >= -75 { return .signalMedium }
|
||||||
|
return .signalWeak
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(_ beacon: EnrichedBeacon) -> some View {
|
||||||
|
let (text, textColor, bgColor): (String, Color, Color) = {
|
||||||
|
switch beacon.status {
|
||||||
|
case .new:
|
||||||
|
return ("NEW", .successGreen, .newBg)
|
||||||
|
case .thisBusiness:
|
||||||
|
let name = beacon.assignedBeaconName
|
||||||
|
let label = (name != nil && !name!.isEmpty) ? "\(name!) (this business)" : "This business"
|
||||||
|
return (label, .infoBlue, .assignedBg)
|
||||||
|
case .otherBusiness:
|
||||||
|
let label = beacon.assignedBusinessName ?? "Other business"
|
||||||
|
return (label, .warningOrange, .assignedBg)
|
||||||
|
case .banned:
|
||||||
|
return ("BANNED", .errorRed, .bannedBg)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return Text(text)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(bgColor)
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snack Overlay
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var snackOverlay: some View {
|
||||||
|
if let message = snackMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.darkGray))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.onAppear {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||||
|
withAnimation { snackMessage = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSnack(_ message: String) {
|
||||||
|
withAnimation { snackMessage = message }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func handleBeaconTap(_ beacon: EnrichedBeacon) {
|
||||||
|
switch beacon.status {
|
||||||
|
case .new:
|
||||||
|
openAssignDialog(beacon)
|
||||||
|
case .thisBusiness:
|
||||||
|
optionsBeacon = beacon
|
||||||
|
showOptionsAlert = true
|
||||||
|
case .otherBusiness:
|
||||||
|
infoBeacon = beacon
|
||||||
|
showInfoAlert = true
|
||||||
|
case .banned:
|
||||||
|
bannedBeacon = beacon
|
||||||
|
generatedUuid = nil
|
||||||
|
showBannedSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadExistingBeacons() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let existing = try await Api.shared.listBeacons(businessId: businessId)
|
||||||
|
let maxNumber = existing.compactMap { beacon -> Int? in
|
||||||
|
guard let match = beacon.name.range(of: #"Table\s+(\d+)"#,
|
||||||
|
options: [.regularExpression, .caseInsensitive]) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let numberStr = beacon.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
|
||||||
|
return Int(numberStr)
|
||||||
|
}.max() ?? 0
|
||||||
|
|
||||||
|
nextTableNumber = maxNumber + 1
|
||||||
|
} catch {
|
||||||
|
// Silently continue — will use default table 1
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPermissionsAndScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPermissionsAndScan() {
|
||||||
|
Task {
|
||||||
|
// Request permission if needed
|
||||||
|
if beaconScanner.authorizationStatus == .notDetermined {
|
||||||
|
beaconScanner.requestPermission()
|
||||||
|
for _ in 0..<20 {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
if beaconScanner.authorizationStatus != .notDetermined { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard beaconScanner.hasPermissions() else {
|
||||||
|
showSnack("Location permission denied — required for beacon scanning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startScan() async {
|
||||||
|
isScanning = true
|
||||||
|
scanResults = []
|
||||||
|
|
||||||
|
// Fetch known beacon UUIDs from server if we haven't yet
|
||||||
|
if targetUUIDs.isEmpty {
|
||||||
|
do {
|
||||||
|
let serverBeacons = try await Api.shared.listAllBeacons()
|
||||||
|
var uuids: [UUID] = []
|
||||||
|
for (rawUuid, _) in serverBeacons {
|
||||||
|
let formatted = formatUuidString(rawUuid)
|
||||||
|
if let uuid = UUID(uuidString: formatted) {
|
||||||
|
uuids.append(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add fallback UUIDs
|
||||||
|
for raw in ScanView.fallbackUUIDs {
|
||||||
|
let formatted = formatUuidString(raw)
|
||||||
|
if let uuid = UUID(uuidString: formatted), !uuids.contains(uuid) {
|
||||||
|
uuids.append(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetUUIDs = uuids
|
||||||
|
} catch {
|
||||||
|
// Use fallbacks only
|
||||||
|
targetUUIDs = ScanView.fallbackUUIDs.compactMap { raw in
|
||||||
|
UUID(uuidString: formatUuidString(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !targetUUIDs.isEmpty else {
|
||||||
|
isScanning = false
|
||||||
|
showSnack("No beacon UUIDs to scan for")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range for 3 seconds
|
||||||
|
beaconScanner.startRanging(uuids: targetUUIDs)
|
||||||
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
|
|
||||||
|
let detected = beaconScanner.stopAndCollect()
|
||||||
|
onScanComplete(detected)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatUuidString(_ raw: String) -> String {
|
||||||
|
let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
|
||||||
|
guard clean.count == 32 else { return raw }
|
||||||
|
if raw.count == 36 { return raw.uppercased() }
|
||||||
|
let chars = Array(clean)
|
||||||
|
return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onScanComplete(_ detected: [DetectedBeacon]) {
|
||||||
|
let filtered = detected.filter { !savedUuids.contains($0.uuid) }
|
||||||
|
|
||||||
|
if filtered.isEmpty {
|
||||||
|
scanResults = []
|
||||||
|
hasScannedOnce = true
|
||||||
|
isScanning = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let lookupResults = try await Api.shared.lookupBeacons(uuids: filtered.map { $0.uuid })
|
||||||
|
knownAssignments.removeAll()
|
||||||
|
for result in lookupResults {
|
||||||
|
knownAssignments[result.uuid] = result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue without lookup data
|
||||||
|
}
|
||||||
|
|
||||||
|
scanResults = filtered.map { beacon in
|
||||||
|
let lookup = knownAssignments[beacon.uuid]
|
||||||
|
let isBanned = BeaconBanList.isBanned(beacon.uuid)
|
||||||
|
let banReason = BeaconBanList.getBanReason(beacon.uuid)
|
||||||
|
|
||||||
|
let status: BeaconStatus
|
||||||
|
if isBanned {
|
||||||
|
status = .banned
|
||||||
|
} else if let lookup = lookup, lookup.businessId == businessId {
|
||||||
|
status = .thisBusiness
|
||||||
|
} else if lookup != nil {
|
||||||
|
status = .otherBusiness
|
||||||
|
} else {
|
||||||
|
status = .new
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnrichedBeacon(
|
||||||
|
uuid: beacon.uuid,
|
||||||
|
rssi: beacon.rssi,
|
||||||
|
samples: beacon.samples,
|
||||||
|
status: status,
|
||||||
|
assignedBusinessName: lookup?.businessName,
|
||||||
|
assignedBeaconName: lookup?.beaconName,
|
||||||
|
assignedServicePointName: lookup?.servicePointName,
|
||||||
|
beaconId: lookup?.beaconId ?? 0,
|
||||||
|
banReason: banReason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasScannedOnce = true
|
||||||
|
isScanning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Assign Dialog
|
||||||
|
|
||||||
|
private func openAssignDialog(_ beacon: EnrichedBeacon) {
|
||||||
|
assignBeacon = beacon
|
||||||
|
assignName = "Table \(nextTableNumber)"
|
||||||
|
showAssignSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var assignSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if let beacon = assignBeacon {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("UUID: \(BeaconBanList.formatUuid(beacon.uuid))")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Text("RSSI: \(beacon.rssi) dBm")
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
if let mac = pendingQrMac {
|
||||||
|
HStack {
|
||||||
|
Text("MAC: \(mac)")
|
||||||
|
.font(.callout)
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.successGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if beacon.status == .otherBusiness {
|
||||||
|
Text("Warning: This beacon is assigned to \(beacon.assignedBusinessName ?? "another business"). Saving will reassign it.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.warningOrange)
|
||||||
|
}
|
||||||
|
if beacon.status == .banned {
|
||||||
|
Text("Warning: This UUID is a known factory default.\n(\(beacon.banReason ?? "Banned"))")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.errorRed)
|
||||||
|
}
|
||||||
|
if beacon.status == .thisBusiness {
|
||||||
|
Text("Currently: \(beacon.assignedBeaconName ?? "unnamed")")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.infoBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Beacon name", text: $assignName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Assign Beacon")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
pendingQrMac = nil
|
||||||
|
showAssignSheet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") {
|
||||||
|
let name = assignName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !name.isEmpty {
|
||||||
|
showAssignSheet = false
|
||||||
|
saveBeacon(uuid: beacon.uuid, name: name, macAddress: pendingQrMac)
|
||||||
|
pendingQrMac = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
|
Button {
|
||||||
|
showAssignSheet = false
|
||||||
|
launchQrScan(forBeacon: beacon)
|
||||||
|
} label: {
|
||||||
|
Label("Scan QR", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveBeacon(uuid: String, name: String, macAddress: String? = nil) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let saved = try await Api.shared.saveBeacon(
|
||||||
|
businessId: businessId, name: name, uuid: uuid, macAddress: macAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
savedUuids.insert(uuid)
|
||||||
|
|
||||||
|
// Increment table number
|
||||||
|
if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
|
||||||
|
let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
|
||||||
|
if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
|
||||||
|
nextTableNumber = savedNumber + 1
|
||||||
|
} else {
|
||||||
|
nextTableNumber += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextTableNumber += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
scanResults.removeAll { $0.uuid == uuid }
|
||||||
|
showSnack("Saved \"\(name)\"")
|
||||||
|
} catch {
|
||||||
|
showSnack(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Banned Dialog
|
||||||
|
|
||||||
|
private var bannedSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if let beacon = bannedBeacon {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Current UUID:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(BeaconBanList.formatUuid(beacon.uuid))
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
|
||||||
|
Text(beacon.banReason ?? "This UUID is a known factory default")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.errorRed)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Suggested Replacement:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if let uuid = generatedUuid {
|
||||||
|
Text(uuid)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
} else {
|
||||||
|
Text("Tap Generate to create a new UUID")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
generatedUuid = UUID().uuidString.uppercased()
|
||||||
|
} label: {
|
||||||
|
Label("Generate", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if let uuid = generatedUuid {
|
||||||
|
UIPasteboard.general.string = uuid
|
||||||
|
showSnack("UUID copied to clipboard")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(generatedUuid == nil)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Banned UUID")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { showBannedSheet = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Info / Options Messages
|
||||||
|
|
||||||
|
private func infoMessage(_ beacon: EnrichedBeacon) -> String {
|
||||||
|
let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
|
||||||
|
return "UUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm\nName: \(beacon.assignedBeaconName ?? "unnamed")\n\nThis beacon is registered to \(beacon.assignedBusinessName ?? "another") business and cannot be reassigned from here."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionsMessage(_ beacon: EnrichedBeacon) -> String {
|
||||||
|
let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
|
||||||
|
let beaconName = beacon.assignedBeaconName ?? "unnamed"
|
||||||
|
let spName = beacon.assignedServicePointName ?? beaconName
|
||||||
|
return "Beacon: \(beaconName)\nService Point: \(spName)\nUUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wipe
|
||||||
|
|
||||||
|
private func performWipe(_ beacon: EnrichedBeacon) {
|
||||||
|
guard beacon.beaconId > 0 else {
|
||||||
|
showSnack("Cannot wipe: invalid beacon ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await Api.shared.wipeBeacon(businessId: businessId, beaconId: beacon.beaconId)
|
||||||
|
savedUuids.remove(beacon.uuid)
|
||||||
|
scanResults.removeAll { $0.uuid == beacon.uuid }
|
||||||
|
showSnack("Beacon wiped")
|
||||||
|
} catch {
|
||||||
|
showSnack(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - QR Scan
|
||||||
|
|
||||||
|
private func launchQrScan(forBeacon: EnrichedBeacon?) {
|
||||||
|
pendingQrBeacon = forBeacon
|
||||||
|
showQrScanner = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleQrScanResult(qrResult: String?, qrType: String?) {
|
||||||
|
guard let qrResult = qrResult, !qrResult.isEmpty else { return }
|
||||||
|
|
||||||
|
switch qrType {
|
||||||
|
case QrScanView.TYPE_MAC:
|
||||||
|
if let beacon = pendingQrBeacon {
|
||||||
|
pendingQrMac = qrResult
|
||||||
|
showSnack("MAC scanned: \(qrResult)")
|
||||||
|
openAssignDialog(beacon)
|
||||||
|
} else {
|
||||||
|
lookupByMac(qrResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
case QrScanView.TYPE_UUID:
|
||||||
|
let matchingBeacon = scanResults.first {
|
||||||
|
$0.uuid.lowercased() == qrResult.replacingOccurrences(of: "-", with: "").lowercased()
|
||||||
|
}
|
||||||
|
if let beacon = matchingBeacon {
|
||||||
|
showSnack("UUID found in scan results")
|
||||||
|
openAssignDialog(beacon)
|
||||||
|
} else {
|
||||||
|
showSnack("UUID not found in nearby beacons")
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
showSnack("Unknown QR format: \(qrResult)")
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingQrBeacon = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lookupByMac(_ macAddress: String) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if let result = try await Api.shared.lookupByMac(macAddress: macAddress) {
|
||||||
|
macLookupResult = result
|
||||||
|
showMacLookupAlert = true
|
||||||
|
} else {
|
||||||
|
pendingQrMac = macAddress
|
||||||
|
showBeaconPickerForMac(macAddress)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showSnack(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showBeaconPickerForMac(_ macAddress: String) {
|
||||||
|
if scanResults.isEmpty {
|
||||||
|
noBeaconsMac = macAddress
|
||||||
|
showNoBeaconsScanAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pickerMac = macAddress
|
||||||
|
showBeaconPickerAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - API Errors
|
|
||||||
|
|
||||||
enum APIError: LocalizedError, Equatable {
|
|
||||||
case invalidURL
|
|
||||||
case noData
|
|
||||||
case decodingError(String)
|
|
||||||
case serverError(String)
|
|
||||||
case unauthorized
|
|
||||||
case networkError(String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .invalidURL: return "Invalid URL"
|
|
||||||
case .noData: return "No data received"
|
|
||||||
case .decodingError(let msg): return "Decoding error: \(msg)"
|
|
||||||
case .serverError(let msg): return msg
|
|
||||||
case .unauthorized: return "Unauthorized"
|
|
||||||
case .networkError(let msg): return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Login Response
|
|
||||||
|
|
||||||
struct LoginResponse {
|
|
||||||
let userId: Int
|
|
||||||
let userFirstName: String
|
|
||||||
let token: String
|
|
||||||
let photoUrl: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - API Service
|
|
||||||
|
|
||||||
actor APIService {
|
|
||||||
static let shared = APIService()
|
|
||||||
|
|
||||||
private enum Environment {
|
|
||||||
case development, production
|
|
||||||
|
|
||||||
var baseURL: String {
|
|
||||||
switch self {
|
|
||||||
case .development: return "https://dev.payfrit.com/api"
|
|
||||||
case .production: return "https://biz.payfrit.com/api"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let environment: Environment = .development
|
|
||||||
var isDev: Bool { environment == .development }
|
|
||||||
private var userToken: String?
|
|
||||||
private var userId: Int?
|
|
||||||
private var businessId: Int = 0
|
|
||||||
|
|
||||||
var baseURL: String { environment.baseURL }
|
|
||||||
|
|
||||||
// MARK: - Configuration
|
|
||||||
|
|
||||||
func setAuth(token: String?, userId: Int?) {
|
|
||||||
self.userToken = token
|
|
||||||
self.userId = userId
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBusinessId(_ id: Int) {
|
|
||||||
self.businessId = id
|
|
||||||
}
|
|
||||||
|
|
||||||
func getToken() -> String? { userToken }
|
|
||||||
func getUserId() -> Int? { userId }
|
|
||||||
func getBusinessId() -> Int { businessId }
|
|
||||||
|
|
||||||
// MARK: - Core Request
|
|
||||||
|
|
||||||
private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
|
|
||||||
let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
|
||||||
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
if let token = userToken, !token.isEmpty {
|
|
||||||
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
|
||||||
}
|
|
||||||
if businessId > 0 {
|
|
||||||
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
|
||||||
guard (200...299).contains(httpResponse.statusCode) else {
|
|
||||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let json = tryDecodeJSON(data) {
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
throw APIError.decodingError("Non-JSON response")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
|
||||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
guard let body = String(data: data, encoding: .utf8),
|
|
||||||
let start = body.firstIndex(of: "{"),
|
|
||||||
let end = body.lastIndex(of: "}") else { return nil }
|
|
||||||
let jsonStr = String(body[start...end])
|
|
||||||
guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
|
|
||||||
return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ok(_ json: [String: Any]) -> Bool {
|
|
||||||
for key in ["OK", "ok", "Ok"] {
|
|
||||||
if let b = json[key] as? Bool { return b }
|
|
||||||
if let i = json[key] as? Int { return i == 1 }
|
|
||||||
if let s = json[key] as? String {
|
|
||||||
let lower = s.lowercased()
|
|
||||||
return lower == "true" || lower == "1" || lower == "yes"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func err(_ json: [String: Any]) -> String {
|
|
||||||
let msg = (json["ERROR"] as? String) ?? (json["error"] as? String)
|
|
||||||
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
|
|
||||||
return msg.isEmpty ? "Unknown error" : msg
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
|
|
||||||
for key in keys {
|
|
||||||
if let arr = json[key] as? [[String: Any]] { return arr }
|
|
||||||
}
|
|
||||||
for (_, value) in json {
|
|
||||||
if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Auth
|
|
||||||
|
|
||||||
func login(username: String, password: String) async throws -> LoginResponse {
|
|
||||||
let json = try await postJSON("/auth/login.cfm", payload: [
|
|
||||||
"username": username,
|
|
||||||
"password": password
|
|
||||||
])
|
|
||||||
|
|
||||||
guard ok(json) else {
|
|
||||||
let e = err(json)
|
|
||||||
if e == "bad_credentials" {
|
|
||||||
throw APIError.serverError("Invalid email/phone or password")
|
|
||||||
}
|
|
||||||
throw APIError.serverError("Login failed: \(e)")
|
|
||||||
}
|
|
||||||
|
|
||||||
let uid = (json["UserID"] as? Int)
|
|
||||||
?? Int(json["UserID"] as? String ?? "")
|
|
||||||
?? (json["UserId"] as? Int)
|
|
||||||
?? 0
|
|
||||||
let token = (json["Token"] as? String)
|
|
||||||
?? (json["token"] as? String)
|
|
||||||
?? ""
|
|
||||||
|
|
||||||
guard uid > 0 else {
|
|
||||||
throw APIError.serverError("Login failed: no user ID returned")
|
|
||||||
}
|
|
||||||
guard !token.isEmpty else {
|
|
||||||
throw APIError.serverError("Login failed: no token returned")
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstName = (json["UserFirstName"] as? String)
|
|
||||||
?? (json["FirstName"] as? String)
|
|
||||||
?? (json["firstName"] as? String)
|
|
||||||
?? (json["Name"] as? String)
|
|
||||||
?? (json["name"] as? String)
|
|
||||||
?? ""
|
|
||||||
let photoUrl = (json["UserPhotoUrl"] as? String)
|
|
||||||
?? (json["PhotoUrl"] as? String)
|
|
||||||
?? (json["photoUrl"] as? String)
|
|
||||||
?? ""
|
|
||||||
|
|
||||||
self.userToken = token
|
|
||||||
self.userId = uid
|
|
||||||
|
|
||||||
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logout() {
|
|
||||||
userToken = nil
|
|
||||||
userId = nil
|
|
||||||
businessId = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Businesses
|
|
||||||
|
|
||||||
func getMyBusinesses() async throws -> [Employment] {
|
|
||||||
guard let uid = userId, uid > 0 else {
|
|
||||||
throw APIError.serverError("User not logged in")
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = try await postJSON("/workers/myBusinesses.cfm", payload: [
|
|
||||||
"UserID": uid
|
|
||||||
])
|
|
||||||
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to load businesses: \(err(json))")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return arr.map { Employment(json: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Beacons
|
|
||||||
|
|
||||||
func listBeacons() async throws -> [Beacon] {
|
|
||||||
let json = try await postJSON("/beacons/list.cfm", payload: [
|
|
||||||
"BusinessID": businessId
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to load beacons: \(err(json))")
|
|
||||||
}
|
|
||||||
guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] }
|
|
||||||
return arr.map { Beacon(json: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBeacon(beaconId: Int) async throws -> Beacon {
|
|
||||||
let json = try await postJSON("/beacons/get.cfm", payload: [
|
|
||||||
"BeaconID": beaconId,
|
|
||||||
"BusinessID": businessId
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to load beacon: \(err(json))")
|
|
||||||
}
|
|
||||||
var beaconJson: [String: Any]?
|
|
||||||
for key in ["BEACON", "Beacon", "beacon"] {
|
|
||||||
if let d = json[key] as? [String: Any] { beaconJson = d; break }
|
|
||||||
}
|
|
||||||
if beaconJson == nil {
|
|
||||||
for (_, value) in json {
|
|
||||||
if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let beaconJson = beaconJson else {
|
|
||||||
throw APIError.serverError("Invalid beacon response")
|
|
||||||
}
|
|
||||||
return Beacon(json: beaconJson)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createBeacon(name: String, uuid: String) async throws -> Int {
|
|
||||||
let json = try await postJSON("/beacons/create.cfm", payload: [
|
|
||||||
"BusinessID": businessId,
|
|
||||||
"Name": name,
|
|
||||||
"UUID": uuid
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to create beacon: \(err(json))")
|
|
||||||
}
|
|
||||||
return (json["BeaconID"] as? Int)
|
|
||||||
?? (json["ID"] as? Int)
|
|
||||||
?? Int(json["BeaconID"] as? String ?? "")
|
|
||||||
?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws {
|
|
||||||
let json = try await postJSON("/beacons/update.cfm", payload: [
|
|
||||||
"BeaconID": beaconId,
|
|
||||||
"BusinessID": businessId,
|
|
||||||
"Name": name,
|
|
||||||
"UUID": uuid,
|
|
||||||
"IsActive": isActive
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to update beacon: \(err(json))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteBeacon(beaconId: Int) async throws {
|
|
||||||
let json = try await postJSON("/beacons/delete.cfm", payload: [
|
|
||||||
"BeaconID": beaconId,
|
|
||||||
"BusinessID": businessId
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to delete beacon: \(err(json))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Service Points
|
|
||||||
|
|
||||||
func listServicePoints() async throws -> [ServicePoint] {
|
|
||||||
let json = try await postJSON("/servicePoints/list.cfm", payload: [
|
|
||||||
"BusinessID": businessId
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to load service points: \(err(json))")
|
|
||||||
}
|
|
||||||
guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] }
|
|
||||||
return arr.map { ServicePoint(json: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws {
|
|
||||||
var payload: [String: Any] = [
|
|
||||||
"ServicePointID": servicePointId,
|
|
||||||
"BusinessID": businessId
|
|
||||||
]
|
|
||||||
if let bid = beaconId {
|
|
||||||
payload["BeaconID"] = bid
|
|
||||||
}
|
|
||||||
let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload)
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to assign beacon: \(err(json))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func listServicePointTypes() async throws -> [ServicePointType] {
|
|
||||||
let json = try await postJSON("/servicePoints/types.cfm", payload: [
|
|
||||||
"BusinessID": businessId
|
|
||||||
])
|
|
||||||
guard ok(json) else {
|
|
||||||
throw APIError.serverError("Failed to load service point types: \(err(json))")
|
|
||||||
}
|
|
||||||
guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] }
|
|
||||||
return arr.map { ServicePointType(json: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - URL Helpers
|
|
||||||
|
|
||||||
func resolvePhotoUrl(_ rawUrl: String) -> String {
|
|
||||||
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty { return "" }
|
|
||||||
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
|
|
||||||
let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
|
||||||
if trimmed.hasPrefix("/") { return baseDomain + trimmed }
|
|
||||||
return baseDomain + "/" + trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Date Parsing
|
|
||||||
|
|
||||||
private static let iso8601Formatter: ISO8601DateFormatter = {
|
|
||||||
let f = ISO8601DateFormatter()
|
|
||||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let iso8601NoFrac: ISO8601DateFormatter = {
|
|
||||||
let f = ISO8601DateFormatter()
|
|
||||||
f.formatOptions = [.withInternetDateTime]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let simpleDateFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let cfmlDateFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let cfmlShortFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let cfmlAltFormatters: [DateFormatter] = {
|
|
||||||
let formats = [
|
|
||||||
"MMM dd, yyyy HH:mm:ss",
|
|
||||||
"MM/dd/yyyy HH:mm:ss",
|
|
||||||
"yyyy-MM-dd HH:mm:ss.S",
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ss.S",
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
|
||||||
"yyyy-MM-dd'T'HH:mm:ss.SZ",
|
|
||||||
]
|
|
||||||
return formats.map { fmt in
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = fmt
|
|
||||||
f.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
nonisolated static func parseDate(_ string: String) -> Date? {
|
|
||||||
let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if s.isEmpty { return nil }
|
|
||||||
|
|
||||||
if let epoch = Double(s) {
|
|
||||||
if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
|
|
||||||
if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if let d = iso8601Formatter.date(from: s) { return d }
|
|
||||||
if let d = iso8601NoFrac.date(from: s) { return d }
|
|
||||||
if let d = simpleDateFormatter.date(from: s) { return d }
|
|
||||||
if let d = cfmlDateFormatter.date(from: s) { return d }
|
|
||||||
if let d = cfmlShortFormatter.date(from: s) { return d }
|
|
||||||
for formatter in cfmlAltFormatters {
|
|
||||||
if let d = formatter.date(from: s) { return d }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct AuthCredentials {
|
|
||||||
let userId: Int
|
|
||||||
let token: String
|
|
||||||
let userName: String?
|
|
||||||
let photoUrl: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
actor AuthStorage {
|
|
||||||
static let shared = AuthStorage()
|
|
||||||
|
|
||||||
private let userIdKey = "payfrit_beacon_user_id"
|
|
||||||
private let userNameKey = "payfrit_beacon_user_name"
|
|
||||||
private let userPhotoKey = "payfrit_beacon_user_photo"
|
|
||||||
private let serviceName = "com.payfrit.beacon"
|
|
||||||
private let tokenAccount = "auth_token"
|
|
||||||
|
|
||||||
// MARK: - Save
|
|
||||||
|
|
||||||
func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
|
|
||||||
UserDefaults.standard.set(userId, forKey: userIdKey)
|
|
||||||
// Always overwrite name/photo to prevent stale data from previous user
|
|
||||||
if let name = userName, !name.isEmpty {
|
|
||||||
UserDefaults.standard.set(name, forKey: userNameKey)
|
|
||||||
} else {
|
|
||||||
UserDefaults.standard.removeObject(forKey: userNameKey)
|
|
||||||
}
|
|
||||||
if let photo = photoUrl, !photo.isEmpty {
|
|
||||||
UserDefaults.standard.set(photo, forKey: userPhotoKey)
|
|
||||||
} else {
|
|
||||||
UserDefaults.standard.removeObject(forKey: userPhotoKey)
|
|
||||||
}
|
|
||||||
saveToKeychain(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Load
|
|
||||||
|
|
||||||
func loadAuth() -> AuthCredentials? {
|
|
||||||
let userId = UserDefaults.standard.integer(forKey: userIdKey)
|
|
||||||
guard userId > 0 else { return nil }
|
|
||||||
guard let token = loadFromKeychain(), !token.isEmpty else { return nil }
|
|
||||||
let userName = UserDefaults.standard.string(forKey: userNameKey)
|
|
||||||
let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey)
|
|
||||||
return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear
|
|
||||||
|
|
||||||
func clearAuth() {
|
|
||||||
UserDefaults.standard.removeObject(forKey: userIdKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: userNameKey)
|
|
||||||
UserDefaults.standard.removeObject(forKey: userPhotoKey)
|
|
||||||
deleteFromKeychain()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Keychain
|
|
||||||
|
|
||||||
private func saveToKeychain(_ token: String) {
|
|
||||||
deleteFromKeychain()
|
|
||||||
guard let data = token.data(using: .utf8) else { return }
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecAttrAccount as String: tokenAccount,
|
|
||||||
kSecValueData as String: data,
|
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
||||||
]
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromKeychain() -> String? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecAttrAccount as String: tokenAccount,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
|
||||||
]
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
|
||||||
return String(data: data, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteFromKeychain() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: serviceName,
|
|
||||||
kSecAttrAccount as String: tokenAccount
|
|
||||||
]
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
import UIKit
|
|
||||||
import CoreBluetooth
|
|
||||||
import CoreLocation
|
|
||||||
|
|
||||||
/// Beacon scanner for detecting BLE beacons by UUID.
|
|
||||||
/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement.
|
|
||||||
/// All mutable state is confined to the main thread via @MainActor.
|
|
||||||
@MainActor
|
|
||||||
final class BeaconScanner: NSObject, ObservableObject {
|
|
||||||
private let targetUUID: String
|
|
||||||
private let normalizedTargetUUID: String
|
|
||||||
private let onBeaconDetected: (Double) -> Void
|
|
||||||
private let onRSSIUpdate: ((Int, Int) -> Void)?
|
|
||||||
private let onBluetoothOff: (() -> Void)?
|
|
||||||
private let onPermissionDenied: (() -> Void)?
|
|
||||||
private let onError: ((String) -> Void)?
|
|
||||||
|
|
||||||
@Published var isScanning = false
|
|
||||||
|
|
||||||
private var locationManager: CLLocationManager?
|
|
||||||
private var activeConstraint: CLBeaconIdentityConstraint?
|
|
||||||
private var checkTimer: Timer?
|
|
||||||
private var bluetoothManager: CBCentralManager?
|
|
||||||
|
|
||||||
// RSSI samples for dwell time enforcement
|
|
||||||
private var rssiSamples: [Int] = []
|
|
||||||
private let minSamplesToConfirm = 5 // ~5 seconds
|
|
||||||
private let rssiThreshold = -75
|
|
||||||
private var hasConfirmed = false
|
|
||||||
private var isPendingPermission = false
|
|
||||||
|
|
||||||
init(targetUUID: String,
|
|
||||||
onBeaconDetected: @escaping (Double) -> Void,
|
|
||||||
onRSSIUpdate: ((Int, Int) -> Void)? = nil,
|
|
||||||
onBluetoothOff: (() -> Void)? = nil,
|
|
||||||
onPermissionDenied: (() -> Void)? = nil,
|
|
||||||
onError: ((String) -> Void)? = nil) {
|
|
||||||
self.targetUUID = targetUUID
|
|
||||||
self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
|
|
||||||
self.onBeaconDetected = onBeaconDetected
|
|
||||||
self.onRSSIUpdate = onRSSIUpdate
|
|
||||||
self.onBluetoothOff = onBluetoothOff
|
|
||||||
self.onPermissionDenied = onPermissionDenied
|
|
||||||
self.onError = onError
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UUID formatting
|
|
||||||
|
|
||||||
private nonisolated func formatUUID(_ uuid: String) -> String {
|
|
||||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
|
||||||
guard clean.count == 32 else { return uuid }
|
|
||||||
let i = clean.startIndex
|
|
||||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
|
||||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
|
||||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
|
||||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
|
||||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
|
||||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Start/Stop
|
|
||||||
|
|
||||||
func startScanning() {
|
|
||||||
guard !isScanning else { return }
|
|
||||||
|
|
||||||
let formatted = formatUUID(targetUUID)
|
|
||||||
guard let uuid = UUID(uuidString: formatted) else {
|
|
||||||
onError?("Invalid beacon UUID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let lm = CLLocationManager()
|
|
||||||
lm.delegate = self
|
|
||||||
locationManager = lm
|
|
||||||
|
|
||||||
let status = lm.authorizationStatus
|
|
||||||
if status == .notDetermined {
|
|
||||||
isPendingPermission = true
|
|
||||||
lm.requestWhenInUseAuthorization()
|
|
||||||
// Delegate will call locationManagerDidChangeAuthorization
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
|
||||||
onPermissionDenied?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
beginRanging(uuid: uuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginRanging(uuid: UUID) {
|
|
||||||
guard let lm = locationManager else { return }
|
|
||||||
|
|
||||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
|
||||||
activeConstraint = constraint
|
|
||||||
lm.startRangingBeacons(satisfying: constraint)
|
|
||||||
|
|
||||||
isScanning = true
|
|
||||||
rssiSamples.removeAll()
|
|
||||||
hasConfirmed = false
|
|
||||||
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
|
||||||
|
|
||||||
// Monitor Bluetooth power state with a real CBCentralManager
|
|
||||||
bluetoothManager = CBCentralManager(delegate: self, queue: .main)
|
|
||||||
|
|
||||||
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.checkBluetoothState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkBluetoothState() {
|
|
||||||
if let bm = bluetoothManager, bm.state == .poweredOff {
|
|
||||||
stopScanning()
|
|
||||||
onBluetoothOff?()
|
|
||||||
}
|
|
||||||
if CBCentralManager.authorization == .denied ||
|
|
||||||
CBCentralManager.authorization == .restricted {
|
|
||||||
stopScanning()
|
|
||||||
onBluetoothOff?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopScanning() {
|
|
||||||
isPendingPermission = false
|
|
||||||
guard isScanning else { return }
|
|
||||||
isScanning = false
|
|
||||||
if let constraint = activeConstraint {
|
|
||||||
locationManager?.stopRangingBeacons(satisfying: constraint)
|
|
||||||
}
|
|
||||||
activeConstraint = nil
|
|
||||||
checkTimer?.invalidate()
|
|
||||||
checkTimer = nil
|
|
||||||
bluetoothManager = nil
|
|
||||||
rssiSamples.removeAll()
|
|
||||||
hasConfirmed = false
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetSamples() {
|
|
||||||
rssiSamples.removeAll()
|
|
||||||
hasConfirmed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func dispose() {
|
|
||||||
stopScanning()
|
|
||||||
locationManager?.delegate = nil
|
|
||||||
locationManager = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Safety net: clean up resources
|
|
||||||
checkTimer?.invalidate()
|
|
||||||
locationManager?.delegate = nil
|
|
||||||
Task { @MainActor in
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate handling (called on main thread, forwarded from nonisolated delegate)
|
|
||||||
|
|
||||||
fileprivate func handleRangedBeacons(_ beacons: [CLBeacon]) {
|
|
||||||
guard isScanning, !hasConfirmed else { return }
|
|
||||||
|
|
||||||
var foundThisCycle = false
|
|
||||||
|
|
||||||
for beacon in beacons {
|
|
||||||
let rssi = beacon.rssi
|
|
||||||
guard rssi != 0 else { continue }
|
|
||||||
|
|
||||||
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
|
||||||
guard detectedUUID == normalizedTargetUUID else { continue }
|
|
||||||
|
|
||||||
foundThisCycle = true
|
|
||||||
|
|
||||||
if rssi >= rssiThreshold {
|
|
||||||
rssiSamples.append(rssi)
|
|
||||||
onRSSIUpdate?(rssi, rssiSamples.count)
|
|
||||||
|
|
||||||
if rssiSamples.count >= minSamplesToConfirm {
|
|
||||||
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
|
||||||
hasConfirmed = true
|
|
||||||
onBeaconDetected(avg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !rssiSamples.isEmpty {
|
|
||||||
rssiSamples.removeAll()
|
|
||||||
onRSSIUpdate?(rssi, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundThisCycle && !rssiSamples.isEmpty {
|
|
||||||
rssiSamples.removeAll()
|
|
||||||
onRSSIUpdate?(0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func handleRangingError(_ error: Error) {
|
|
||||||
onError?("Beacon ranging failed: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) {
|
|
||||||
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
|
||||||
// Permission granted — start ranging only if we were waiting for permission
|
|
||||||
if isPendingPermission && !isScanning {
|
|
||||||
isPendingPermission = false
|
|
||||||
let formatted = formatUUID(targetUUID)
|
|
||||||
if let uuid = UUID(uuidString: formatted) {
|
|
||||||
beginRanging(uuid: uuid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if status == .denied || status == .restricted {
|
|
||||||
isPendingPermission = false
|
|
||||||
stopScanning()
|
|
||||||
onPermissionDenied?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
|
||||||
// These delegate callbacks arrive on the main thread since CLLocationManager was created on main.
|
|
||||||
// We forward to @MainActor methods above.
|
|
||||||
|
|
||||||
extension BeaconScanner: CLLocationManagerDelegate {
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
|
||||||
satisfying constraint: CLBeaconIdentityConstraint) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.handleRangedBeacons(beacons)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.handleRangingError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.handleAuthorizationChange(manager.authorizationStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CBCentralManagerDelegate
|
|
||||||
|
|
||||||
extension BeaconScanner: CBCentralManagerDelegate {
|
|
||||||
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
||||||
Task { @MainActor in
|
|
||||||
if central.state == .poweredOff {
|
|
||||||
self.stopScanning()
|
|
||||||
self.onBluetoothOff?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class AppState: ObservableObject {
|
|
||||||
@Published var userId: Int?
|
|
||||||
@Published var userName: String?
|
|
||||||
@Published var userPhotoUrl: String?
|
|
||||||
@Published var userToken: String?
|
|
||||||
@Published var businessId: Int = 0
|
|
||||||
@Published var businessName: String = ""
|
|
||||||
@Published var isAuthenticated = false
|
|
||||||
|
|
||||||
func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
|
|
||||||
self.userId = userId
|
|
||||||
self.userToken = token
|
|
||||||
self.userName = userName
|
|
||||||
self.userPhotoUrl = photoUrl
|
|
||||||
self.isAuthenticated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBusiness(id: Int, name: String) {
|
|
||||||
self.businessId = id
|
|
||||||
self.businessName = name
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearAuth() {
|
|
||||||
userId = nil
|
|
||||||
userToken = nil
|
|
||||||
userName = nil
|
|
||||||
userPhotoUrl = nil
|
|
||||||
isAuthenticated = false
|
|
||||||
businessId = 0
|
|
||||||
businessName = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle 401 unauthorized — clear everything and force re-login
|
|
||||||
func handleUnauthorized() async {
|
|
||||||
await AuthStorage.shared.clearAuth()
|
|
||||||
await APIService.shared.logout()
|
|
||||||
clearAuth()
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadSavedAuth() async {
|
|
||||||
let creds = await AuthStorage.shared.loadAuth()
|
|
||||||
guard let creds = creds else { return }
|
|
||||||
await APIService.shared.setAuth(token: creds.token, userId: creds.userId)
|
|
||||||
setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BeaconDashboard: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
let business: Employment
|
|
||||||
@State private var isReady = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isReady {
|
|
||||||
TabView {
|
|
||||||
BeaconListScreen()
|
|
||||||
.tabItem {
|
|
||||||
Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill")
|
|
||||||
}
|
|
||||||
|
|
||||||
ServicePointListScreen()
|
|
||||||
.tabItem {
|
|
||||||
Label("Service Points", systemImage: "mappin.and.ellipse")
|
|
||||||
}
|
|
||||||
|
|
||||||
ScannerScreen()
|
|
||||||
.tabItem {
|
|
||||||
Label("Scanner", systemImage: "antenna.radiowaves.left.and.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
} else {
|
|
||||||
ProgressView("Loading...")
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await APIService.shared.setBusinessId(business.businessId)
|
|
||||||
appState.setBusiness(id: business.businessId, name: business.businessName)
|
|
||||||
isReady = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BeaconDetailScreen: View {
|
|
||||||
let beacon: Beacon
|
|
||||||
var onSaved: () -> Void
|
|
||||||
|
|
||||||
@State private var name: String = ""
|
|
||||||
@State private var uuid: String = ""
|
|
||||||
@State private var isActive: Bool = true
|
|
||||||
@State private var isSaving = false
|
|
||||||
@State private var isDeleting = false
|
|
||||||
@State private var error: String?
|
|
||||||
@State private var showDeleteConfirm = false
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section("Beacon Info") {
|
|
||||||
TextField("Name", text: $name)
|
|
||||||
TextField("UUID (32 hex characters)", text: $uuid)
|
|
||||||
.textInputAutocapitalization(.characters)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
Toggle("Active", isOn: $isActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Details") {
|
|
||||||
LabeledContent("ID", value: "\(beacon.id)")
|
|
||||||
LabeledContent("Business ID", value: "\(beacon.businessId)")
|
|
||||||
if let date = beacon.createdAt {
|
|
||||||
LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened))
|
|
||||||
}
|
|
||||||
if let date = beacon.updatedAt {
|
|
||||||
LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button("Save Changes") { save() }
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) {
|
|
||||||
showDeleteConfirm = true
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.disabled(isSaving || isDeleting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(beacon.name)
|
|
||||||
.onAppear {
|
|
||||||
name = beacon.name
|
|
||||||
uuid = beacon.uuid
|
|
||||||
isActive = beacon.isActive
|
|
||||||
}
|
|
||||||
.alert("Delete Beacon?", isPresented: $showDeleteConfirm) {
|
|
||||||
Button("Delete", role: .destructive) { deleteBeacon() }
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func save() {
|
|
||||||
isSaving = true
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await APIService.shared.updateBeacon(
|
|
||||||
beaconId: beacon.id,
|
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
|
||||||
uuid: uuid.trimmingCharacters(in: .whitespaces),
|
|
||||||
isActive: isActive
|
|
||||||
)
|
|
||||||
onSaved()
|
|
||||||
dismiss()
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteBeacon() {
|
|
||||||
isDeleting = true
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await APIService.shared.deleteBeacon(beaconId: beacon.id)
|
|
||||||
onSaved()
|
|
||||||
dismiss()
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isDeleting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BeaconEditSheet: View {
|
|
||||||
var onSaved: () -> Void
|
|
||||||
|
|
||||||
@State private var name = ""
|
|
||||||
@State private var uuid = ""
|
|
||||||
@State private var isSaving = false
|
|
||||||
@State private var error: String?
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Form {
|
|
||||||
Section("New Beacon") {
|
|
||||||
TextField("Name (e.g. Table 1 Beacon)", text: $name)
|
|
||||||
TextField("UUID (32 hex characters)", text: $uuid)
|
|
||||||
.textInputAutocapitalization(.characters)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Add Beacon")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Save") { save() }
|
|
||||||
.disabled(isSaving || name.isEmpty || uuid.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func save() {
|
|
||||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
|
||||||
let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces)
|
|
||||||
|
|
||||||
guard !trimmedName.isEmpty else {
|
|
||||||
error = "Name is required"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !trimmedUUID.isEmpty else {
|
|
||||||
error = "UUID is required"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = true
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
_ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID)
|
|
||||||
onSaved()
|
|
||||||
dismiss()
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BeaconListScreen: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var beacons: [Beacon] = []
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var error: String?
|
|
||||||
@State private var showAddSheet = false
|
|
||||||
@State private var isDeleting = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView("Loading beacons...")
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if let error = error {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") { loadBeacons() }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
} else if beacons.isEmpty {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("No beacons yet")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("Tap + to add your first beacon")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
List {
|
|
||||||
ForEach(beacons) { beacon in
|
|
||||||
NavigationLink(value: beacon) {
|
|
||||||
BeaconRow(beacon: beacon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteBeacons)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Beacons")
|
|
||||||
.navigationDestination(for: Beacon.self) { beacon in
|
|
||||||
BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() })
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button {
|
|
||||||
showAddSheet = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showAddSheet) {
|
|
||||||
BeaconEditSheet(onSaved: { loadBeacons() })
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
loadBeacons { continuation.resume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task { loadBeacons() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBeacons(completion: (() -> Void)? = nil) {
|
|
||||||
isLoading = beacons.isEmpty
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
beacons = try await APIService.shared.listBeacons()
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteBeacons(at offsets: IndexSet) {
|
|
||||||
guard !isDeleting else { return }
|
|
||||||
let toDelete = offsets.map { beacons[$0] }
|
|
||||||
// Optimistic removal
|
|
||||||
beacons.remove(atOffsets: offsets)
|
|
||||||
isDeleting = true
|
|
||||||
|
|
||||||
Task {
|
|
||||||
var failedBeacons: [Beacon] = []
|
|
||||||
for beacon in toDelete {
|
|
||||||
do {
|
|
||||||
try await APIService.shared.deleteBeacon(beaconId: beacon.id)
|
|
||||||
} catch {
|
|
||||||
failedBeacons.append(beacon)
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Restore any that failed to delete
|
|
||||||
if !failedBeacons.isEmpty {
|
|
||||||
beacons.append(contentsOf: failedBeacons)
|
|
||||||
}
|
|
||||||
isDeleting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Beacon Row
|
|
||||||
|
|
||||||
struct BeaconRow: View {
|
|
||||||
let beacon: Beacon
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack {
|
|
||||||
Text(beacon.name)
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
if beacon.isActive {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
.font(.caption)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(beacon.formattedUUID)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make Beacon Hashable for NavigationLink
|
|
||||||
extension Beacon: Hashable {
|
|
||||||
static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id }
|
|
||||||
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BusinessSelectionScreen: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var businesses: [Employment] = []
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var error: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView("Loading businesses...")
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if let error = error {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") { loadBusinesses() }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
} else if businesses.isEmpty {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "building.2")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("No businesses found")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(spacing: 12) {
|
|
||||||
ForEach(businesses) { biz in
|
|
||||||
NavigationLink(value: biz) {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
BusinessHeaderImage(businessId: biz.businessId)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(biz.businessName)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
if !biz.businessCity.isEmpty {
|
|
||||||
Text(biz.businessCity)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
.background(Color(.systemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
||||||
.stroke(Color(.systemGray4), lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
.shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Select Business")
|
|
||||||
.navigationDestination(for: Employment.self) { biz in
|
|
||||||
BeaconDashboard(business: biz)
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button {
|
|
||||||
logout()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task { loadBusinesses() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBusinesses() {
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
businesses = try await APIService.shared.getMyBusinesses()
|
|
||||||
isLoading = false
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func logout() {
|
|
||||||
Task {
|
|
||||||
await AuthStorage.shared.clearAuth()
|
|
||||||
await APIService.shared.logout()
|
|
||||||
appState.clearAuth()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make Employment Hashable for NavigationLink
|
|
||||||
extension Employment: Hashable {
|
|
||||||
static func == (lhs: Employment, rhs: Employment) -> Bool {
|
|
||||||
lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId
|
|
||||||
}
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(employeeId)
|
|
||||||
hasher.combine(businessId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Business Header Image
|
|
||||||
|
|
||||||
struct BusinessHeaderImage: View {
|
|
||||||
let businessId: Int
|
|
||||||
|
|
||||||
@State private var loadedImage: UIImage?
|
|
||||||
@State private var isLoading = true
|
|
||||||
|
|
||||||
private var imageURLs: [URL] {
|
|
||||||
[
|
|
||||||
"https://dev.payfrit.com/uploads/headers/\(businessId).png",
|
|
||||||
"https://dev.payfrit.com/uploads/headers/\(businessId).jpg",
|
|
||||||
].compactMap { URL(string: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Color(.systemGray6)
|
|
||||||
|
|
||||||
if let image = loadedImage {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fit)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
} else if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 100)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "building.2")
|
|
||||||
.font(.system(size: 30))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await loadImage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage() async {
|
|
||||||
for url in imageURLs {
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(from: url)
|
|
||||||
if let httpResponse = response as? HTTPURLResponse,
|
|
||||||
httpResponse.statusCode == 200,
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
await MainActor.run {
|
|
||||||
loadedImage = image
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LoginScreen: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var username = ""
|
|
||||||
@State private var password = ""
|
|
||||||
@State private var showPassword = false
|
|
||||||
@State private var isLoading = false
|
|
||||||
@State private var error: String?
|
|
||||||
@State private var isDev = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.payfritGreen)
|
|
||||||
.padding(.top, 40)
|
|
||||||
|
|
||||||
Text("Payfrit Beacon")
|
|
||||||
.font(.system(size: 28, weight: .bold))
|
|
||||||
|
|
||||||
Text("Sign in to manage beacons")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
if isDev {
|
|
||||||
Text("DEV MODE — password: 123456")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
TextField("Email or Phone", text: $username)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.textContentType(.emailAddress)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
|
|
||||||
ZStack(alignment: .trailing) {
|
|
||||||
Group {
|
|
||||||
if showPassword {
|
|
||||||
TextField("Password", text: $password)
|
|
||||||
.textContentType(.password)
|
|
||||||
} else {
|
|
||||||
SecureField("Password", text: $password)
|
|
||||||
.textContentType(.password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.onSubmit { login() }
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showPassword.toggle()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
.padding(.trailing, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.callout)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(Color.red.opacity(0.1))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: login) {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
|
||||||
} else {
|
|
||||||
Text("Sign In")
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
.disabled(isLoading)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.frame(minHeight: geo.size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
.task { isDev = await APIService.shared.isDev }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func login() {
|
|
||||||
let user = username.trimmingCharacters(in: .whitespaces)
|
|
||||||
let pass = password
|
|
||||||
guard !user.isEmpty, !pass.isEmpty else {
|
|
||||||
error = "Please enter username and password"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
error = nil
|
|
||||||
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let response = try await APIService.shared.login(username: user, password: pass)
|
|
||||||
let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl)
|
|
||||||
await AuthStorage.shared.saveAuth(
|
|
||||||
userId: response.userId,
|
|
||||||
token: response.token,
|
|
||||||
userName: response.userFirstName,
|
|
||||||
photoUrl: resolvedPhoto
|
|
||||||
)
|
|
||||||
appState.setAuth(
|
|
||||||
userId: response.userId,
|
|
||||||
token: response.token,
|
|
||||||
userName: response.userFirstName,
|
|
||||||
photoUrl: resolvedPhoto
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
import LocalAuthentication
|
|
||||||
|
|
||||||
struct RootView: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var isCheckingAuth = true
|
|
||||||
@State private var isDev = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isCheckingAuth {
|
|
||||||
loadingView
|
|
||||||
} else if appState.isAuthenticated {
|
|
||||||
BusinessSelectionScreen()
|
|
||||||
} else {
|
|
||||||
LoginScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
if isDev {
|
|
||||||
Text("DEV")
|
|
||||||
.font(.caption.bold())
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(width: 80, height: 20)
|
|
||||||
.background(Color.orange)
|
|
||||||
.rotationEffect(.degrees(45))
|
|
||||||
.offset(x: -20, y: -6)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
isDev = await APIService.shared.isDev
|
|
||||||
await checkAuthWithBiometrics()
|
|
||||||
isCheckingAuth = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var loadingView: some View {
|
|
||||||
ZStack {
|
|
||||||
Color.white.ignoresSafeArea()
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.payfritGreen)
|
|
||||||
Text("Payfrit Beacon")
|
|
||||||
.font(.title2.bold())
|
|
||||||
ProgressView()
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkAuthWithBiometrics() async {
|
|
||||||
let creds = await AuthStorage.shared.loadAuth()
|
|
||||||
guard creds != nil else { return }
|
|
||||||
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
await appState.loadSavedAuth()
|
|
||||||
return
|
|
||||||
#else
|
|
||||||
let context = LAContext()
|
|
||||||
context.localizedCancelTitle = "Use Password"
|
|
||||||
var error: NSError?
|
|
||||||
let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
|
||||||
|
|
||||||
guard canUseBiometrics else {
|
|
||||||
// No biometrics available — allow login with saved credentials
|
|
||||||
await appState.loadSavedAuth()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let success = try await context.evaluatePolicy(
|
|
||||||
.deviceOwnerAuthenticationWithBiometrics,
|
|
||||||
localizedReason: "Sign in to Payfrit Beacon"
|
|
||||||
)
|
|
||||||
if success {
|
|
||||||
await appState.loadSavedAuth()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// User cancelled biometrics — still allow them in with saved credentials
|
|
||||||
NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)")
|
|
||||||
await appState.loadSavedAuth()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
import CoreLocation
|
|
||||||
|
|
||||||
struct ScannerScreen: View {
|
|
||||||
@State private var beacons: [Beacon] = []
|
|
||||||
@State private var selectedBeacon: Beacon?
|
|
||||||
@State private var isLoading = true
|
|
||||||
|
|
||||||
// Scanner state
|
|
||||||
@StateObject private var scanner = ScannerViewModel()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Beacon selector
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Picker("Select Beacon", selection: $selectedBeacon) {
|
|
||||||
Text("Choose a beacon...").tag(nil as Beacon?)
|
|
||||||
ForEach(beacons) { beacon in
|
|
||||||
Text(beacon.name).tag(beacon as Beacon?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Scanner display
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Status indicator
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(scanner.statusColor.opacity(0.15))
|
|
||||||
.frame(width: 160, height: 160)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(scanner.statusColor.opacity(0.3))
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
|
|
||||||
Image(systemName: scanner.statusIcon)
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundColor(scanner.statusColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(scanner.statusText)
|
|
||||||
.font(.title3.bold())
|
|
||||||
|
|
||||||
if scanner.isScanning {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
if scanner.rssi != 0 {
|
|
||||||
HStack {
|
|
||||||
Text("RSSI:")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("\(scanner.rssi) dBm")
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
HStack {
|
|
||||||
Text("Samples:")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("\(scanner.sampleCount)/\(scanner.requiredSamples)")
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
}
|
|
||||||
// Signal strength bar
|
|
||||||
SignalStrengthBar(rssi: scanner.rssi)
|
|
||||||
.frame(height: 20)
|
|
||||||
.padding(.horizontal, 40)
|
|
||||||
} else {
|
|
||||||
Text("Searching for beacon signal...")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Start/Stop button
|
|
||||||
Button {
|
|
||||||
if scanner.isScanning {
|
|
||||||
scanner.stop()
|
|
||||||
} else if let beacon = selectedBeacon {
|
|
||||||
scanner.start(uuid: beacon.uuid)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill")
|
|
||||||
Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning")
|
|
||||||
}
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 50)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(scanner.isScanning ? .red : .payfritGreen)
|
|
||||||
.disabled(selectedBeacon == nil && !scanner.isScanning)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Beacon Scanner")
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
do {
|
|
||||||
beacons = try await APIService.shared.listBeacons()
|
|
||||||
} catch {
|
|
||||||
// Silently fail — user can still see the scanner
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
.onChange(of: selectedBeacon) { _ in
|
|
||||||
if scanner.isScanning {
|
|
||||||
scanner.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Scanner ViewModel
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class ScannerViewModel: ObservableObject {
|
|
||||||
@Published var isScanning = false
|
|
||||||
@Published var statusText = "Select a beacon to scan"
|
|
||||||
@Published var statusColor: Color = .secondary
|
|
||||||
@Published var statusIcon = "sensor.tag.radiowaves.forward.fill"
|
|
||||||
@Published var rssi: Int = 0
|
|
||||||
@Published var sampleCount = 0
|
|
||||||
let requiredSamples = 5
|
|
||||||
|
|
||||||
private var beaconScanner: BeaconScanner?
|
|
||||||
|
|
||||||
func start(uuid: String) {
|
|
||||||
beaconScanner?.dispose()
|
|
||||||
|
|
||||||
beaconScanner = BeaconScanner(
|
|
||||||
targetUUID: uuid,
|
|
||||||
onBeaconDetected: { [weak self] avgRssi in
|
|
||||||
self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)"
|
|
||||||
self?.statusColor = .green
|
|
||||||
self?.statusIcon = "checkmark.circle.fill"
|
|
||||||
},
|
|
||||||
onRSSIUpdate: { [weak self] currentRssi, samples in
|
|
||||||
self?.rssi = currentRssi
|
|
||||||
self?.sampleCount = samples
|
|
||||||
},
|
|
||||||
onBluetoothOff: { [weak self] in
|
|
||||||
self?.statusText = "Bluetooth is OFF"
|
|
||||||
self?.statusColor = .orange
|
|
||||||
self?.statusIcon = "bluetooth.slash"
|
|
||||||
},
|
|
||||||
onPermissionDenied: { [weak self] in
|
|
||||||
self?.statusText = "Location Permission Denied"
|
|
||||||
self?.statusColor = .red
|
|
||||||
self?.statusIcon = "location.slash.fill"
|
|
||||||
self?.isScanning = false
|
|
||||||
},
|
|
||||||
onError: { [weak self] message in
|
|
||||||
self?.statusText = message
|
|
||||||
self?.statusColor = .red
|
|
||||||
self?.statusIcon = "exclamationmark.triangle.fill"
|
|
||||||
self?.isScanning = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
beaconScanner?.startScanning()
|
|
||||||
isScanning = true
|
|
||||||
statusText = "Scanning..."
|
|
||||||
statusColor = .blue
|
|
||||||
statusIcon = "antenna.radiowaves.left.and.right"
|
|
||||||
rssi = 0
|
|
||||||
sampleCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
beaconScanner?.dispose()
|
|
||||||
beaconScanner = nil
|
|
||||||
isScanning = false
|
|
||||||
statusText = "Select a beacon to scan"
|
|
||||||
statusColor = .secondary
|
|
||||||
statusIcon = "sensor.tag.radiowaves.forward.fill"
|
|
||||||
rssi = 0
|
|
||||||
sampleCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
// Ensure cleanup if view is removed while scanning
|
|
||||||
// Note: deinit runs on main actor since class is @MainActor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Signal Strength Bar
|
|
||||||
|
|
||||||
struct SignalStrengthBar: View {
|
|
||||||
let rssi: Int
|
|
||||||
|
|
||||||
private var strength: Double {
|
|
||||||
// Map RSSI from -100..-30 to 0..1
|
|
||||||
let clamped = max(-100, min(-30, rssi))
|
|
||||||
return Double(clamped + 100) / 70.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var barColor: Color {
|
|
||||||
if strength > 0.7 { return .green }
|
|
||||||
if strength > 0.4 { return .yellow }
|
|
||||||
return .red
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Color.secondary.opacity(0.2))
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(barColor)
|
|
||||||
.frame(width: geo.size.width * strength)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ServicePointListScreen: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var servicePoints: [ServicePoint] = []
|
|
||||||
@State private var beacons: [Beacon] = []
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var error: String?
|
|
||||||
@State private var assigningPointId: Int?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView("Loading service points...")
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if let error = error {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
Button("Retry") { loadData() }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.payfritGreen)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
} else if servicePoints.isEmpty {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "mappin.and.ellipse")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("No service points")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
List(servicePoints) { sp in
|
|
||||||
ServicePointRow(
|
|
||||||
servicePoint: sp,
|
|
||||||
beacons: beacons,
|
|
||||||
isAssigning: assigningPointId == sp.id,
|
|
||||||
onAssignBeacon: { beaconId in
|
|
||||||
assignBeacon(servicePointId: sp.id, beaconId: beaconId)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Service Points")
|
|
||||||
.refreshable {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
loadData { continuation.resume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task { loadData() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadData(completion: (() -> Void)? = nil) {
|
|
||||||
isLoading = servicePoints.isEmpty
|
|
||||||
error = nil
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
async let sp = APIService.shared.listServicePoints()
|
|
||||||
async let b = APIService.shared.listBeacons()
|
|
||||||
servicePoints = try await sp
|
|
||||||
beacons = try await b
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func assignBeacon(servicePointId: Int, beaconId: Int?) {
|
|
||||||
assigningPointId = servicePointId
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await APIService.shared.assignBeaconToServicePoint(
|
|
||||||
servicePointId: servicePointId,
|
|
||||||
beaconId: beaconId
|
|
||||||
)
|
|
||||||
loadData()
|
|
||||||
} catch let apiError as APIError where apiError == .unauthorized {
|
|
||||||
await appState.handleUnauthorized()
|
|
||||||
} catch {
|
|
||||||
self.error = error.localizedDescription
|
|
||||||
}
|
|
||||||
assigningPointId = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Service Point Row
|
|
||||||
|
|
||||||
struct ServicePointRow: View {
|
|
||||||
let servicePoint: ServicePoint
|
|
||||||
let beacons: [Beacon]
|
|
||||||
let isAssigning: Bool
|
|
||||||
var onAssignBeacon: (Int?) -> Void
|
|
||||||
|
|
||||||
@State private var showBeaconPicker = false
|
|
||||||
|
|
||||||
private var assignedBeacon: Beacon? {
|
|
||||||
guard let bid = servicePoint.beaconId else { return nil }
|
|
||||||
return beacons.first { $0.id == bid }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(servicePoint.name)
|
|
||||||
.font(.headline)
|
|
||||||
if !servicePoint.typeName.isEmpty {
|
|
||||||
Text(servicePoint.typeName)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if servicePoint.isActive {
|
|
||||||
Circle()
|
|
||||||
.fill(.green)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.fill(.red)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beacon assignment
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
if isAssigning {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
} else if let beacon = assignedBeacon {
|
|
||||||
Text(beacon.name)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.payfritGreen)
|
|
||||||
} else {
|
|
||||||
Text("No beacon assigned")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showBeaconPicker = true
|
|
||||||
} label: {
|
|
||||||
Text(assignedBeacon != nil ? "Change" : "Assign")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
|
|
||||||
if assignedBeacon != nil {
|
|
||||||
Button {
|
|
||||||
onAssignBeacon(nil)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.sheet(isPresented: $showBeaconPicker) {
|
|
||||||
BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in
|
|
||||||
onAssignBeacon(selectedId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Beacon Picker Sheet
|
|
||||||
|
|
||||||
struct BeaconPickerSheet: View {
|
|
||||||
let beacons: [Beacon]
|
|
||||||
let currentBeaconId: Int?
|
|
||||||
var onSelect: (Int) -> Void
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
List(beacons) { beacon in
|
|
||||||
Button {
|
|
||||||
onSelect(beacon.id)
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(beacon.name)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(beacon.formattedUUID)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if beacon.id == currentBeaconId {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(.payfritGreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Select Beacon")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
PayfritBeacon/en.lproj/InfoPlist.strings
Normal file
2
PayfritBeacon/en.lproj/InfoPlist.strings
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CFBundleDisplayName = "Payfrit Beacon";
|
||||||
|
CFBundleName = "Payfrit Beacon";
|
||||||
28
PayfritBeacon/payfrit-favicon-light-outlines.svg
Normal file
28
PayfritBeacon/payfrit-favicon-light-outlines.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="960px" height="560px" viewBox="0 0 960 560" enable-background="new 0 0 960 560" xml:space="preserve">
|
||||||
|
<rect x="9.109" y="12.221" fill="#22B24B" width="942.891" height="68.608"/>
|
||||||
|
<g enable-background="new ">
|
||||||
|
<path d="M9.256,291.354V123.396h54.42c20.623,0,34.064,0.842,40.328,2.521c9.624,2.521,17.682,8.002,24.174,16.44
|
||||||
|
c6.491,8.441,9.738,19.345,9.738,32.71c0,10.311-1.873,18.98-5.614,26.007c-3.743,7.028-8.498,12.545-14.264,16.556
|
||||||
|
c-5.768,4.01-11.628,6.664-17.586,7.962c-8.097,1.604-19.82,2.406-35.173,2.406H43.168v63.356H9.256z M43.168,151.809v47.661h18.56
|
||||||
|
c13.365,0,22.302-0.878,26.81-2.636c4.505-1.756,8.038-4.506,10.597-8.249c2.558-3.741,3.838-8.095,3.838-13.061
|
||||||
|
c0-6.109-1.795-11.15-5.385-15.123c-3.591-3.971-8.134-6.453-13.634-7.447c-4.049-0.763-12.184-1.146-24.403-1.146H43.168z"/>
|
||||||
|
<path d="M299.803,291.354h-36.891l-14.666-38.151h-67.137l-13.863,38.151h-35.975l65.419-167.958h35.86L299.803,291.354z
|
||||||
|
M237.363,224.904l-23.144-62.325l-22.685,62.325H237.363z"/>
|
||||||
|
<path d="M340.36,291.354v-70.688l-61.523-97.27h39.756l39.525,66.45l38.725-66.45h39.068l-61.753,97.498v70.459H340.36z"/>
|
||||||
|
<path d="M452.98,291.354V123.396h115.142v28.413h-81.229v39.756h70.116v28.413h-70.116v71.375H452.98z"/>
|
||||||
|
<path d="M596.192,291.354V123.396h71.376c17.948,0,30.991,1.51,39.125,4.525c8.135,3.019,14.646,8.384,19.534,16.098
|
||||||
|
c4.887,7.715,7.332,16.537,7.332,26.465c0,12.603-3.705,23.011-11.112,31.22c-7.41,8.212-18.485,13.387-33.226,15.524
|
||||||
|
c7.333,4.278,13.385,8.976,18.159,14.092c4.772,5.118,11.208,14.207,19.305,27.268l20.508,32.766h-40.557l-24.518-36.547
|
||||||
|
c-8.708-13.061-14.665-21.29-17.873-24.689c-3.208-3.397-6.607-5.729-10.196-6.988c-3.592-1.261-9.28-1.891-17.071-1.891h-6.874
|
||||||
|
v70.116H596.192z M630.104,194.429h25.091c16.269,0,26.426-0.688,30.475-2.063c4.048-1.375,7.218-3.741,9.51-7.104
|
||||||
|
c2.291-3.36,3.437-7.562,3.437-12.603c0-5.651-1.509-10.215-4.525-13.69c-3.018-3.475-7.274-5.672-12.774-6.588
|
||||||
|
c-2.749-0.382-10.998-0.573-24.746-0.573h-26.466V194.429z"/>
|
||||||
|
<path d="M764.494,291.354V123.396h33.912v167.958H764.494z"/>
|
||||||
|
<path d="M868.522,291.354V151.809h-49.838v-28.413h133.473v28.413h-49.723v139.544H868.522z"/>
|
||||||
|
</g>
|
||||||
|
<rect x="9.109" y="334.846" fill="#22B24B" width="942.891" height="68.609"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
15
Podfile
Normal file
15
Podfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
platform :ios, '16.0'
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
target 'PayfritBeacon' do
|
||||||
|
pod 'Kingfisher', '~> 7.0'
|
||||||
|
pod 'SVGKit', '~> 3.0'
|
||||||
|
end
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
26
Podfile.lock
Normal file
26
Podfile.lock
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
PODS:
|
||||||
|
- CocoaLumberjack (3.9.0):
|
||||||
|
- CocoaLumberjack/Core (= 3.9.0)
|
||||||
|
- CocoaLumberjack/Core (3.9.0)
|
||||||
|
- Kingfisher (7.12.0)
|
||||||
|
- SVGKit (3.0.0):
|
||||||
|
- CocoaLumberjack (~> 3.0)
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- Kingfisher (~> 7.0)
|
||||||
|
- SVGKit (~> 3.0)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- CocoaLumberjack
|
||||||
|
- Kingfisher
|
||||||
|
- SVGKit
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a
|
||||||
|
Kingfisher: 53a10ea35051a436b5fb626ca2dd8f3144d755e9
|
||||||
|
SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: 548c7a88f61dd6e8448dba1f369d199e3053e0b6
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
Loading…
Add table
Reference in a new issue