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 0000000..1592cde Binary files /dev/null and b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/appicon.png differ 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