From 962a767863ccdcc52c0f317b9f79ba26390107b3 Mon Sep 17 00:00:00 2001 From: John Pinkyfloyd Date: Wed, 4 Feb 2026 22:07:39 -0800 Subject: [PATCH] Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PayfritBeacon.xcodeproj/project.pbxproj | 283 ++++--- PayfritBeacon/Api.swift | 365 ++++++++ .../AppIcon.appiconset/Contents.json | 1 + .../AppIcon.appiconset/appicon.png | Bin 0 -> 60811 bytes PayfritBeacon/BeaconBanList.swift | 79 ++ PayfritBeacon/BeaconScanner.swift | 128 +++ PayfritBeacon/BusinessListView.swift | 142 ++++ PayfritBeacon/DevBanner.swift | 29 + PayfritBeacon/Info.plist | 11 +- PayfritBeacon/LoginView.swift | 236 ++++++ PayfritBeacon/Models/Beacon.swift | 68 -- PayfritBeacon/Models/Employment.swift | 32 - PayfritBeacon/Models/ServicePoint.swift | 47 -- PayfritBeacon/PayfritBeaconApp.swift | 15 +- PayfritBeacon/QrScanView.swift | 192 +++++ PayfritBeacon/RootView.swift | 58 ++ PayfritBeacon/ScanView.swift | 779 ++++++++++++++++++ PayfritBeacon/Services/APIService.swift | 416 ---------- PayfritBeacon/Services/AuthStorage.swift | 95 --- PayfritBeacon/Services/BeaconScanner.swift | 262 ------ PayfritBeacon/ViewModels/AppState.swift | 49 -- PayfritBeacon/Views/BeaconDashboard.swift | 39 - PayfritBeacon/Views/BeaconDetailScreen.swift | 116 --- PayfritBeacon/Views/BeaconEditSheet.swift | 83 -- PayfritBeacon/Views/BeaconListScreen.swift | 154 ---- .../Views/BusinessSelectionScreen.swift | 196 ----- PayfritBeacon/Views/LoginScreen.swift | 136 --- PayfritBeacon/Views/RootView.swift | 88 -- PayfritBeacon/Views/ScannerScreen.swift | 225 ----- .../Views/ServicePointListScreen.swift | 232 ------ PayfritBeacon/en.lproj/InfoPlist.strings | 2 + .../payfrit-favicon-light-outlines.svg | 28 + Podfile | 15 + Podfile.lock | 26 + 34 files changed, 2236 insertions(+), 2391 deletions(-) create mode 100644 PayfritBeacon/Api.swift create mode 100644 PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png create mode 100644 PayfritBeacon/BeaconBanList.swift create mode 100644 PayfritBeacon/BeaconScanner.swift create mode 100644 PayfritBeacon/BusinessListView.swift create mode 100644 PayfritBeacon/DevBanner.swift create mode 100644 PayfritBeacon/LoginView.swift delete mode 100644 PayfritBeacon/Models/Beacon.swift delete mode 100644 PayfritBeacon/Models/Employment.swift delete mode 100644 PayfritBeacon/Models/ServicePoint.swift create mode 100644 PayfritBeacon/QrScanView.swift create mode 100644 PayfritBeacon/RootView.swift create mode 100644 PayfritBeacon/ScanView.swift delete mode 100644 PayfritBeacon/Services/APIService.swift delete mode 100644 PayfritBeacon/Services/AuthStorage.swift delete mode 100644 PayfritBeacon/Services/BeaconScanner.swift delete mode 100644 PayfritBeacon/ViewModels/AppState.swift delete mode 100644 PayfritBeacon/Views/BeaconDashboard.swift delete mode 100644 PayfritBeacon/Views/BeaconDetailScreen.swift delete mode 100644 PayfritBeacon/Views/BeaconEditSheet.swift delete mode 100644 PayfritBeacon/Views/BeaconListScreen.swift delete mode 100644 PayfritBeacon/Views/BusinessSelectionScreen.swift delete mode 100644 PayfritBeacon/Views/LoginScreen.swift delete mode 100644 PayfritBeacon/Views/RootView.swift delete mode 100644 PayfritBeacon/Views/ScannerScreen.swift delete mode 100644 PayfritBeacon/Views/ServicePointListScreen.swift create mode 100644 PayfritBeacon/en.lproj/InfoPlist.strings create mode 100644 PayfritBeacon/payfrit-favicon-light-outlines.svg create mode 100644 Podfile create mode 100644 Podfile.lock diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj index e018555..6cbbb10 100644 --- a/PayfritBeacon.xcodeproj/project.pbxproj +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -7,71 +7,41 @@ objects = { /* Begin PBXBuildFile section */ - /* App Entry */ - C01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000001; }; - - /* Models */ - C01000000010 /* Beacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000010; }; - C01000000011 /* ServicePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000011; }; - C01000000012 /* Employment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000012; }; - - /* ViewModels */ - C01000000020 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000020; }; - - /* Services */ - C01000000030 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000030; }; - C01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02000000031; }; - 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; }; + 7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */; }; + D01000000001 /* PayfritBeaconApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000001 /* PayfritBeaconApp.swift */; }; + D01000000002 /* Api.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000002 /* Api.swift */; }; + D01000000003 /* BeaconBanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000003 /* BeaconBanList.swift */; }; + D01000000004 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000004 /* BeaconScanner.swift */; }; + D01000000005 /* DevBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000005 /* DevBanner.swift */; }; + D01000000006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000006 /* LoginView.swift */; }; + D01000000007 /* BusinessListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000007 /* BusinessListView.swift */; }; + D01000000008 /* ScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000008 /* ScanView.swift */; }; + D01000000009 /* QrScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02000000009 /* QrScanView.swift */; }; + D0100000000A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0200000000A /* RootView.swift */; }; + D01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D02000000060 /* Assets.xcassets */; }; + D01000000070 /* payfrit-favicon-light-outlines.svg in Resources */ = {isa = PBXBuildFile; fileRef = D02000000070 /* payfrit-favicon-light-outlines.svg */; }; + D01000000080 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D02000000080 /* InfoPlist.strings */; }; /* End PBXBuildFile 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 = ""; }; + D02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = ""; }; + D02000000002 /* Api.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Api.swift; sourceTree = ""; }; + D02000000003 /* BeaconBanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconBanList.swift; sourceTree = ""; }; + D02000000004 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = ""; }; + D02000000005 /* DevBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevBanner.swift; sourceTree = ""; }; + D02000000006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + D02000000007 /* BusinessListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessListView.swift; sourceTree = ""; }; + D02000000008 /* ScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanView.swift; sourceTree = ""; }; + D02000000009 /* QrScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrScanView.swift; sourceTree = ""; }; + D0200000000A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + D02000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D02000000070 /* payfrit-favicon-light-outlines.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = "payfrit-favicon-light-outlines.svg"; sourceTree = ""; }; + D02000000081 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; C03000000001 /* PayfritBeacon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritBeacon.app; sourceTree = BUILT_PRODUCTS_DIR; }; - - /* App Entry */ - C02000000001 /* PayfritBeaconApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritBeaconApp.swift; sourceTree = ""; }; - C02000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - - /* Models */ - C02000000010 /* Beacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Beacon.swift; sourceTree = ""; }; - C02000000011 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = ""; }; - C02000000012 /* Employment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Employment.swift; sourceTree = ""; }; - - /* ViewModels */ - C02000000020 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - - /* Services */ - C02000000030 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; - C02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = ""; }; - C02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = ""; }; - - /* Views */ - C02000000040 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - C02000000041 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; - C02000000042 /* BusinessSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessSelectionScreen.swift; sourceTree = ""; }; - C02000000043 /* BeaconDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDashboard.swift; sourceTree = ""; }; - C02000000044 /* BeaconListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconListScreen.swift; sourceTree = ""; }; - C02000000045 /* BeaconDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDetailScreen.swift; sourceTree = ""; }; - C02000000046 /* BeaconEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconEditSheet.swift; sourceTree = ""; }; - C02000000047 /* ServicePointListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePointListScreen.swift; sourceTree = ""; }; - C02000000048 /* ScannerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerScreen.swift; sourceTree = ""; }; - - /* Resources */ - C02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,86 +49,54 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7D757E1341A0143A2E9EBDF4 /* Pods_PayfritBeacon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 04996117E2F5D5BB2D86CD46 /* Pods */ = { + isa = PBXGroup; + children = ( + AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */, + F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; C05000000001 = { isa = PBXGroup; children = ( - C05000000002 /* PayfritBeacon */, + D05000000002 /* PayfritBeacon */, C05000000009 /* Products */, + 04996117E2F5D5BB2D86CD46 /* Pods */, + EEC06FED6BE78CF9357F3158 /* Frameworks */, ); sourceTree = ""; }; - C05000000002 /* PayfritBeacon */ = { + D05000000002 /* PayfritBeacon */ = { isa = PBXGroup; children = ( - C02000000001 /* PayfritBeaconApp.swift */, - C02000000002 /* Info.plist */, - C05000000003 /* Models */, - C05000000004 /* ViewModels */, - C05000000005 /* Services */, - C05000000006 /* Views */, - C05000000007 /* Resources */, + D02000000001 /* PayfritBeaconApp.swift */, + D0200000000A /* RootView.swift */, + D02000000002 /* Api.swift */, + D02000000003 /* BeaconBanList.swift */, + D02000000004 /* BeaconScanner.swift */, + D02000000005 /* DevBanner.swift */, + 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; sourceTree = ""; }; - C05000000003 /* Models */ = { - isa = PBXGroup; - children = ( - C02000000010 /* Beacon.swift */, - C02000000011 /* ServicePoint.swift */, - C02000000012 /* Employment.swift */, - ); - path = Models; - sourceTree = ""; - }; - C05000000004 /* ViewModels */ = { - isa = PBXGroup; - children = ( - C02000000020 /* AppState.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; - C05000000005 /* Services */ = { - isa = PBXGroup; - children = ( - C02000000030 /* APIService.swift */, - C02000000031 /* AuthStorage.swift */, - C02000000032 /* BeaconScanner.swift */, - ); - path = Services; - sourceTree = ""; - }; - 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 = ""; - }; - C05000000007 /* Resources */ = { - isa = PBXGroup; - children = ( - C02000000060 /* Assets.xcassets */, - ); - name = Resources; - sourceTree = ""; - }; C05000000009 /* Products */ = { isa = PBXGroup; children = ( @@ -167,6 +105,14 @@ name = Products; sourceTree = ""; }; + EEC06FED6BE78CF9357F3158 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7F6819A0F2BD2E9D84E7EDB4 /* Pods_PayfritBeacon.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -174,9 +120,11 @@ isa = PBXNativeTarget; buildConfigurationList = C08000000003 /* Build configuration list for PBXNativeTarget "PayfritBeacon" */; buildPhases = ( + 744B96DEA84C89E13D29B8B7 /* [CP] Check Pods Manifest.lock */, C07000000001 /* Sources */, C04000000001 /* Frameworks */, C09000000001 /* Resources */, + 66702B40BAEAF5430876D7CE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -225,39 +173,87 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; 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; }; /* 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 */ C07000000001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C01000000001 /* PayfritBeaconApp.swift in Sources */, - C01000000010 /* Beacon.swift in Sources */, - C01000000011 /* ServicePoint.swift in Sources */, - C01000000012 /* Employment.swift in Sources */, - C01000000020 /* AppState.swift in Sources */, - C01000000030 /* APIService.swift in Sources */, - C01000000031 /* AuthStorage.swift in Sources */, - C01000000032 /* BeaconScanner.swift in Sources */, - C01000000040 /* RootView.swift in Sources */, - C01000000041 /* LoginScreen.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 */, + D01000000001 /* PayfritBeaconApp.swift in Sources */, + D01000000002 /* Api.swift in Sources */, + D01000000003 /* BeaconBanList.swift in Sources */, + D01000000004 /* BeaconScanner.swift in Sources */, + D01000000005 /* DevBanner.swift in Sources */, + D01000000006 /* LoginView.swift in Sources */, + D01000000007 /* BusinessListView.swift in Sources */, + D01000000008 /* ScanView.swift in Sources */, + D01000000009 /* QrScanView.swift in Sources */, + D0100000000A /* RootView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + D02000000080 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + D02000000081 /* en */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ C0B000000001 /* Debug */ = { isa = XCBuildConfiguration; @@ -296,7 +292,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -358,7 +354,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -378,6 +374,7 @@ }; C0B000000003 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AFF542870BA2862FAB429A86 /* Pods-PayfritBeacon.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -410,6 +407,7 @@ }; C0B000000004 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F1B84CA677516C50F6BE2294 /* Pods-PayfritBeacon.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -462,7 +460,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - }; rootObject = C0A000000001 /* Project object */; } diff --git a/PayfritBeacon/Api.swift b/PayfritBeacon/Api.swift new file mode 100644 index 0000000..5120aca --- /dev/null +++ b/PayfritBeacon/Api.swift @@ -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 +} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..3193f63 100644 --- a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "appicon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1592cdeaffd43ed0c83b6c9fe57412df333070a1 GIT binary patch literal 60811 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%0A5%~)Dm8CL1>ya*DTl=$U(=KBMUChR?2 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 P&99}dt5$T)?#cfGaa1=< literal 0 HcmV?d00001 diff --git a/PayfritBeacon/BeaconBanList.swift b/PayfritBeacon/BeaconBanList.swift new file mode 100644 index 0000000..e72a7f3 --- /dev/null +++ b/PayfritBeacon/BeaconBanList.swift @@ -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.. 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 +} diff --git a/PayfritBeacon/BusinessListView.swift b/PayfritBeacon/BusinessListView.swift new file mode 100644 index 0000000..75726a0 --- /dev/null +++ b/PayfritBeacon/BusinessListView.swift @@ -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() + } +} diff --git a/PayfritBeacon/DevBanner.swift b/PayfritBeacon/DevBanner.swift new file mode 100644 index 0000000..4ca5245 --- /dev/null +++ b/PayfritBeacon/DevBanner.swift @@ -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() + } + } +} diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist index 74f4ffe..fa9623d 100644 --- a/PayfritBeacon/Info.plist +++ b/PayfritBeacon/Info.plist @@ -11,7 +11,9 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Payfrit Beacon + CFBundleDisplayName + Payfrit Beacon CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString @@ -26,11 +28,8 @@ Payfrit Beacon uses Bluetooth to scan for and manage nearby beacons. NSFaceIDUsageDescription Payfrit Beacon uses Face ID for quick sign-in. - UIBackgroundModes - - bluetooth-central - location - + NSCameraUsageDescription + Payfrit Beacon uses the camera to scan QR codes on beacon stickers. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/PayfritBeacon/LoginView.swift b/PayfritBeacon/LoginView.swift new file mode 100644 index 0000000..1e41bf4 --- /dev/null +++ b/PayfritBeacon/LoginView.swift @@ -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) {} +} diff --git a/PayfritBeacon/Models/Beacon.swift b/PayfritBeacon/Models/Beacon.swift deleted file mode 100644 index 555d978..0000000 --- a/PayfritBeacon/Models/Beacon.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -struct Beacon: Identifiable { - let id: Int - let businessId: Int - let name: String - let uuid: String - let namespaceId: String - let instanceId: String - let isActive: Bool - let createdAt: Date? - let updatedAt: Date? - - init(json: [String: Any]) { - id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0 - businessId = Self.parseInt(json["BusinessID"]) ?? 0 - name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? "" - uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? "" - namespaceId = (json["NamespaceId"] as? String) ?? "" - instanceId = (json["InstanceId"] as? String) ?? "" - isActive = Self.parseBool(json["IsActive"]) ?? true - createdAt = Self.parseDate(json["CreatedAt"]) - updatedAt = Self.parseDate(json["UpdatedAt"]) - } - - /// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format - var formattedUUID: String { - let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() - guard clean.count == 32 else { return uuid } - let i = clean.startIndex - let p1 = clean[i.. Int? { - guard let value = value else { return nil } - if let v = value as? Int { return v } - if let v = value as? Double { return Int(v) } - if let v = value as? NSNumber { return v.intValue } - if let v = value as? String, let i = Int(v) { return i } - return nil - } - - static func parseBool(_ value: Any?) -> Bool? { - guard let value = value else { return nil } - if let b = value as? Bool { return b } - if let i = value as? Int { return i == 1 } - if let s = value as? String { - let lower = s.lowercased() - if lower == "true" || lower == "1" || lower == "yes" { return true } - if lower == "false" || lower == "0" || lower == "no" { return false } - } - return nil - } - - static func parseDate(_ value: Any?) -> Date? { - guard let value = value else { return nil } - if let d = value as? Date { return d } - if let s = value as? String { return APIService.parseDate(s) } - return nil - } -} diff --git a/PayfritBeacon/Models/Employment.swift b/PayfritBeacon/Models/Employment.swift deleted file mode 100644 index 00b0ded..0000000 --- a/PayfritBeacon/Models/Employment.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct Employment: Identifiable { - /// Composite ID to avoid collisions when same employee works at multiple businesses - var id: String { "\(employeeId)-\(businessId)" } - let employeeId: Int - let businessId: Int - let businessName: String - let businessAddress: String - let businessCity: String - let employeeStatusId: Int - let pendingTaskCount: Int - - var statusName: String { - switch employeeStatusId { - case 1: return "Active" - case 2: return "Suspended" - case 3: return "Terminated" - default: return "Unknown" - } - } - - init(json: [String: Any]) { - employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0 - businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 - businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? "" - businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? "" - businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? "" - employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0 - pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0 - } -} diff --git a/PayfritBeacon/Models/ServicePoint.swift b/PayfritBeacon/Models/ServicePoint.swift deleted file mode 100644 index 6229394..0000000 --- a/PayfritBeacon/Models/ServicePoint.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -struct ServicePoint: Identifiable { - let id: Int - let businessId: Int - let name: String - let typeId: Int - let typeName: String - let code: String - let description: String - let sortOrder: Int - let isActive: Bool - let isClosedToNewMembers: Bool - let beaconId: Int? - let assignedByUserId: Int? - let createdAt: Date? - let updatedAt: Date? - - init(json: [String: Any]) { - id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0 - businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 - name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? "" - typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0 - typeName = (json["TypeName"] as? String) ?? "" - code = (json["Code"] as? String) ?? "" - description = (json["Description"] as? String) ?? "" - sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 - isActive = Beacon.parseBool(json["IsActive"]) ?? true - isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false - beaconId = Beacon.parseInt(json["BeaconID"]) - assignedByUserId = Beacon.parseInt(json["AssignedByUserID"]) - createdAt = Beacon.parseDate(json["CreatedAt"]) - updatedAt = Beacon.parseDate(json["UpdatedAt"]) - } -} - -struct ServicePointType: Identifiable { - let id: Int - let name: String - let sortOrder: Int - - init(json: [String: Any]) { - id = Beacon.parseInt(json["ID"]) ?? 0 - name = (json["Name"] as? String) ?? "" - sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 - } -} diff --git a/PayfritBeacon/PayfritBeaconApp.swift b/PayfritBeacon/PayfritBeaconApp.swift index 4a67203..8080d9c 100644 --- a/PayfritBeacon/PayfritBeaconApp.swift +++ b/PayfritBeacon/PayfritBeaconApp.swift @@ -2,16 +2,23 @@ import SwiftUI @main struct PayfritBeaconApp: App { - @StateObject private var appState = AppState() - var body: some Scene { WindowGroup { RootView() - .environmentObject(appState) } } } extension Color { - static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294) + static let payfritGreen = Color(red: 34/255, green: 178/255, blue: 75/255) // #22B24B + static let signalStrong = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 + static let signalMedium = Color(red: 249/255, green: 168/255, blue: 37/255) // #F9A825 + static let signalWeak = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A + static let infoBlue = Color(red: 33/255, green: 150/255, blue: 243/255) // #2196F3 + static let warningOrange = Color(red: 255/255, green: 152/255, blue: 0/255) // #FF9800 + static let errorRed = Color(red: 186/255, green: 26/255, blue: 26/255) // #BA1A1A + static let successGreen = Color(red: 76/255, green: 175/255, blue: 80/255) // #4CAF50 + static let newBg = Color(red: 76/255, green: 175/255, blue: 80/255).opacity(0.12) + static let assignedBg = Color(red: 33/255, green: 150/255, blue: 243/255).opacity(0.12) + static let bannedBg = Color(red: 186/255, green: 26/255, blue: 26/255).opacity(0.12) } diff --git a/PayfritBeacon/QrScanView.swift b/PayfritBeacon/QrScanView.swift new file mode 100644 index 0000000..21f5f40 --- /dev/null +++ b/PayfritBeacon/QrScanView.swift @@ -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.. 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() + } +} diff --git a/PayfritBeacon/RootView.swift b/PayfritBeacon/RootView.swift new file mode 100644 index 0000000..d49624d --- /dev/null +++ b/PayfritBeacon/RootView.swift @@ -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 + } + } +} diff --git a/PayfritBeacon/ScanView.swift b/PayfritBeacon/ScanView.swift new file mode 100644 index 0000000..a3544fa --- /dev/null +++ b/PayfritBeacon/ScanView.swift @@ -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 = [] + @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 + } +} diff --git a/PayfritBeacon/Services/APIService.swift b/PayfritBeacon/Services/APIService.swift deleted file mode 100644 index b6dfbbb..0000000 --- a/PayfritBeacon/Services/APIService.swift +++ /dev/null @@ -1,416 +0,0 @@ -import Foundation - -// MARK: - API Errors - -enum APIError: LocalizedError, Equatable { - case invalidURL - case noData - case decodingError(String) - case serverError(String) - case unauthorized - case networkError(String) - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL" - case .noData: return "No data received" - case .decodingError(let msg): return "Decoding error: \(msg)" - case .serverError(let msg): return msg - case .unauthorized: return "Unauthorized" - case .networkError(let msg): return msg - } - } -} - -// MARK: - Login Response - -struct LoginResponse { - let userId: Int - let userFirstName: String - let token: String - let photoUrl: String -} - -// MARK: - API Service - -actor APIService { - static let shared = APIService() - - private enum Environment { - case development, production - - var baseURL: String { - switch self { - case .development: return "https://dev.payfrit.com/api" - case .production: return "https://biz.payfrit.com/api" - } - } - } - - private let environment: Environment = .development - var isDev: Bool { environment == .development } - private var userToken: String? - private var userId: Int? - private var businessId: Int = 0 - - var baseURL: String { environment.baseURL } - - // MARK: - Configuration - - func setAuth(token: String?, userId: Int?) { - self.userToken = token - self.userId = userId - } - - func setBusinessId(_ id: Int) { - self.businessId = id - } - - func getToken() -> String? { userToken } - func getUserId() -> Int? { userId } - func getBusinessId() -> Int { businessId } - - // MARK: - Core Request - - private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] { - let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)") - guard let url = URL(string: urlString) else { throw APIError.invalidURL } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - - if let token = userToken, !token.isEmpty { - request.setValue(token, forHTTPHeaderField: "X-User-Token") - } - if businessId > 0 { - request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") - } - - request.httpBody = try JSONSerialization.data(withJSONObject: payload) - - let (data, response) = try await URLSession.shared.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 401 { throw APIError.unauthorized } - guard (200...299).contains(httpResponse.statusCode) else { - throw APIError.serverError("HTTP \(httpResponse.statusCode)") - } - } - - if let json = tryDecodeJSON(data) { - return json - } - throw APIError.decodingError("Non-JSON response") - } - - private func tryDecodeJSON(_ data: Data) -> [String: Any]? { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return json - } - guard let body = String(data: data, encoding: .utf8), - let start = body.firstIndex(of: "{"), - let end = body.lastIndex(of: "}") else { return nil } - let jsonStr = String(body[start...end]) - guard let jsonData = jsonStr.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] - } - - private func ok(_ json: [String: Any]) -> Bool { - for key in ["OK", "ok", "Ok"] { - if let b = json[key] as? Bool { return b } - if let i = json[key] as? Int { return i == 1 } - if let s = json[key] as? String { - let lower = s.lowercased() - return lower == "true" || lower == "1" || lower == "yes" - } - } - return false - } - - private func err(_ json: [String: Any]) -> String { - let msg = (json["ERROR"] as? String) ?? (json["error"] as? String) - ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? "" - return msg.isEmpty ? "Unknown error" : msg - } - - nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? { - for key in keys { - if let arr = json[key] as? [[String: Any]] { return arr } - } - for (_, value) in json { - if let arr = value as? [[String: Any]], !arr.isEmpty { return arr } - } - return nil - } - - // MARK: - Auth - - func login(username: String, password: String) async throws -> LoginResponse { - let json = try await postJSON("/auth/login.cfm", payload: [ - "username": username, - "password": password - ]) - - guard ok(json) else { - let e = err(json) - if e == "bad_credentials" { - throw APIError.serverError("Invalid email/phone or password") - } - throw APIError.serverError("Login failed: \(e)") - } - - let uid = (json["UserID"] as? Int) - ?? Int(json["UserID"] as? String ?? "") - ?? (json["UserId"] as? Int) - ?? 0 - let token = (json["Token"] as? String) - ?? (json["token"] as? String) - ?? "" - - guard uid > 0 else { - throw APIError.serverError("Login failed: no user ID returned") - } - guard !token.isEmpty else { - throw APIError.serverError("Login failed: no token returned") - } - - let firstName = (json["UserFirstName"] as? String) - ?? (json["FirstName"] as? String) - ?? (json["firstName"] as? String) - ?? (json["Name"] as? String) - ?? (json["name"] as? String) - ?? "" - let photoUrl = (json["UserPhotoUrl"] as? String) - ?? (json["PhotoUrl"] as? String) - ?? (json["photoUrl"] as? String) - ?? "" - - self.userToken = token - self.userId = uid - - return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl) - } - - func logout() { - userToken = nil - userId = nil - businessId = 0 - } - - // MARK: - Businesses - - func getMyBusinesses() async throws -> [Employment] { - guard let uid = userId, uid > 0 else { - throw APIError.serverError("User not logged in") - } - - let json = try await postJSON("/workers/myBusinesses.cfm", payload: [ - "UserID": uid - ]) - - guard ok(json) else { - throw APIError.serverError("Failed to load businesses: \(err(json))") - } - - guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else { - return [] - } - return arr.map { Employment(json: $0) } - } - - // MARK: - Beacons - - func listBeacons() async throws -> [Beacon] { - let json = try await postJSON("/beacons/list.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load beacons: \(err(json))") - } - guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] } - return arr.map { Beacon(json: $0) } - } - - func getBeacon(beaconId: Int) async throws -> Beacon { - let json = try await postJSON("/beacons/get.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load beacon: \(err(json))") - } - var beaconJson: [String: Any]? - for key in ["BEACON", "Beacon", "beacon"] { - if let d = json[key] as? [String: Any] { beaconJson = d; break } - } - if beaconJson == nil { - for (_, value) in json { - if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break } - } - } - guard let beaconJson = beaconJson else { - throw APIError.serverError("Invalid beacon response") - } - return Beacon(json: beaconJson) - } - - func createBeacon(name: String, uuid: String) async throws -> Int { - let json = try await postJSON("/beacons/create.cfm", payload: [ - "BusinessID": businessId, - "Name": name, - "UUID": uuid - ]) - guard ok(json) else { - throw APIError.serverError("Failed to create beacon: \(err(json))") - } - return (json["BeaconID"] as? Int) - ?? (json["ID"] as? Int) - ?? Int(json["BeaconID"] as? String ?? "") - ?? 0 - } - - func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws { - let json = try await postJSON("/beacons/update.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId, - "Name": name, - "UUID": uuid, - "IsActive": isActive - ]) - guard ok(json) else { - throw APIError.serverError("Failed to update beacon: \(err(json))") - } - } - - func deleteBeacon(beaconId: Int) async throws { - let json = try await postJSON("/beacons/delete.cfm", payload: [ - "BeaconID": beaconId, - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to delete beacon: \(err(json))") - } - } - - // MARK: - Service Points - - func listServicePoints() async throws -> [ServicePoint] { - let json = try await postJSON("/servicePoints/list.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load service points: \(err(json))") - } - guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] } - return arr.map { ServicePoint(json: $0) } - } - - func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws { - var payload: [String: Any] = [ - "ServicePointID": servicePointId, - "BusinessID": businessId - ] - if let bid = beaconId { - payload["BeaconID"] = bid - } - let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload) - guard ok(json) else { - throw APIError.serverError("Failed to assign beacon: \(err(json))") - } - } - - func listServicePointTypes() async throws -> [ServicePointType] { - let json = try await postJSON("/servicePoints/types.cfm", payload: [ - "BusinessID": businessId - ]) - guard ok(json) else { - throw APIError.serverError("Failed to load service point types: \(err(json))") - } - guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] } - return arr.map { ServicePointType(json: $0) } - } - - // MARK: - URL Helpers - - func resolvePhotoUrl(_ rawUrl: String) -> String { - let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return "" } - if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed } - let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com" - if trimmed.hasPrefix("/") { return baseDomain + trimmed } - return baseDomain + "/" + trimmed - } - - // MARK: - Date Parsing - - private static let iso8601Formatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() - - private static let iso8601NoFrac: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() - - private static let simpleDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm:ss" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlShortFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - private static let cfmlAltFormatters: [DateFormatter] = { - let formats = [ - "MMM dd, yyyy HH:mm:ss", - "MM/dd/yyyy HH:mm:ss", - "yyyy-MM-dd HH:mm:ss.S", - "yyyy-MM-dd'T'HH:mm:ss.S", - "yyyy-MM-dd'T'HH:mm:ssZ", - "yyyy-MM-dd'T'HH:mm:ss.SZ", - ] - return formats.map { fmt in - let f = DateFormatter() - f.dateFormat = fmt - f.locale = Locale(identifier: "en_US_POSIX") - return f - } - }() - - nonisolated static func parseDate(_ string: String) -> Date? { - let s = string.trimmingCharacters(in: .whitespacesAndNewlines) - if s.isEmpty { return nil } - - if let epoch = Double(s) { - if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) } - if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) } - } - - if let d = iso8601Formatter.date(from: s) { return d } - if let d = iso8601NoFrac.date(from: s) { return d } - if let d = simpleDateFormatter.date(from: s) { return d } - if let d = cfmlDateFormatter.date(from: s) { return d } - if let d = cfmlShortFormatter.date(from: s) { return d } - for formatter in cfmlAltFormatters { - if let d = formatter.date(from: s) { return d } - } - return nil - } -} diff --git a/PayfritBeacon/Services/AuthStorage.swift b/PayfritBeacon/Services/AuthStorage.swift deleted file mode 100644 index 04a165a..0000000 --- a/PayfritBeacon/Services/AuthStorage.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Security - -struct AuthCredentials { - let userId: Int - let token: String - let userName: String? - let photoUrl: String? -} - -actor AuthStorage { - static let shared = AuthStorage() - - private let userIdKey = "payfrit_beacon_user_id" - private let userNameKey = "payfrit_beacon_user_name" - private let userPhotoKey = "payfrit_beacon_user_photo" - private let serviceName = "com.payfrit.beacon" - private let tokenAccount = "auth_token" - - // MARK: - Save - - func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { - UserDefaults.standard.set(userId, forKey: userIdKey) - // Always overwrite name/photo to prevent stale data from previous user - if let name = userName, !name.isEmpty { - UserDefaults.standard.set(name, forKey: userNameKey) - } else { - UserDefaults.standard.removeObject(forKey: userNameKey) - } - if let photo = photoUrl, !photo.isEmpty { - UserDefaults.standard.set(photo, forKey: userPhotoKey) - } else { - UserDefaults.standard.removeObject(forKey: userPhotoKey) - } - saveToKeychain(token) - } - - // MARK: - Load - - func loadAuth() -> AuthCredentials? { - let userId = UserDefaults.standard.integer(forKey: userIdKey) - guard userId > 0 else { return nil } - guard let token = loadFromKeychain(), !token.isEmpty else { return nil } - let userName = UserDefaults.standard.string(forKey: userNameKey) - let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey) - return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl) - } - - // MARK: - Clear - - func clearAuth() { - UserDefaults.standard.removeObject(forKey: userIdKey) - UserDefaults.standard.removeObject(forKey: userNameKey) - UserDefaults.standard.removeObject(forKey: userPhotoKey) - deleteFromKeychain() - } - - // MARK: - Keychain - - private func saveToKeychain(_ token: String) { - deleteFromKeychain() - guard let data = token.data(using: .utf8) else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount, - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ] - SecItemAdd(query as CFDictionary, nil) - } - - private func loadFromKeychain() -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private func deleteFromKeychain() { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: tokenAccount - ] - SecItemDelete(query as CFDictionary) - } -} diff --git a/PayfritBeacon/Services/BeaconScanner.swift b/PayfritBeacon/Services/BeaconScanner.swift deleted file mode 100644 index 4363653..0000000 --- a/PayfritBeacon/Services/BeaconScanner.swift +++ /dev/null @@ -1,262 +0,0 @@ -import UIKit -import CoreBluetooth -import CoreLocation - -/// Beacon scanner for detecting BLE beacons by UUID. -/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement. -/// All mutable state is confined to the main thread via @MainActor. -@MainActor -final class BeaconScanner: NSObject, ObservableObject { - private let targetUUID: String - private let normalizedTargetUUID: String - private let onBeaconDetected: (Double) -> Void - private let onRSSIUpdate: ((Int, Int) -> Void)? - private let onBluetoothOff: (() -> Void)? - private let onPermissionDenied: (() -> Void)? - private let onError: ((String) -> Void)? - - @Published var isScanning = false - - private var locationManager: CLLocationManager? - private var activeConstraint: CLBeaconIdentityConstraint? - private var checkTimer: Timer? - private var bluetoothManager: CBCentralManager? - - // RSSI samples for dwell time enforcement - private var rssiSamples: [Int] = [] - private let minSamplesToConfirm = 5 // ~5 seconds - private let rssiThreshold = -75 - private var hasConfirmed = false - private var isPendingPermission = false - - init(targetUUID: String, - onBeaconDetected: @escaping (Double) -> Void, - onRSSIUpdate: ((Int, Int) -> Void)? = nil, - onBluetoothOff: (() -> Void)? = nil, - onPermissionDenied: (() -> Void)? = nil, - onError: ((String) -> Void)? = nil) { - self.targetUUID = targetUUID - self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased() - self.onBeaconDetected = onBeaconDetected - self.onRSSIUpdate = onRSSIUpdate - self.onBluetoothOff = onBluetoothOff - self.onPermissionDenied = onPermissionDenied - self.onError = onError - super.init() - } - - // MARK: - UUID formatting - - private nonisolated func formatUUID(_ uuid: String) -> String { - let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() - guard clean.count == 32 else { return uuid } - let i = clean.startIndex - let p1 = clean[i..= rssiThreshold { - rssiSamples.append(rssi) - onRSSIUpdate?(rssi, rssiSamples.count) - - if rssiSamples.count >= minSamplesToConfirm { - let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) - hasConfirmed = true - onBeaconDetected(avg) - return - } - } else { - if !rssiSamples.isEmpty { - rssiSamples.removeAll() - onRSSIUpdate?(rssi, 0) - } - } - } - - if !foundThisCycle && !rssiSamples.isEmpty { - rssiSamples.removeAll() - onRSSIUpdate?(0, 0) - } - } - - fileprivate func handleRangingError(_ error: Error) { - onError?("Beacon ranging failed: \(error.localizedDescription)") - } - - fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) { - if status == .authorizedWhenInUse || status == .authorizedAlways { - // Permission granted — start ranging only if we were waiting for permission - if isPendingPermission && !isScanning { - isPendingPermission = false - let formatted = formatUUID(targetUUID) - if let uuid = UUID(uuidString: formatted) { - beginRanging(uuid: uuid) - } - } - } else if status == .denied || status == .restricted { - isPendingPermission = false - stopScanning() - onPermissionDenied?() - } - } -} - -// MARK: - CLLocationManagerDelegate -// These delegate callbacks arrive on the main thread since CLLocationManager was created on main. -// We forward to @MainActor methods above. - -extension BeaconScanner: CLLocationManagerDelegate { - nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], - satisfying constraint: CLBeaconIdentityConstraint) { - Task { @MainActor in - self.handleRangedBeacons(beacons) - } - } - - nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { - Task { @MainActor in - self.handleRangingError(error) - } - } - - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - Task { @MainActor in - self.handleAuthorizationChange(manager.authorizationStatus) - } - } -} - -// MARK: - CBCentralManagerDelegate - -extension BeaconScanner: CBCentralManagerDelegate { - nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) { - Task { @MainActor in - if central.state == .poweredOff { - self.stopScanning() - self.onBluetoothOff?() - } - } - } -} diff --git a/PayfritBeacon/ViewModels/AppState.swift b/PayfritBeacon/ViewModels/AppState.swift deleted file mode 100644 index 4fbec6c..0000000 --- a/PayfritBeacon/ViewModels/AppState.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -@MainActor -final class AppState: ObservableObject { - @Published var userId: Int? - @Published var userName: String? - @Published var userPhotoUrl: String? - @Published var userToken: String? - @Published var businessId: Int = 0 - @Published var businessName: String = "" - @Published var isAuthenticated = false - - func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { - self.userId = userId - self.userToken = token - self.userName = userName - self.userPhotoUrl = photoUrl - self.isAuthenticated = true - } - - func setBusiness(id: Int, name: String) { - self.businessId = id - self.businessName = name - } - - func clearAuth() { - userId = nil - userToken = nil - userName = nil - userPhotoUrl = nil - isAuthenticated = false - businessId = 0 - businessName = "" - } - - /// Handle 401 unauthorized — clear everything and force re-login - func handleUnauthorized() async { - await AuthStorage.shared.clearAuth() - await APIService.shared.logout() - clearAuth() - } - - func loadSavedAuth() async { - let creds = await AuthStorage.shared.loadAuth() - guard let creds = creds else { return } - await APIService.shared.setAuth(token: creds.token, userId: creds.userId) - setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl) - } -} diff --git a/PayfritBeacon/Views/BeaconDashboard.swift b/PayfritBeacon/Views/BeaconDashboard.swift deleted file mode 100644 index e8e148e..0000000 --- a/PayfritBeacon/Views/BeaconDashboard.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -struct BeaconDashboard: View { - @EnvironmentObject var appState: AppState - let business: Employment - @State private var isReady = false - - var body: some View { - Group { - if isReady { - TabView { - BeaconListScreen() - .tabItem { - Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill") - } - - ServicePointListScreen() - .tabItem { - Label("Service Points", systemImage: "mappin.and.ellipse") - } - - ScannerScreen() - .tabItem { - Label("Scanner", systemImage: "antenna.radiowaves.left.and.right") - } - } - .tint(.payfritGreen) - } else { - ProgressView("Loading...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .task { - await APIService.shared.setBusinessId(business.businessId) - appState.setBusiness(id: business.businessId, name: business.businessName) - isReady = true - } - } -} diff --git a/PayfritBeacon/Views/BeaconDetailScreen.swift b/PayfritBeacon/Views/BeaconDetailScreen.swift deleted file mode 100644 index 1b4aac9..0000000 --- a/PayfritBeacon/Views/BeaconDetailScreen.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI - -struct BeaconDetailScreen: View { - let beacon: Beacon - var onSaved: () -> Void - - @State private var name: String = "" - @State private var uuid: String = "" - @State private var isActive: Bool = true - @State private var isSaving = false - @State private var isDeleting = false - @State private var error: String? - @State private var showDeleteConfirm = false - @EnvironmentObject var appState: AppState - @Environment(\.dismiss) private var dismiss - - var body: some View { - Form { - Section("Beacon Info") { - TextField("Name", text: $name) - TextField("UUID (32 hex characters)", text: $uuid) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - Toggle("Active", isOn: $isActive) - } - - Section("Details") { - LabeledContent("ID", value: "\(beacon.id)") - LabeledContent("Business ID", value: "\(beacon.businessId)") - if let date = beacon.createdAt { - LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened)) - } - if let date = beacon.updatedAt { - LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened)) - } - } - - if let error = error { - Section { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - } - } - } - - Section { - Button("Save Changes") { save() } - .frame(maxWidth: .infinity) - .disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty) - } - - Section { - Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) { - showDeleteConfirm = true - } - .frame(maxWidth: .infinity) - .disabled(isSaving || isDeleting) - } - } - .navigationTitle(beacon.name) - .onAppear { - name = beacon.name - uuid = beacon.uuid - isActive = beacon.isActive - } - .alert("Delete Beacon?", isPresented: $showDeleteConfirm) { - Button("Delete", role: .destructive) { deleteBeacon() } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.") - } - } - - private func save() { - isSaving = true - error = nil - Task { - do { - try await APIService.shared.updateBeacon( - beaconId: beacon.id, - name: name.trimmingCharacters(in: .whitespaces), - uuid: uuid.trimmingCharacters(in: .whitespaces), - isActive: isActive - ) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isSaving = false - } - } - - private func deleteBeacon() { - isDeleting = true - error = nil - Task { - do { - try await APIService.shared.deleteBeacon(beaconId: beacon.id) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isDeleting = false - } - } -} diff --git a/PayfritBeacon/Views/BeaconEditSheet.swift b/PayfritBeacon/Views/BeaconEditSheet.swift deleted file mode 100644 index d3ffc9b..0000000 --- a/PayfritBeacon/Views/BeaconEditSheet.swift +++ /dev/null @@ -1,83 +0,0 @@ -import SwiftUI - -struct BeaconEditSheet: View { - var onSaved: () -> Void - - @State private var name = "" - @State private var uuid = "" - @State private var isSaving = false - @State private var error: String? - @EnvironmentObject var appState: AppState - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - Form { - Section("New Beacon") { - TextField("Name (e.g. Table 1 Beacon)", text: $name) - TextField("UUID (32 hex characters)", text: $uuid) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .font(.system(.body, design: .monospaced)) - } - - Section { - Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001") - .font(.caption) - .foregroundColor(.secondary) - } - - if let error = error { - Section { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - } - } - } - } - .navigationTitle("Add Beacon") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { save() } - .disabled(isSaving || name.isEmpty || uuid.isEmpty) - } - } - } - } - - private func save() { - let trimmedName = name.trimmingCharacters(in: .whitespaces) - let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces) - - guard !trimmedName.isEmpty else { - error = "Name is required" - return - } - guard !trimmedUUID.isEmpty else { - error = "UUID is required" - return - } - - isSaving = true - error = nil - Task { - do { - _ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID) - onSaved() - dismiss() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isSaving = false - } - } -} diff --git a/PayfritBeacon/Views/BeaconListScreen.swift b/PayfritBeacon/Views/BeaconListScreen.swift deleted file mode 100644 index 4d9b1c7..0000000 --- a/PayfritBeacon/Views/BeaconListScreen.swift +++ /dev/null @@ -1,154 +0,0 @@ -import SwiftUI - -struct BeaconListScreen: View { - @EnvironmentObject var appState: AppState - @State private var beacons: [Beacon] = [] - @State private var isLoading = true - @State private var error: String? - @State private var showAddSheet = false - @State private var isDeleting = false - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading beacons...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadBeacons() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if beacons.isEmpty { - VStack(spacing: 16) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No beacons yet") - .font(.title3) - .foregroundColor(.secondary) - Text("Tap + to add your first beacon") - .font(.subheadline) - .foregroundColor(.secondary) - } - } else { - List { - ForEach(beacons) { beacon in - NavigationLink(value: beacon) { - BeaconRow(beacon: beacon) - } - } - .onDelete(perform: deleteBeacons) - } - } - } - .navigationTitle("Beacons") - .navigationDestination(for: Beacon.self) { beacon in - BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() }) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - showAddSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showAddSheet) { - BeaconEditSheet(onSaved: { loadBeacons() }) - } - .refreshable { - await withCheckedContinuation { continuation in - loadBeacons { continuation.resume() } - } - } - } - .task { loadBeacons() } - } - - private func loadBeacons(completion: (() -> Void)? = nil) { - isLoading = beacons.isEmpty - error = nil - Task { - do { - beacons = try await APIService.shared.listBeacons() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isLoading = false - completion?() - } - } - - private func deleteBeacons(at offsets: IndexSet) { - guard !isDeleting else { return } - let toDelete = offsets.map { beacons[$0] } - // Optimistic removal - beacons.remove(atOffsets: offsets) - isDeleting = true - - Task { - var failedBeacons: [Beacon] = [] - for beacon in toDelete { - do { - try await APIService.shared.deleteBeacon(beaconId: beacon.id) - } catch { - failedBeacons.append(beacon) - self.error = error.localizedDescription - } - } - // Restore any that failed to delete - if !failedBeacons.isEmpty { - beacons.append(contentsOf: failedBeacons) - } - isDeleting = false - } - } -} - -// MARK: - Beacon Row - -struct BeaconRow: View { - let beacon: Beacon - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(beacon.name) - .font(.headline) - Spacer() - if beacon.isActive { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.caption) - } else { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .font(.caption) - } - } - Text(beacon.formattedUUID) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - .padding(.vertical, 2) - } -} - -// Make Beacon Hashable for NavigationLink -extension Beacon: Hashable { - static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id } - func hash(into hasher: inout Hasher) { hasher.combine(id) } -} diff --git a/PayfritBeacon/Views/BusinessSelectionScreen.swift b/PayfritBeacon/Views/BusinessSelectionScreen.swift deleted file mode 100644 index c410417..0000000 --- a/PayfritBeacon/Views/BusinessSelectionScreen.swift +++ /dev/null @@ -1,196 +0,0 @@ -import SwiftUI - -struct BusinessSelectionScreen: View { - @EnvironmentObject var appState: AppState - @State private var businesses: [Employment] = [] - @State private var isLoading = true - @State private var error: String? - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading businesses...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadBusinesses() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if businesses.isEmpty { - VStack(spacing: 16) { - Image(systemName: "building.2") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("No businesses found") - .foregroundColor(.secondary) - } - } else { - ScrollView { - LazyVStack(spacing: 12) { - ForEach(businesses) { biz in - NavigationLink(value: biz) { - VStack(spacing: 0) { - BusinessHeaderImage(businessId: biz.businessId) - - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(biz.businessName) - .font(.subheadline.weight(.semibold)) - .foregroundColor(.primary) - if !biz.businessCity.isEmpty { - Text(biz.businessCity) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color(.systemGray4), lineWidth: 0.5) - ) - .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 20) - } - } - } - .navigationTitle("Select Business") - .navigationDestination(for: Employment.self) { biz in - BeaconDashboard(business: biz) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - logout() - } label: { - Image(systemName: "rectangle.portrait.and.arrow.right") - } - } - } - } - .task { loadBusinesses() } - } - - private func loadBusinesses() { - isLoading = true - error = nil - Task { - do { - businesses = try await APIService.shared.getMyBusinesses() - isLoading = false - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - isLoading = false - } - } - } - - private func logout() { - Task { - await AuthStorage.shared.clearAuth() - await APIService.shared.logout() - appState.clearAuth() - } - } -} - -// Make Employment Hashable for NavigationLink -extension Employment: Hashable { - static func == (lhs: Employment, rhs: Employment) -> Bool { - lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId - } - func hash(into hasher: inout Hasher) { - hasher.combine(employeeId) - hasher.combine(businessId) - } -} - -// MARK: - Business Header Image - -struct BusinessHeaderImage: View { - let businessId: Int - - @State private var loadedImage: UIImage? - @State private var isLoading = true - - private var imageURLs: [URL] { - [ - "https://dev.payfrit.com/uploads/headers/\(businessId).png", - "https://dev.payfrit.com/uploads/headers/\(businessId).jpg", - ].compactMap { URL(string: $0) } - } - - var body: some View { - ZStack { - Color(.systemGray6) - - if let image = loadedImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - } else if isLoading { - ProgressView() - .tint(.payfritGreen) - .frame(maxWidth: .infinity) - .frame(height: 100) - } else { - Image(systemName: "building.2") - .font(.system(size: 30)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - .frame(height: 100) - } - } - .task { - await loadImage() - } - } - - private func loadImage() async { - for url in imageURLs { - do { - let (data, response) = try await URLSession.shared.data(from: url) - if let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let image = UIImage(data: data) { - await MainActor.run { - loadedImage = image - isLoading = false - } - return - } - } catch { - continue - } - } - await MainActor.run { - isLoading = false - } - } -} diff --git a/PayfritBeacon/Views/LoginScreen.swift b/PayfritBeacon/Views/LoginScreen.swift deleted file mode 100644 index af5db0f..0000000 --- a/PayfritBeacon/Views/LoginScreen.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI - -struct LoginScreen: View { - @EnvironmentObject var appState: AppState - @State private var username = "" - @State private var password = "" - @State private var showPassword = false - @State private var isLoading = false - @State private var error: String? - @State private var isDev = false - - var body: some View { - GeometryReader { geo in - ScrollView { - VStack(spacing: 12) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 60)) - .foregroundColor(.payfritGreen) - .padding(.top, 40) - - Text("Payfrit Beacon") - .font(.system(size: 28, weight: .bold)) - - Text("Sign in to manage beacons") - .foregroundColor(.secondary) - - if isDev { - Text("DEV MODE — password: 123456") - .font(.caption) - .foregroundColor(.red) - .fontWeight(.medium) - } - - VStack(spacing: 12) { - TextField("Email or Phone", text: $username) - .textFieldStyle(.roundedBorder) - .textContentType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - - ZStack(alignment: .trailing) { - Group { - if showPassword { - TextField("Password", text: $password) - .textContentType(.password) - } else { - SecureField("Password", text: $password) - .textContentType(.password) - } - } - .textFieldStyle(.roundedBorder) - .onSubmit { login() } - - Button { - showPassword.toggle() - } label: { - Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill") - .foregroundColor(.secondary) - .font(.subheadline) - } - .padding(.trailing, 8) - } - } - .padding(.top, 8) - - if let error = error { - HStack { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - Text(error) - .foregroundColor(.red) - .font(.callout) - Spacer() - } - .padding(12) - .background(Color.red.opacity(0.1)) - .cornerRadius(8) - } - - Button(action: login) { - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .frame(maxWidth: .infinity, minHeight: 44) - } else { - Text("Sign In") - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 44) - } - } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - .disabled(isLoading) - } - .padding(.horizontal, 24) - .frame(minHeight: geo.size.height) - } - } - .background(Color(.systemGroupedBackground)) - .task { isDev = await APIService.shared.isDev } - } - - private func login() { - let user = username.trimmingCharacters(in: .whitespaces) - let pass = password - guard !user.isEmpty, !pass.isEmpty else { - error = "Please enter username and password" - return - } - - isLoading = true - error = nil - - Task { - do { - let response = try await APIService.shared.login(username: user, password: pass) - let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl) - await AuthStorage.shared.saveAuth( - userId: response.userId, - token: response.token, - userName: response.userFirstName, - photoUrl: resolvedPhoto - ) - appState.setAuth( - userId: response.userId, - token: response.token, - userName: response.userFirstName, - photoUrl: resolvedPhoto - ) - } catch { - self.error = error.localizedDescription - } - isLoading = false - } - } -} diff --git a/PayfritBeacon/Views/RootView.swift b/PayfritBeacon/Views/RootView.swift deleted file mode 100644 index 0cfd3d6..0000000 --- a/PayfritBeacon/Views/RootView.swift +++ /dev/null @@ -1,88 +0,0 @@ -import SwiftUI -import LocalAuthentication - -struct RootView: View { - @EnvironmentObject var appState: AppState - @State private var isCheckingAuth = true - @State private var isDev = false - - var body: some View { - Group { - if isCheckingAuth { - loadingView - } else if appState.isAuthenticated { - BusinessSelectionScreen() - } else { - LoginScreen() - } - } - .animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated) - .overlay(alignment: .bottomLeading) { - if isDev { - Text("DEV") - .font(.caption.bold()) - .foregroundColor(.white) - .frame(width: 80, height: 20) - .background(Color.orange) - .rotationEffect(.degrees(45)) - .offset(x: -20, y: -6) - .allowsHitTesting(false) - } - } - .task { - isDev = await APIService.shared.isDev - await checkAuthWithBiometrics() - isCheckingAuth = false - } - } - - private var loadingView: some View { - ZStack { - Color.white.ignoresSafeArea() - VStack(spacing: 16) { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.system(size: 60)) - .foregroundColor(.payfritGreen) - Text("Payfrit Beacon") - .font(.title2.bold()) - ProgressView() - .tint(.payfritGreen) - } - } - } - - private func checkAuthWithBiometrics() async { - let creds = await AuthStorage.shared.loadAuth() - guard creds != nil else { return } - - #if targetEnvironment(simulator) - await appState.loadSavedAuth() - return - #else - let context = LAContext() - context.localizedCancelTitle = "Use Password" - var error: NSError? - let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) - - guard canUseBiometrics else { - // No biometrics available — allow login with saved credentials - await appState.loadSavedAuth() - return - } - - do { - let success = try await context.evaluatePolicy( - .deviceOwnerAuthenticationWithBiometrics, - localizedReason: "Sign in to Payfrit Beacon" - ) - if success { - await appState.loadSavedAuth() - } - } catch { - // User cancelled biometrics — still allow them in with saved credentials - NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)") - await appState.loadSavedAuth() - } - #endif - } -} diff --git a/PayfritBeacon/Views/ScannerScreen.swift b/PayfritBeacon/Views/ScannerScreen.swift deleted file mode 100644 index 8466cb8..0000000 --- a/PayfritBeacon/Views/ScannerScreen.swift +++ /dev/null @@ -1,225 +0,0 @@ -import SwiftUI -import CoreLocation - -struct ScannerScreen: View { - @State private var beacons: [Beacon] = [] - @State private var selectedBeacon: Beacon? - @State private var isLoading = true - - // Scanner state - @StateObject private var scanner = ScannerViewModel() - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - // Beacon selector - if isLoading { - ProgressView() - .padding() - } else { - Picker("Select Beacon", selection: $selectedBeacon) { - Text("Choose a beacon...").tag(nil as Beacon?) - ForEach(beacons) { beacon in - Text(beacon.name).tag(beacon as Beacon?) - } - } - .pickerStyle(.menu) - .padding() - } - - Divider() - - // Scanner display - VStack(spacing: 24) { - Spacer() - - // Status indicator - ZStack { - Circle() - .fill(scanner.statusColor.opacity(0.15)) - .frame(width: 160, height: 160) - - Circle() - .fill(scanner.statusColor.opacity(0.3)) - .frame(width: 120, height: 120) - - Image(systemName: scanner.statusIcon) - .font(.system(size: 48)) - .foregroundColor(scanner.statusColor) - } - - Text(scanner.statusText) - .font(.title3.bold()) - - if scanner.isScanning { - VStack(spacing: 8) { - if scanner.rssi != 0 { - HStack { - Text("RSSI:") - .foregroundColor(.secondary) - Text("\(scanner.rssi) dBm") - .font(.system(.body, design: .monospaced)) - .bold() - } - HStack { - Text("Samples:") - .foregroundColor(.secondary) - Text("\(scanner.sampleCount)/\(scanner.requiredSamples)") - .font(.system(.body, design: .monospaced)) - } - // Signal strength bar - SignalStrengthBar(rssi: scanner.rssi) - .frame(height: 20) - .padding(.horizontal, 40) - } else { - Text("Searching for beacon signal...") - .foregroundColor(.secondary) - } - } - } - - Spacer() - - // Start/Stop button - Button { - if scanner.isScanning { - scanner.stop() - } else if let beacon = selectedBeacon { - scanner.start(uuid: beacon.uuid) - } - } label: { - HStack { - Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill") - Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning") - } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 50) - } - .buttonStyle(.borderedProminent) - .tint(scanner.isScanning ? .red : .payfritGreen) - .disabled(selectedBeacon == nil && !scanner.isScanning) - .padding(.horizontal, 24) - .padding(.bottom, 24) - } - } - .navigationTitle("Beacon Scanner") - } - .task { - do { - beacons = try await APIService.shared.listBeacons() - } catch { - // Silently fail — user can still see the scanner - } - isLoading = false - } - .onChange(of: selectedBeacon) { _ in - if scanner.isScanning { - scanner.stop() - } - } - } -} - -// MARK: - Scanner ViewModel - -@MainActor -final class ScannerViewModel: ObservableObject { - @Published var isScanning = false - @Published var statusText = "Select a beacon to scan" - @Published var statusColor: Color = .secondary - @Published var statusIcon = "sensor.tag.radiowaves.forward.fill" - @Published var rssi: Int = 0 - @Published var sampleCount = 0 - let requiredSamples = 5 - - private var beaconScanner: BeaconScanner? - - func start(uuid: String) { - beaconScanner?.dispose() - - beaconScanner = BeaconScanner( - targetUUID: uuid, - onBeaconDetected: { [weak self] avgRssi in - self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)" - self?.statusColor = .green - self?.statusIcon = "checkmark.circle.fill" - }, - onRSSIUpdate: { [weak self] currentRssi, samples in - self?.rssi = currentRssi - self?.sampleCount = samples - }, - onBluetoothOff: { [weak self] in - self?.statusText = "Bluetooth is OFF" - self?.statusColor = .orange - self?.statusIcon = "bluetooth.slash" - }, - onPermissionDenied: { [weak self] in - self?.statusText = "Location Permission Denied" - self?.statusColor = .red - self?.statusIcon = "location.slash.fill" - self?.isScanning = false - }, - onError: { [weak self] message in - self?.statusText = message - self?.statusColor = .red - self?.statusIcon = "exclamationmark.triangle.fill" - self?.isScanning = false - } - ) - - beaconScanner?.startScanning() - isScanning = true - statusText = "Scanning..." - statusColor = .blue - statusIcon = "antenna.radiowaves.left.and.right" - rssi = 0 - sampleCount = 0 - } - - func stop() { - beaconScanner?.dispose() - beaconScanner = nil - isScanning = false - statusText = "Select a beacon to scan" - statusColor = .secondary - statusIcon = "sensor.tag.radiowaves.forward.fill" - rssi = 0 - sampleCount = 0 - } - - deinit { - // Ensure cleanup if view is removed while scanning - // Note: deinit runs on main actor since class is @MainActor - } -} - -// MARK: - Signal Strength Bar - -struct SignalStrengthBar: View { - let rssi: Int - - private var strength: Double { - // Map RSSI from -100..-30 to 0..1 - let clamped = max(-100, min(-30, rssi)) - return Double(clamped + 100) / 70.0 - } - - private var barColor: Color { - if strength > 0.7 { return .green } - if strength > 0.4 { return .yellow } - return .red - } - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.secondary.opacity(0.2)) - - RoundedRectangle(cornerRadius: 4) - .fill(barColor) - .frame(width: geo.size.width * strength) - } - } - } -} diff --git a/PayfritBeacon/Views/ServicePointListScreen.swift b/PayfritBeacon/Views/ServicePointListScreen.swift deleted file mode 100644 index e86bc85..0000000 --- a/PayfritBeacon/Views/ServicePointListScreen.swift +++ /dev/null @@ -1,232 +0,0 @@ -import SwiftUI - -struct ServicePointListScreen: View { - @EnvironmentObject var appState: AppState - @State private var servicePoints: [ServicePoint] = [] - @State private var beacons: [Beacon] = [] - @State private var isLoading = true - @State private var error: String? - @State private var assigningPointId: Int? - - var body: some View { - NavigationStack { - Group { - if isLoading { - ProgressView("Loading service points...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.orange) - Text(error) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - Button("Retry") { loadData() } - .buttonStyle(.borderedProminent) - .tint(.payfritGreen) - } - .padding() - } else if servicePoints.isEmpty { - VStack(spacing: 16) { - Image(systemName: "mappin.and.ellipse") - .font(.system(size: 48)) - .foregroundColor(.secondary) - Text("No service points") - .font(.title3) - .foregroundColor(.secondary) - } - } else { - List(servicePoints) { sp in - ServicePointRow( - servicePoint: sp, - beacons: beacons, - isAssigning: assigningPointId == sp.id, - onAssignBeacon: { beaconId in - assignBeacon(servicePointId: sp.id, beaconId: beaconId) - } - ) - } - } - } - .navigationTitle("Service Points") - .refreshable { - await withCheckedContinuation { continuation in - loadData { continuation.resume() } - } - } - } - .task { loadData() } - } - - private func loadData(completion: (() -> Void)? = nil) { - isLoading = servicePoints.isEmpty - error = nil - Task { - do { - async let sp = APIService.shared.listServicePoints() - async let b = APIService.shared.listBeacons() - servicePoints = try await sp - beacons = try await b - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - isLoading = false - completion?() - } - } - - private func assignBeacon(servicePointId: Int, beaconId: Int?) { - assigningPointId = servicePointId - Task { - do { - try await APIService.shared.assignBeaconToServicePoint( - servicePointId: servicePointId, - beaconId: beaconId - ) - loadData() - } catch let apiError as APIError where apiError == .unauthorized { - await appState.handleUnauthorized() - } catch { - self.error = error.localizedDescription - } - assigningPointId = nil - } - } -} - -// MARK: - Service Point Row - -struct ServicePointRow: View { - let servicePoint: ServicePoint - let beacons: [Beacon] - let isAssigning: Bool - var onAssignBeacon: (Int?) -> Void - - @State private var showBeaconPicker = false - - private var assignedBeacon: Beacon? { - guard let bid = servicePoint.beaconId else { return nil } - return beacons.first { $0.id == bid } - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(servicePoint.name) - .font(.headline) - if !servicePoint.typeName.isEmpty { - Text(servicePoint.typeName) - .font(.caption) - .foregroundColor(.secondary) - } - } - Spacer() - if servicePoint.isActive { - Circle() - .fill(.green) - .frame(width: 8, height: 8) - } else { - Circle() - .fill(.red) - .frame(width: 8, height: 8) - } - } - - // Beacon assignment - HStack { - Image(systemName: "sensor.tag.radiowaves.forward.fill") - .font(.caption) - .foregroundColor(.secondary) - - if isAssigning { - ProgressView() - .controlSize(.small) - } else if let beacon = assignedBeacon { - Text(beacon.name) - .font(.subheadline) - .foregroundColor(.payfritGreen) - } else { - Text("No beacon assigned") - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer() - - Button { - showBeaconPicker = true - } label: { - Text(assignedBeacon != nil ? "Change" : "Assign") - .font(.caption) - } - .buttonStyle(.bordered) - .controlSize(.small) - - if assignedBeacon != nil { - Button { - onAssignBeacon(nil) - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.red) - .font(.caption) - } - .buttonStyle(.plain) - } - } - } - .padding(.vertical, 4) - .sheet(isPresented: $showBeaconPicker) { - BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in - onAssignBeacon(selectedId) - } - } - } -} - -// MARK: - Beacon Picker Sheet - -struct BeaconPickerSheet: View { - let beacons: [Beacon] - let currentBeaconId: Int? - var onSelect: (Int) -> Void - - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - List(beacons) { beacon in - Button { - onSelect(beacon.id) - dismiss() - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(beacon.name) - .font(.headline) - .foregroundColor(.primary) - Text(beacon.formattedUUID) - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - if beacon.id == currentBeaconId { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.payfritGreen) - } - } - } - } - .navigationTitle("Select Beacon") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - } - } -} diff --git a/PayfritBeacon/en.lproj/InfoPlist.strings b/PayfritBeacon/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..ce805e8 --- /dev/null +++ b/PayfritBeacon/en.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +CFBundleDisplayName = "Payfrit Beacon"; +CFBundleName = "Payfrit Beacon"; diff --git a/PayfritBeacon/payfrit-favicon-light-outlines.svg b/PayfritBeacon/payfrit-favicon-light-outlines.svg new file mode 100644 index 0000000..9007140 --- /dev/null +++ b/PayfritBeacon/payfrit-favicon-light-outlines.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..9aad56c --- /dev/null +++ b/Podfile @@ -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 diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..377b246 --- /dev/null +++ b/Podfile.lock @@ -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