commit 4faec5499d8d70255e4e0a1a5c40b236a09811d7 Author: John Pinkyfloyd Date: Sun Feb 1 23:39:29 2026 -0800 Initial commit: Payfrit Beacon iOS native app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c1627c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Xcode +build/ +DerivedData/ +*.xcuserstate +*.xcworkspacedata +xcuserdata/ +*.xccheckout +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +Pods/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# Misc +*.DS_Store +*.swp +*~ diff --git a/PayfritBeacon.xcodeproj/project.pbxproj b/PayfritBeacon.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e018555 --- /dev/null +++ b/PayfritBeacon.xcodeproj/project.pbxproj @@ -0,0 +1,468 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + 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; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + /* Product */ + 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C04000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C05000000001 = { + isa = PBXGroup; + children = ( + C05000000002 /* PayfritBeacon */, + C05000000009 /* Products */, + ); + sourceTree = ""; + }; + C05000000002 /* PayfritBeacon */ = { + isa = PBXGroup; + children = ( + C02000000001 /* PayfritBeaconApp.swift */, + C02000000002 /* Info.plist */, + C05000000003 /* Models */, + C05000000004 /* ViewModels */, + C05000000005 /* Services */, + C05000000006 /* Views */, + C05000000007 /* Resources */, + ); + 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 = ( + C03000000001 /* PayfritBeacon.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C06000000001 /* PayfritBeacon */ = { + isa = PBXNativeTarget; + buildConfigurationList = C08000000003 /* Build configuration list for PBXNativeTarget "PayfritBeacon" */; + buildPhases = ( + C07000000001 /* Sources */, + C04000000001 /* Frameworks */, + C09000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PayfritBeacon; + productName = PayfritBeacon; + productReference = C03000000001 /* PayfritBeacon.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C0A000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + C06000000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = C08000000001 /* Build configuration list for PBXProject "PayfritBeacon" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C05000000001; + productRefGroup = C05000000009 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C06000000001 /* PayfritBeacon */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C09000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C01000000060 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase 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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C0B000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C0B000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C0B000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = U83YL8VRF3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PayfritBeacon/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Beacon"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.beacon; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C0B000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = U83YL8VRF3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PayfritBeacon/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Beacon"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.beacon; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C08000000001 /* Build configuration list for PBXProject "PayfritBeacon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0B000000001 /* Debug */, + C0B000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C08000000003 /* Build configuration list for PBXNativeTarget "PayfritBeacon" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0B000000003 /* Debug */, + C0B000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = C0A000000001 /* Project object */; +} diff --git a/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme new file mode 100644 index 0000000..9e91318 --- /dev/null +++ b/PayfritBeacon.xcodeproj/xcshareddata/xcschemes/PayfritBeacon.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json b/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..650bdd4 --- /dev/null +++ b/PayfritBeacon/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.294", + "green" : "0.698", + "red" : "0.133" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/PayfritBeacon/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PayfritBeacon/Assets.xcassets/Contents.json b/PayfritBeacon/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PayfritBeacon/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PayfritBeacon/Info.plist b/PayfritBeacon/Info.plist new file mode 100644 index 0000000..74f4ffe --- /dev/null +++ b/PayfritBeacon/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSLocationWhenInUseUsageDescription + Payfrit Beacon uses your location to detect nearby BLE beacons. + NSLocationAlwaysAndWhenInUseUsageDescription + Payfrit Beacon uses your location to detect nearby BLE beacons. + NSBluetoothAlwaysUsageDescription + 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 + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/PayfritBeacon/Models/Beacon.swift b/PayfritBeacon/Models/Beacon.swift new file mode 100644 index 0000000..555d978 --- /dev/null +++ b/PayfritBeacon/Models/Beacon.swift @@ -0,0 +1,68 @@ +import Foundation + +struct Beacon: Identifiable { + let id: Int + let businessId: Int + let name: String + let uuid: String + let namespaceId: String + let instanceId: String + let isActive: Bool + let createdAt: Date? + let updatedAt: Date? + + init(json: [String: Any]) { + id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0 + businessId = Self.parseInt(json["BusinessID"]) ?? 0 + name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? "" + uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? "" + namespaceId = (json["NamespaceId"] as? String) ?? "" + instanceId = (json["InstanceId"] as? String) ?? "" + isActive = Self.parseBool(json["IsActive"]) ?? true + createdAt = Self.parseDate(json["CreatedAt"]) + updatedAt = Self.parseDate(json["UpdatedAt"]) + } + + /// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format + var formattedUUID: String { + let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() + guard clean.count == 32 else { return uuid } + let i = clean.startIndex + let p1 = clean[i.. 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 new file mode 100644 index 0000000..00b0ded --- /dev/null +++ b/PayfritBeacon/Models/Employment.swift @@ -0,0 +1,32 @@ +import Foundation + +struct Employment: Identifiable { + /// Composite ID to avoid collisions when same employee works at multiple businesses + var id: String { "\(employeeId)-\(businessId)" } + let employeeId: Int + let businessId: Int + let businessName: String + let businessAddress: String + let businessCity: String + let employeeStatusId: Int + let pendingTaskCount: Int + + var statusName: String { + switch employeeStatusId { + case 1: return "Active" + case 2: return "Suspended" + case 3: return "Terminated" + default: return "Unknown" + } + } + + init(json: [String: Any]) { + employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0 + businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 + businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? "" + businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? "" + businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? "" + employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0 + pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0 + } +} diff --git a/PayfritBeacon/Models/ServicePoint.swift b/PayfritBeacon/Models/ServicePoint.swift new file mode 100644 index 0000000..6229394 --- /dev/null +++ b/PayfritBeacon/Models/ServicePoint.swift @@ -0,0 +1,47 @@ +import Foundation + +struct ServicePoint: Identifiable { + let id: Int + let businessId: Int + let name: String + let typeId: Int + let typeName: String + let code: String + let description: String + let sortOrder: Int + let isActive: Bool + let isClosedToNewMembers: Bool + let beaconId: Int? + let assignedByUserId: Int? + let createdAt: Date? + let updatedAt: Date? + + init(json: [String: Any]) { + id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0 + businessId = Beacon.parseInt(json["BusinessID"]) ?? 0 + name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? "" + typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0 + typeName = (json["TypeName"] as? String) ?? "" + code = (json["Code"] as? String) ?? "" + description = (json["Description"] as? String) ?? "" + sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 + isActive = Beacon.parseBool(json["IsActive"]) ?? true + isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false + beaconId = Beacon.parseInt(json["BeaconID"]) + assignedByUserId = Beacon.parseInt(json["AssignedByUserID"]) + createdAt = Beacon.parseDate(json["CreatedAt"]) + updatedAt = Beacon.parseDate(json["UpdatedAt"]) + } +} + +struct ServicePointType: Identifiable { + let id: Int + let name: String + let sortOrder: Int + + init(json: [String: Any]) { + id = Beacon.parseInt(json["ID"]) ?? 0 + name = (json["Name"] as? String) ?? "" + sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0 + } +} diff --git a/PayfritBeacon/PayfritBeaconApp.swift b/PayfritBeacon/PayfritBeaconApp.swift new file mode 100644 index 0000000..4a67203 --- /dev/null +++ b/PayfritBeacon/PayfritBeaconApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct PayfritBeaconApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(appState) + } + } +} + +extension Color { + static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294) +} diff --git a/PayfritBeacon/Services/APIService.swift b/PayfritBeacon/Services/APIService.swift new file mode 100644 index 0000000..b6dfbbb --- /dev/null +++ b/PayfritBeacon/Services/APIService.swift @@ -0,0 +1,416 @@ +import Foundation + +// MARK: - API Errors + +enum APIError: LocalizedError, Equatable { + case invalidURL + case noData + case decodingError(String) + case serverError(String) + case unauthorized + case networkError(String) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid URL" + case .noData: return "No data received" + case .decodingError(let msg): return "Decoding error: \(msg)" + case .serverError(let msg): return msg + case .unauthorized: return "Unauthorized" + case .networkError(let msg): return msg + } + } +} + +// MARK: - Login Response + +struct LoginResponse { + let userId: Int + let userFirstName: String + let token: String + let photoUrl: String +} + +// MARK: - API Service + +actor APIService { + static let shared = APIService() + + private enum Environment { + case development, production + + var baseURL: String { + switch self { + case .development: return "https://dev.payfrit.com/api" + case .production: return "https://biz.payfrit.com/api" + } + } + } + + private let environment: Environment = .development + var isDev: Bool { environment == .development } + private var userToken: String? + private var userId: Int? + private var businessId: Int = 0 + + var baseURL: String { environment.baseURL } + + // MARK: - Configuration + + func setAuth(token: String?, userId: Int?) { + self.userToken = token + self.userId = userId + } + + func setBusinessId(_ id: Int) { + self.businessId = id + } + + func getToken() -> String? { userToken } + func getUserId() -> Int? { userId } + func getBusinessId() -> Int { businessId } + + // MARK: - Core Request + + private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] { + let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)") + guard let url = URL(string: urlString) else { throw APIError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + + if let token = userToken, !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "X-User-Token") + } + if businessId > 0 { + request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID") + } + + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 401 { throw APIError.unauthorized } + guard (200...299).contains(httpResponse.statusCode) else { + throw APIError.serverError("HTTP \(httpResponse.statusCode)") + } + } + + if let json = tryDecodeJSON(data) { + return json + } + throw APIError.decodingError("Non-JSON response") + } + + private func tryDecodeJSON(_ data: Data) -> [String: Any]? { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return json + } + guard let body = String(data: data, encoding: .utf8), + let start = body.firstIndex(of: "{"), + let end = body.lastIndex(of: "}") else { return nil } + let jsonStr = String(body[start...end]) + guard let jsonData = jsonStr.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + } + + private func ok(_ json: [String: Any]) -> Bool { + for key in ["OK", "ok", "Ok"] { + if let b = json[key] as? Bool { return b } + if let i = json[key] as? Int { return i == 1 } + if let s = json[key] as? String { + let lower = s.lowercased() + return lower == "true" || lower == "1" || lower == "yes" + } + } + return false + } + + private func err(_ json: [String: Any]) -> String { + let msg = (json["ERROR"] as? String) ?? (json["error"] as? String) + ?? (json["Error"] as? String) ?? (json["message"] as? String) ?? "" + return msg.isEmpty ? "Unknown error" : msg + } + + nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? { + for key in keys { + if let arr = json[key] as? [[String: Any]] { return arr } + } + for (_, value) in json { + if let arr = value as? [[String: Any]], !arr.isEmpty { return arr } + } + return nil + } + + // MARK: - Auth + + func login(username: String, password: String) async throws -> LoginResponse { + let json = try await postJSON("/auth/login.cfm", payload: [ + "username": username, + "password": password + ]) + + guard ok(json) else { + let e = err(json) + if e == "bad_credentials" { + throw APIError.serverError("Invalid email/phone or password") + } + throw APIError.serverError("Login failed: \(e)") + } + + let uid = (json["UserID"] as? Int) + ?? Int(json["UserID"] as? String ?? "") + ?? (json["UserId"] as? Int) + ?? 0 + let token = (json["Token"] as? String) + ?? (json["token"] as? String) + ?? "" + + guard uid > 0 else { + throw APIError.serverError("Login failed: no user ID returned") + } + guard !token.isEmpty else { + throw APIError.serverError("Login failed: no token returned") + } + + let firstName = (json["UserFirstName"] as? String) + ?? (json["FirstName"] as? String) + ?? (json["firstName"] as? String) + ?? (json["Name"] as? String) + ?? (json["name"] as? String) + ?? "" + let photoUrl = (json["UserPhotoUrl"] as? String) + ?? (json["PhotoUrl"] as? String) + ?? (json["photoUrl"] as? String) + ?? "" + + self.userToken = token + self.userId = uid + + return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl) + } + + func logout() { + userToken = nil + userId = nil + businessId = 0 + } + + // MARK: - Businesses + + func getMyBusinesses() async throws -> [Employment] { + guard let uid = userId, uid > 0 else { + throw APIError.serverError("User not logged in") + } + + let json = try await postJSON("/workers/myBusinesses.cfm", payload: [ + "UserID": uid + ]) + + guard ok(json) else { + throw APIError.serverError("Failed to load businesses: \(err(json))") + } + + guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else { + return [] + } + return arr.map { Employment(json: $0) } + } + + // MARK: - Beacons + + func listBeacons() async throws -> [Beacon] { + let json = try await postJSON("/beacons/list.cfm", payload: [ + "BusinessID": businessId + ]) + guard ok(json) else { + throw APIError.serverError("Failed to load beacons: \(err(json))") + } + guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] } + return arr.map { Beacon(json: $0) } + } + + func getBeacon(beaconId: Int) async throws -> Beacon { + let json = try await postJSON("/beacons/get.cfm", payload: [ + "BeaconID": beaconId, + "BusinessID": businessId + ]) + guard ok(json) else { + throw APIError.serverError("Failed to load beacon: \(err(json))") + } + var beaconJson: [String: Any]? + for key in ["BEACON", "Beacon", "beacon"] { + if let d = json[key] as? [String: Any] { beaconJson = d; break } + } + if beaconJson == nil { + for (_, value) in json { + if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break } + } + } + guard let beaconJson = beaconJson else { + throw APIError.serverError("Invalid beacon response") + } + return Beacon(json: beaconJson) + } + + func createBeacon(name: String, uuid: String) async throws -> Int { + let json = try await postJSON("/beacons/create.cfm", payload: [ + "BusinessID": businessId, + "Name": name, + "UUID": uuid + ]) + guard ok(json) else { + throw APIError.serverError("Failed to create beacon: \(err(json))") + } + return (json["BeaconID"] as? Int) + ?? (json["ID"] as? Int) + ?? Int(json["BeaconID"] as? String ?? "") + ?? 0 + } + + func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws { + let json = try await postJSON("/beacons/update.cfm", payload: [ + "BeaconID": beaconId, + "BusinessID": businessId, + "Name": name, + "UUID": uuid, + "IsActive": isActive + ]) + guard ok(json) else { + throw APIError.serverError("Failed to update beacon: \(err(json))") + } + } + + func deleteBeacon(beaconId: Int) async throws { + let json = try await postJSON("/beacons/delete.cfm", payload: [ + "BeaconID": beaconId, + "BusinessID": businessId + ]) + guard ok(json) else { + throw APIError.serverError("Failed to delete beacon: \(err(json))") + } + } + + // MARK: - Service Points + + func listServicePoints() async throws -> [ServicePoint] { + let json = try await postJSON("/servicePoints/list.cfm", payload: [ + "BusinessID": businessId + ]) + guard ok(json) else { + throw APIError.serverError("Failed to load service points: \(err(json))") + } + guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] } + return arr.map { ServicePoint(json: $0) } + } + + func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws { + var payload: [String: Any] = [ + "ServicePointID": servicePointId, + "BusinessID": businessId + ] + if let bid = beaconId { + payload["BeaconID"] = bid + } + let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload) + guard ok(json) else { + throw APIError.serverError("Failed to assign beacon: \(err(json))") + } + } + + func listServicePointTypes() async throws -> [ServicePointType] { + let json = try await postJSON("/servicePoints/types.cfm", payload: [ + "BusinessID": businessId + ]) + guard ok(json) else { + throw APIError.serverError("Failed to load service point types: \(err(json))") + } + guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] } + return arr.map { ServicePointType(json: $0) } + } + + // MARK: - URL Helpers + + func resolvePhotoUrl(_ rawUrl: String) -> String { + let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return "" } + if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed } + let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com" + if trimmed.hasPrefix("/") { return baseDomain + trimmed } + return baseDomain + "/" + trimmed + } + + // MARK: - Date Parsing + + private static let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let iso8601NoFrac: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let simpleDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static let cfmlDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static let cfmlShortFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static let cfmlAltFormatters: [DateFormatter] = { + let formats = [ + "MMM dd, yyyy HH:mm:ss", + "MM/dd/yyyy HH:mm:ss", + "yyyy-MM-dd HH:mm:ss.S", + "yyyy-MM-dd'T'HH:mm:ss.S", + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd'T'HH:mm:ss.SZ", + ] + return formats.map { fmt in + let f = DateFormatter() + f.dateFormat = fmt + f.locale = Locale(identifier: "en_US_POSIX") + return f + } + }() + + nonisolated static func parseDate(_ string: String) -> Date? { + let s = string.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return nil } + + if let epoch = Double(s) { + if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) } + if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) } + } + + if let d = iso8601Formatter.date(from: s) { return d } + if let d = iso8601NoFrac.date(from: s) { return d } + if let d = simpleDateFormatter.date(from: s) { return d } + if let d = cfmlDateFormatter.date(from: s) { return d } + if let d = cfmlShortFormatter.date(from: s) { return d } + for formatter in cfmlAltFormatters { + if let d = formatter.date(from: s) { return d } + } + return nil + } +} diff --git a/PayfritBeacon/Services/AuthStorage.swift b/PayfritBeacon/Services/AuthStorage.swift new file mode 100644 index 0000000..04a165a --- /dev/null +++ b/PayfritBeacon/Services/AuthStorage.swift @@ -0,0 +1,95 @@ +import Foundation +import Security + +struct AuthCredentials { + let userId: Int + let token: String + let userName: String? + let photoUrl: String? +} + +actor AuthStorage { + static let shared = AuthStorage() + + private let userIdKey = "payfrit_beacon_user_id" + private let userNameKey = "payfrit_beacon_user_name" + private let userPhotoKey = "payfrit_beacon_user_photo" + private let serviceName = "com.payfrit.beacon" + private let tokenAccount = "auth_token" + + // MARK: - Save + + func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { + UserDefaults.standard.set(userId, forKey: userIdKey) + // Always overwrite name/photo to prevent stale data from previous user + if let name = userName, !name.isEmpty { + UserDefaults.standard.set(name, forKey: userNameKey) + } else { + UserDefaults.standard.removeObject(forKey: userNameKey) + } + if let photo = photoUrl, !photo.isEmpty { + UserDefaults.standard.set(photo, forKey: userPhotoKey) + } else { + UserDefaults.standard.removeObject(forKey: userPhotoKey) + } + saveToKeychain(token) + } + + // MARK: - Load + + func loadAuth() -> AuthCredentials? { + let userId = UserDefaults.standard.integer(forKey: userIdKey) + guard userId > 0 else { return nil } + guard let token = loadFromKeychain(), !token.isEmpty else { return nil } + let userName = UserDefaults.standard.string(forKey: userNameKey) + let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey) + return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl) + } + + // MARK: - Clear + + func clearAuth() { + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userNameKey) + UserDefaults.standard.removeObject(forKey: userPhotoKey) + deleteFromKeychain() + } + + // MARK: - Keychain + + private func saveToKeychain(_ token: String) { + deleteFromKeychain() + guard let data = token.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: tokenAccount, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + SecItemAdd(query as CFDictionary, nil) + } + + private func loadFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: tokenAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func deleteFromKeychain() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: tokenAccount + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/PayfritBeacon/Services/BeaconScanner.swift b/PayfritBeacon/Services/BeaconScanner.swift new file mode 100644 index 0000000..4363653 --- /dev/null +++ b/PayfritBeacon/Services/BeaconScanner.swift @@ -0,0 +1,262 @@ +import UIKit +import CoreBluetooth +import CoreLocation + +/// Beacon scanner for detecting BLE beacons by UUID. +/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement. +/// All mutable state is confined to the main thread via @MainActor. +@MainActor +final class BeaconScanner: NSObject, ObservableObject { + private let targetUUID: String + private let normalizedTargetUUID: String + private let onBeaconDetected: (Double) -> Void + private let onRSSIUpdate: ((Int, Int) -> Void)? + private let onBluetoothOff: (() -> Void)? + private let onPermissionDenied: (() -> Void)? + private let onError: ((String) -> Void)? + + @Published var isScanning = false + + private var locationManager: CLLocationManager? + private var activeConstraint: CLBeaconIdentityConstraint? + private var checkTimer: Timer? + private var bluetoothManager: CBCentralManager? + + // RSSI samples for dwell time enforcement + private var rssiSamples: [Int] = [] + private let minSamplesToConfirm = 5 // ~5 seconds + private let rssiThreshold = -75 + private var hasConfirmed = false + private var isPendingPermission = false + + init(targetUUID: String, + onBeaconDetected: @escaping (Double) -> Void, + onRSSIUpdate: ((Int, Int) -> Void)? = nil, + onBluetoothOff: (() -> Void)? = nil, + onPermissionDenied: (() -> Void)? = nil, + onError: ((String) -> Void)? = nil) { + self.targetUUID = targetUUID + self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased() + self.onBeaconDetected = onBeaconDetected + self.onRSSIUpdate = onRSSIUpdate + self.onBluetoothOff = onBluetoothOff + self.onPermissionDenied = onPermissionDenied + self.onError = onError + super.init() + } + + // MARK: - UUID formatting + + private nonisolated func formatUUID(_ uuid: String) -> String { + let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() + guard clean.count == 32 else { return uuid } + let i = clean.startIndex + let p1 = clean[i..= 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 new file mode 100644 index 0000000..4fbec6c --- /dev/null +++ b/PayfritBeacon/ViewModels/AppState.swift @@ -0,0 +1,49 @@ +import SwiftUI + +@MainActor +final class AppState: ObservableObject { + @Published var userId: Int? + @Published var userName: String? + @Published var userPhotoUrl: String? + @Published var userToken: String? + @Published var businessId: Int = 0 + @Published var businessName: String = "" + @Published var isAuthenticated = false + + func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) { + self.userId = userId + self.userToken = token + self.userName = userName + self.userPhotoUrl = photoUrl + self.isAuthenticated = true + } + + func setBusiness(id: Int, name: String) { + self.businessId = id + self.businessName = name + } + + func clearAuth() { + userId = nil + userToken = nil + userName = nil + userPhotoUrl = nil + isAuthenticated = false + businessId = 0 + businessName = "" + } + + /// Handle 401 unauthorized — clear everything and force re-login + func handleUnauthorized() async { + await AuthStorage.shared.clearAuth() + await APIService.shared.logout() + clearAuth() + } + + func loadSavedAuth() async { + let creds = await AuthStorage.shared.loadAuth() + guard let creds = creds else { return } + await APIService.shared.setAuth(token: creds.token, userId: creds.userId) + setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl) + } +} diff --git a/PayfritBeacon/Views/BeaconDashboard.swift b/PayfritBeacon/Views/BeaconDashboard.swift new file mode 100644 index 0000000..e8e148e --- /dev/null +++ b/PayfritBeacon/Views/BeaconDashboard.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct BeaconDashboard: View { + @EnvironmentObject var appState: AppState + let business: Employment + @State private var isReady = false + + var body: some View { + Group { + if isReady { + TabView { + BeaconListScreen() + .tabItem { + Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill") + } + + ServicePointListScreen() + .tabItem { + Label("Service Points", systemImage: "mappin.and.ellipse") + } + + ScannerScreen() + .tabItem { + Label("Scanner", systemImage: "antenna.radiowaves.left.and.right") + } + } + .tint(.payfritGreen) + } else { + ProgressView("Loading...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + await APIService.shared.setBusinessId(business.businessId) + appState.setBusiness(id: business.businessId, name: business.businessName) + isReady = true + } + } +} diff --git a/PayfritBeacon/Views/BeaconDetailScreen.swift b/PayfritBeacon/Views/BeaconDetailScreen.swift new file mode 100644 index 0000000..1b4aac9 --- /dev/null +++ b/PayfritBeacon/Views/BeaconDetailScreen.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct BeaconDetailScreen: View { + let beacon: Beacon + var onSaved: () -> Void + + @State private var name: String = "" + @State private var uuid: String = "" + @State private var isActive: Bool = true + @State private var isSaving = false + @State private var isDeleting = false + @State private var error: String? + @State private var showDeleteConfirm = false + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section("Beacon Info") { + TextField("Name", text: $name) + TextField("UUID (32 hex characters)", text: $uuid) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + Toggle("Active", isOn: $isActive) + } + + Section("Details") { + LabeledContent("ID", value: "\(beacon.id)") + LabeledContent("Business ID", value: "\(beacon.businessId)") + if let date = beacon.createdAt { + LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened)) + } + if let date = beacon.updatedAt { + LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened)) + } + } + + if let error = error { + Section { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + Text(error) + .foregroundColor(.red) + } + } + } + + Section { + Button("Save Changes") { save() } + .frame(maxWidth: .infinity) + .disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty) + } + + Section { + Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) { + showDeleteConfirm = true + } + .frame(maxWidth: .infinity) + .disabled(isSaving || isDeleting) + } + } + .navigationTitle(beacon.name) + .onAppear { + name = beacon.name + uuid = beacon.uuid + isActive = beacon.isActive + } + .alert("Delete Beacon?", isPresented: $showDeleteConfirm) { + Button("Delete", role: .destructive) { deleteBeacon() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.") + } + } + + private func save() { + isSaving = true + error = nil + Task { + do { + try await APIService.shared.updateBeacon( + beaconId: beacon.id, + name: name.trimmingCharacters(in: .whitespaces), + uuid: uuid.trimmingCharacters(in: .whitespaces), + isActive: isActive + ) + onSaved() + dismiss() + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + isSaving = false + } + } + + private func deleteBeacon() { + isDeleting = true + error = nil + Task { + do { + try await APIService.shared.deleteBeacon(beaconId: beacon.id) + onSaved() + dismiss() + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + isDeleting = false + } + } +} diff --git a/PayfritBeacon/Views/BeaconEditSheet.swift b/PayfritBeacon/Views/BeaconEditSheet.swift new file mode 100644 index 0000000..d3ffc9b --- /dev/null +++ b/PayfritBeacon/Views/BeaconEditSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct BeaconEditSheet: View { + var onSaved: () -> Void + + @State private var name = "" + @State private var uuid = "" + @State private var isSaving = false + @State private var error: String? + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("New Beacon") { + TextField("Name (e.g. Table 1 Beacon)", text: $name) + TextField("UUID (32 hex characters)", text: $uuid) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .font(.system(.body, design: .monospaced)) + } + + Section { + Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001") + .font(.caption) + .foregroundColor(.secondary) + } + + if let error = error { + Section { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + Text(error) + .foregroundColor(.red) + } + } + } + } + .navigationTitle("Add Beacon") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(isSaving || name.isEmpty || uuid.isEmpty) + } + } + } + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces) + + guard !trimmedName.isEmpty else { + error = "Name is required" + return + } + guard !trimmedUUID.isEmpty else { + error = "UUID is required" + return + } + + isSaving = true + error = nil + Task { + do { + _ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID) + onSaved() + dismiss() + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + isSaving = false + } + } +} diff --git a/PayfritBeacon/Views/BeaconListScreen.swift b/PayfritBeacon/Views/BeaconListScreen.swift new file mode 100644 index 0000000..4d9b1c7 --- /dev/null +++ b/PayfritBeacon/Views/BeaconListScreen.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct BeaconListScreen: View { + @EnvironmentObject var appState: AppState + @State private var beacons: [Beacon] = [] + @State private var isLoading = true + @State private var error: String? + @State private var showAddSheet = false + @State private var isDeleting = false + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading beacons...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + Text(error) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button("Retry") { loadBeacons() } + .buttonStyle(.borderedProminent) + .tint(.payfritGreen) + } + .padding() + } else if beacons.isEmpty { + VStack(spacing: 16) { + Image(systemName: "sensor.tag.radiowaves.forward.fill") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No beacons yet") + .font(.title3) + .foregroundColor(.secondary) + Text("Tap + to add your first beacon") + .font(.subheadline) + .foregroundColor(.secondary) + } + } else { + List { + ForEach(beacons) { beacon in + NavigationLink(value: beacon) { + BeaconRow(beacon: beacon) + } + } + .onDelete(perform: deleteBeacons) + } + } + } + .navigationTitle("Beacons") + .navigationDestination(for: Beacon.self) { beacon in + BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() }) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddSheet) { + BeaconEditSheet(onSaved: { loadBeacons() }) + } + .refreshable { + await withCheckedContinuation { continuation in + loadBeacons { continuation.resume() } + } + } + } + .task { loadBeacons() } + } + + private func loadBeacons(completion: (() -> Void)? = nil) { + isLoading = beacons.isEmpty + error = nil + Task { + do { + beacons = try await APIService.shared.listBeacons() + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + isLoading = false + completion?() + } + } + + private func deleteBeacons(at offsets: IndexSet) { + guard !isDeleting else { return } + let toDelete = offsets.map { beacons[$0] } + // Optimistic removal + beacons.remove(atOffsets: offsets) + isDeleting = true + + Task { + var failedBeacons: [Beacon] = [] + for beacon in toDelete { + do { + try await APIService.shared.deleteBeacon(beaconId: beacon.id) + } catch { + failedBeacons.append(beacon) + self.error = error.localizedDescription + } + } + // Restore any that failed to delete + if !failedBeacons.isEmpty { + beacons.append(contentsOf: failedBeacons) + } + isDeleting = false + } + } +} + +// MARK: - Beacon Row + +struct BeaconRow: View { + let beacon: Beacon + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(beacon.name) + .font(.headline) + Spacer() + if beacon.isActive { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.caption) + } + } + Text(beacon.formattedUUID) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + .padding(.vertical, 2) + } +} + +// Make Beacon Hashable for NavigationLink +extension Beacon: Hashable { + static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id } + func hash(into hasher: inout Hasher) { hasher.combine(id) } +} diff --git a/PayfritBeacon/Views/BusinessSelectionScreen.swift b/PayfritBeacon/Views/BusinessSelectionScreen.swift new file mode 100644 index 0000000..c410417 --- /dev/null +++ b/PayfritBeacon/Views/BusinessSelectionScreen.swift @@ -0,0 +1,196 @@ +import SwiftUI + +struct BusinessSelectionScreen: View { + @EnvironmentObject var appState: AppState + @State private var businesses: [Employment] = [] + @State private var isLoading = true + @State private var error: String? + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading businesses...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + Text(error) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button("Retry") { loadBusinesses() } + .buttonStyle(.borderedProminent) + .tint(.payfritGreen) + } + .padding() + } else if businesses.isEmpty { + VStack(spacing: 16) { + Image(systemName: "building.2") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No businesses found") + .foregroundColor(.secondary) + } + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(businesses) { biz in + NavigationLink(value: biz) { + VStack(spacing: 0) { + BusinessHeaderImage(businessId: biz.businessId) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(biz.businessName) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + if !biz.businessCity.isEmpty { + Text(biz.businessCity) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color(.systemGray4), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 20) + } + } + } + .navigationTitle("Select Business") + .navigationDestination(for: Employment.self) { biz in + BeaconDashboard(business: biz) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + logout() + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + } + } + } + } + .task { loadBusinesses() } + } + + private func loadBusinesses() { + isLoading = true + error = nil + Task { + do { + businesses = try await APIService.shared.getMyBusinesses() + isLoading = false + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + isLoading = false + } + } + } + + private func logout() { + Task { + await AuthStorage.shared.clearAuth() + await APIService.shared.logout() + appState.clearAuth() + } + } +} + +// Make Employment Hashable for NavigationLink +extension Employment: Hashable { + static func == (lhs: Employment, rhs: Employment) -> Bool { + lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId + } + func hash(into hasher: inout Hasher) { + hasher.combine(employeeId) + hasher.combine(businessId) + } +} + +// MARK: - Business Header Image + +struct BusinessHeaderImage: View { + let businessId: Int + + @State private var loadedImage: UIImage? + @State private var isLoading = true + + private var imageURLs: [URL] { + [ + "https://dev.payfrit.com/uploads/headers/\(businessId).png", + "https://dev.payfrit.com/uploads/headers/\(businessId).jpg", + ].compactMap { URL(string: $0) } + } + + var body: some View { + ZStack { + Color(.systemGray6) + + if let image = loadedImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + } else if isLoading { + ProgressView() + .tint(.payfritGreen) + .frame(maxWidth: .infinity) + .frame(height: 100) + } else { + Image(systemName: "building.2") + .font(.system(size: 30)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 100) + } + } + .task { + await loadImage() + } + } + + private func loadImage() async { + for url in imageURLs { + do { + let (data, response) = try await URLSession.shared.data(from: url) + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200, + let image = UIImage(data: data) { + await MainActor.run { + loadedImage = image + isLoading = false + } + return + } + } catch { + continue + } + } + await MainActor.run { + isLoading = false + } + } +} diff --git a/PayfritBeacon/Views/LoginScreen.swift b/PayfritBeacon/Views/LoginScreen.swift new file mode 100644 index 0000000..af5db0f --- /dev/null +++ b/PayfritBeacon/Views/LoginScreen.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct LoginScreen: View { + @EnvironmentObject var appState: AppState + @State private var username = "" + @State private var password = "" + @State private var showPassword = false + @State private var isLoading = false + @State private var error: String? + @State private var isDev = false + + var body: some View { + GeometryReader { geo in + ScrollView { + VStack(spacing: 12) { + Image(systemName: "sensor.tag.radiowaves.forward.fill") + .font(.system(size: 60)) + .foregroundColor(.payfritGreen) + .padding(.top, 40) + + Text("Payfrit Beacon") + .font(.system(size: 28, weight: .bold)) + + Text("Sign in to manage beacons") + .foregroundColor(.secondary) + + if isDev { + Text("DEV MODE — password: 123456") + .font(.caption) + .foregroundColor(.red) + .fontWeight(.medium) + } + + VStack(spacing: 12) { + TextField("Email or Phone", text: $username) + .textFieldStyle(.roundedBorder) + .textContentType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + + ZStack(alignment: .trailing) { + Group { + if showPassword { + TextField("Password", text: $password) + .textContentType(.password) + } else { + SecureField("Password", text: $password) + .textContentType(.password) + } + } + .textFieldStyle(.roundedBorder) + .onSubmit { login() } + + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill") + .foregroundColor(.secondary) + .font(.subheadline) + } + .padding(.trailing, 8) + } + } + .padding(.top, 8) + + if let error = error { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + Text(error) + .foregroundColor(.red) + .font(.callout) + Spacer() + } + .padding(12) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + Button(action: login) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity, minHeight: 44) + } else { + Text("Sign In") + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 44) + } + } + .buttonStyle(.borderedProminent) + .tint(.payfritGreen) + .disabled(isLoading) + } + .padding(.horizontal, 24) + .frame(minHeight: geo.size.height) + } + } + .background(Color(.systemGroupedBackground)) + .task { isDev = await APIService.shared.isDev } + } + + private func login() { + let user = username.trimmingCharacters(in: .whitespaces) + let pass = password + guard !user.isEmpty, !pass.isEmpty else { + error = "Please enter username and password" + return + } + + isLoading = true + error = nil + + Task { + do { + let response = try await APIService.shared.login(username: user, password: pass) + let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl) + await AuthStorage.shared.saveAuth( + userId: response.userId, + token: response.token, + userName: response.userFirstName, + photoUrl: resolvedPhoto + ) + appState.setAuth( + userId: response.userId, + token: response.token, + userName: response.userFirstName, + photoUrl: resolvedPhoto + ) + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + } +} diff --git a/PayfritBeacon/Views/RootView.swift b/PayfritBeacon/Views/RootView.swift new file mode 100644 index 0000000..0cfd3d6 --- /dev/null +++ b/PayfritBeacon/Views/RootView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import LocalAuthentication + +struct RootView: View { + @EnvironmentObject var appState: AppState + @State private var isCheckingAuth = true + @State private var isDev = false + + var body: some View { + Group { + if isCheckingAuth { + loadingView + } else if appState.isAuthenticated { + BusinessSelectionScreen() + } else { + LoginScreen() + } + } + .animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated) + .overlay(alignment: .bottomLeading) { + if isDev { + Text("DEV") + .font(.caption.bold()) + .foregroundColor(.white) + .frame(width: 80, height: 20) + .background(Color.orange) + .rotationEffect(.degrees(45)) + .offset(x: -20, y: -6) + .allowsHitTesting(false) + } + } + .task { + isDev = await APIService.shared.isDev + await checkAuthWithBiometrics() + isCheckingAuth = false + } + } + + private var loadingView: some View { + ZStack { + Color.white.ignoresSafeArea() + VStack(spacing: 16) { + Image(systemName: "sensor.tag.radiowaves.forward.fill") + .font(.system(size: 60)) + .foregroundColor(.payfritGreen) + Text("Payfrit Beacon") + .font(.title2.bold()) + ProgressView() + .tint(.payfritGreen) + } + } + } + + private func checkAuthWithBiometrics() async { + let creds = await AuthStorage.shared.loadAuth() + guard creds != nil else { return } + + #if targetEnvironment(simulator) + await appState.loadSavedAuth() + return + #else + let context = LAContext() + context.localizedCancelTitle = "Use Password" + var error: NSError? + let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + + guard canUseBiometrics else { + // No biometrics available — allow login with saved credentials + await appState.loadSavedAuth() + return + } + + do { + let success = try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Sign in to Payfrit Beacon" + ) + if success { + await appState.loadSavedAuth() + } + } catch { + // User cancelled biometrics — still allow them in with saved credentials + NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)") + await appState.loadSavedAuth() + } + #endif + } +} diff --git a/PayfritBeacon/Views/ScannerScreen.swift b/PayfritBeacon/Views/ScannerScreen.swift new file mode 100644 index 0000000..8466cb8 --- /dev/null +++ b/PayfritBeacon/Views/ScannerScreen.swift @@ -0,0 +1,225 @@ +import SwiftUI +import CoreLocation + +struct ScannerScreen: View { + @State private var beacons: [Beacon] = [] + @State private var selectedBeacon: Beacon? + @State private var isLoading = true + + // Scanner state + @StateObject private var scanner = ScannerViewModel() + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Beacon selector + if isLoading { + ProgressView() + .padding() + } else { + Picker("Select Beacon", selection: $selectedBeacon) { + Text("Choose a beacon...").tag(nil as Beacon?) + ForEach(beacons) { beacon in + Text(beacon.name).tag(beacon as Beacon?) + } + } + .pickerStyle(.menu) + .padding() + } + + Divider() + + // Scanner display + VStack(spacing: 24) { + Spacer() + + // Status indicator + ZStack { + Circle() + .fill(scanner.statusColor.opacity(0.15)) + .frame(width: 160, height: 160) + + Circle() + .fill(scanner.statusColor.opacity(0.3)) + .frame(width: 120, height: 120) + + Image(systemName: scanner.statusIcon) + .font(.system(size: 48)) + .foregroundColor(scanner.statusColor) + } + + Text(scanner.statusText) + .font(.title3.bold()) + + if scanner.isScanning { + VStack(spacing: 8) { + if scanner.rssi != 0 { + HStack { + Text("RSSI:") + .foregroundColor(.secondary) + Text("\(scanner.rssi) dBm") + .font(.system(.body, design: .monospaced)) + .bold() + } + HStack { + Text("Samples:") + .foregroundColor(.secondary) + Text("\(scanner.sampleCount)/\(scanner.requiredSamples)") + .font(.system(.body, design: .monospaced)) + } + // Signal strength bar + SignalStrengthBar(rssi: scanner.rssi) + .frame(height: 20) + .padding(.horizontal, 40) + } else { + Text("Searching for beacon signal...") + .foregroundColor(.secondary) + } + } + } + + Spacer() + + // Start/Stop button + Button { + if scanner.isScanning { + scanner.stop() + } else if let beacon = selectedBeacon { + scanner.start(uuid: beacon.uuid) + } + } label: { + HStack { + Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill") + Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning") + } + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 50) + } + .buttonStyle(.borderedProminent) + .tint(scanner.isScanning ? .red : .payfritGreen) + .disabled(selectedBeacon == nil && !scanner.isScanning) + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + } + .navigationTitle("Beacon Scanner") + } + .task { + do { + beacons = try await APIService.shared.listBeacons() + } catch { + // Silently fail — user can still see the scanner + } + isLoading = false + } + .onChange(of: selectedBeacon) { _ in + if scanner.isScanning { + scanner.stop() + } + } + } +} + +// MARK: - Scanner ViewModel + +@MainActor +final class ScannerViewModel: ObservableObject { + @Published var isScanning = false + @Published var statusText = "Select a beacon to scan" + @Published var statusColor: Color = .secondary + @Published var statusIcon = "sensor.tag.radiowaves.forward.fill" + @Published var rssi: Int = 0 + @Published var sampleCount = 0 + let requiredSamples = 5 + + private var beaconScanner: BeaconScanner? + + func start(uuid: String) { + beaconScanner?.dispose() + + beaconScanner = BeaconScanner( + targetUUID: uuid, + onBeaconDetected: { [weak self] avgRssi in + self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)" + self?.statusColor = .green + self?.statusIcon = "checkmark.circle.fill" + }, + onRSSIUpdate: { [weak self] currentRssi, samples in + self?.rssi = currentRssi + self?.sampleCount = samples + }, + onBluetoothOff: { [weak self] in + self?.statusText = "Bluetooth is OFF" + self?.statusColor = .orange + self?.statusIcon = "bluetooth.slash" + }, + onPermissionDenied: { [weak self] in + self?.statusText = "Location Permission Denied" + self?.statusColor = .red + self?.statusIcon = "location.slash.fill" + self?.isScanning = false + }, + onError: { [weak self] message in + self?.statusText = message + self?.statusColor = .red + self?.statusIcon = "exclamationmark.triangle.fill" + self?.isScanning = false + } + ) + + beaconScanner?.startScanning() + isScanning = true + statusText = "Scanning..." + statusColor = .blue + statusIcon = "antenna.radiowaves.left.and.right" + rssi = 0 + sampleCount = 0 + } + + func stop() { + beaconScanner?.dispose() + beaconScanner = nil + isScanning = false + statusText = "Select a beacon to scan" + statusColor = .secondary + statusIcon = "sensor.tag.radiowaves.forward.fill" + rssi = 0 + sampleCount = 0 + } + + deinit { + // Ensure cleanup if view is removed while scanning + // Note: deinit runs on main actor since class is @MainActor + } +} + +// MARK: - Signal Strength Bar + +struct SignalStrengthBar: View { + let rssi: Int + + private var strength: Double { + // Map RSSI from -100..-30 to 0..1 + let clamped = max(-100, min(-30, rssi)) + return Double(clamped + 100) / 70.0 + } + + private var barColor: Color { + if strength > 0.7 { return .green } + if strength > 0.4 { return .yellow } + return .red + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.2)) + + RoundedRectangle(cornerRadius: 4) + .fill(barColor) + .frame(width: geo.size.width * strength) + } + } + } +} diff --git a/PayfritBeacon/Views/ServicePointListScreen.swift b/PayfritBeacon/Views/ServicePointListScreen.swift new file mode 100644 index 0000000..e86bc85 --- /dev/null +++ b/PayfritBeacon/Views/ServicePointListScreen.swift @@ -0,0 +1,232 @@ +import SwiftUI + +struct ServicePointListScreen: View { + @EnvironmentObject var appState: AppState + @State private var servicePoints: [ServicePoint] = [] + @State private var beacons: [Beacon] = [] + @State private var isLoading = true + @State private var error: String? + @State private var assigningPointId: Int? + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading service points...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.orange) + Text(error) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button("Retry") { loadData() } + .buttonStyle(.borderedProminent) + .tint(.payfritGreen) + } + .padding() + } else if servicePoints.isEmpty { + VStack(spacing: 16) { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 48)) + .foregroundColor(.secondary) + Text("No service points") + .font(.title3) + .foregroundColor(.secondary) + } + } else { + List(servicePoints) { sp in + ServicePointRow( + servicePoint: sp, + beacons: beacons, + isAssigning: assigningPointId == sp.id, + onAssignBeacon: { beaconId in + assignBeacon(servicePointId: sp.id, beaconId: beaconId) + } + ) + } + } + } + .navigationTitle("Service Points") + .refreshable { + await withCheckedContinuation { continuation in + loadData { continuation.resume() } + } + } + } + .task { loadData() } + } + + private func loadData(completion: (() -> Void)? = nil) { + isLoading = servicePoints.isEmpty + error = nil + Task { + do { + async let sp = APIService.shared.listServicePoints() + async let b = APIService.shared.listBeacons() + servicePoints = try await sp + beacons = try await b + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + isLoading = false + completion?() + } + } + + private func assignBeacon(servicePointId: Int, beaconId: Int?) { + assigningPointId = servicePointId + Task { + do { + try await APIService.shared.assignBeaconToServicePoint( + servicePointId: servicePointId, + beaconId: beaconId + ) + loadData() + } catch let apiError as APIError where apiError == .unauthorized { + await appState.handleUnauthorized() + } catch { + self.error = error.localizedDescription + } + assigningPointId = nil + } + } +} + +// MARK: - Service Point Row + +struct ServicePointRow: View { + let servicePoint: ServicePoint + let beacons: [Beacon] + let isAssigning: Bool + var onAssignBeacon: (Int?) -> Void + + @State private var showBeaconPicker = false + + private var assignedBeacon: Beacon? { + guard let bid = servicePoint.beaconId else { return nil } + return beacons.first { $0.id == bid } + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(servicePoint.name) + .font(.headline) + if !servicePoint.typeName.isEmpty { + Text(servicePoint.typeName) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + if servicePoint.isActive { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + } else { + Circle() + .fill(.red) + .frame(width: 8, height: 8) + } + } + + // Beacon assignment + HStack { + Image(systemName: "sensor.tag.radiowaves.forward.fill") + .font(.caption) + .foregroundColor(.secondary) + + if isAssigning { + ProgressView() + .controlSize(.small) + } else if let beacon = assignedBeacon { + Text(beacon.name) + .font(.subheadline) + .foregroundColor(.payfritGreen) + } else { + Text("No beacon assigned") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + showBeaconPicker = true + } label: { + Text(assignedBeacon != nil ? "Change" : "Assign") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + + if assignedBeacon != nil { + Button { + onAssignBeacon(nil) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + .font(.caption) + } + .buttonStyle(.plain) + } + } + } + .padding(.vertical, 4) + .sheet(isPresented: $showBeaconPicker) { + BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in + onAssignBeacon(selectedId) + } + } + } +} + +// MARK: - Beacon Picker Sheet + +struct BeaconPickerSheet: View { + let beacons: [Beacon] + let currentBeaconId: Int? + var onSelect: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List(beacons) { beacon in + Button { + onSelect(beacon.id) + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(beacon.name) + .font(.headline) + .foregroundColor(.primary) + Text(beacon.formattedUUID) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if beacon.id == currentBeaconId { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.payfritGreen) + } + } + } + } + .navigationTitle("Select Beacon") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/QA_WALKTHROUGH.md b/QA_WALKTHROUGH.md new file mode 100644 index 0000000..1ce41ba --- /dev/null +++ b/QA_WALKTHROUGH.md @@ -0,0 +1,329 @@ +# Payfrit Beacon iOS — QA Walkthrough Test Document + +## Overview + +**App:** Payfrit Beacon iOS (SwiftUI) +**Purpose:** BLE beacon management for business locations +**Auth:** Token-based with Keychain storage + biometric +**Environment:** Dev (`dev.payfrit.com`) / Prod (`biz.payfrit.com`) — hardcoded in `APIService.swift:50` + +--- + +## Pre-Test Setup + +- [ ] Device has iOS 14+ +- [ ] Bluetooth enabled in Settings +- [ ] Location Services enabled in Settings +- [ ] Stable network connection (Wi-Fi or LTE) +- [ ] A physical BLE beacon available for scanner tests +- [ ] Test account with at least 1 business, 1 beacon, 1 service point + +--- + +## 1. Authentication Flow + +### 1.1 First-Time Login + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Launch app (fresh install) | LoginScreen shown | +| 2 | Leave fields empty, tap "Sign In" | Button disabled (name/password empty) | +| 3 | Enter email only, tap "Sign In" | Error: "Please enter username and password" | +| 4 | Enter valid email + wrong password | Error: "Invalid email/phone or password" | +| 5 | Enter valid credentials, tap "Sign In" | Loading spinner on button, navigates to BusinessSelectionScreen | +| 6 | Kill and relaunch app | Saved auth loaded, skips login | + +### 1.2 Biometric Re-Auth (Device Only, Skipped on Simulator) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Relaunch app with saved auth | Face ID / Touch ID prompt appears | +| 2 | Authenticate successfully | Navigates to BusinessSelectionScreen | +| 3 | Relaunch, cancel biometric | Still loads saved auth (fallback) | + +### 1.3 Logout + +| Step | Action | Expected | +|------|--------|----------| +| 1 | From BusinessSelectionScreen, tap logout (top-right arrow icon) | Clears Keychain + UserDefaults, returns to LoginScreen | +| 2 | Kill and relaunch | LoginScreen shown (no saved auth) | + +### 1.4 Session Expiry (401) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Login, wait for token to expire (or invalidate server-side) | Next API call returns 401 | +| 2 | Try any action (list beacons, etc.) | Auto-logout, returns to LoginScreen | + +### 1.5 Dev Mode Indicator + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Launch in dev environment | LoginScreen shows red "DEV MODE — password: 123456" | +| 2 | After login, check bottom-left of RootView | Orange "DEV" banner visible | + +--- + +## 2. Business Selection + +### 2.1 Load Businesses + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Login successfully | BusinessSelectionScreen loads with spinner | +| 2 | Wait for load | List of businesses with name + city | +| 3 | Tap a business | Pushes to BeaconDashboard, shows "Loading..." then TabView | + +### 2.2 Edge Cases + +| Scenario | Expected | +|----------|----------| +| User has no businesses | "No businesses found" message, can only logout | +| Network error during load | Error message + "Retry" button | +| Tap Retry | Re-fetches business list | +| 401 during load | Auto-logout to LoginScreen | + +--- + +## 3. Beacon Management (Beacons Tab) + +### 3.1 View Beacon List + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Tap "Beacons" tab | List loads with spinner | +| 2 | Verify beacon rows | Each shows: name, formatted UUID (XXXXXXXX-XXXX-...), active badge (green check / red X) | +| 3 | Pull down to refresh | Spinner appears, list refreshes | +| 4 | Empty list | "No beacons yet" + "Tap + to add" message | + +### 3.2 Create Beacon + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Tap + button | BeaconEditSheet modal appears | +| 2 | Leave fields empty, tap Save | Button disabled | +| 3 | Enter name only | Button disabled (UUID required) | +| 4 | Enter name + valid 32-char hex UUID | Save enabled | +| 5 | Tap Save | Modal dismisses, list refreshes with new beacon | +| 6 | Network error during save | Error shown in sheet, sheet stays open | + +### 3.3 Edit Beacon + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Tap a beacon row | Pushes to BeaconDetailScreen | +| 2 | Verify pre-filled fields | Name, UUID, Active toggle match beacon data | +| 3 | Verify read-only fields | ID, Business ID, Created, Updated dates shown | +| 4 | Change name, tap "Save Changes" | Returns to list, beacon updated | +| 5 | Toggle Active off, save | Badge changes from green check to red X | +| 6 | Clear name, try save | Button disabled | + +### 3.4 Delete Beacon (Detail Screen) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | From BeaconDetailScreen, tap "Delete Beacon" | Alert: "Delete Beacon?" with warning about service points | +| 2 | Tap "Cancel" | Nothing happens | +| 3 | Tap "Delete" | API call, returns to list, beacon removed | +| 4 | Network error during delete | Error shown, stays on detail screen | + +### 3.5 Delete Beacon (Swipe) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Swipe left on beacon row | Delete action appears | +| 2 | Tap delete | Beacon removed immediately (optimistic) | +| 3 | If API fails | Beacon re-added to list, error shown | + +### 3.6 Rapid Delete Stress Test + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Swipe-delete 3 beacons quickly | All removed from UI | +| 2 | Wait for API responses | Failed deletes restored, successful ones stay removed | + +--- + +## 4. Service Points (Service Points Tab) + +### 4.1 View Service Points + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Tap "Service Points" tab | List loads (fetches both service points AND beacons) | +| 2 | Verify rows | Name, type, active indicator (green/red circle), beacon assignment | +| 3 | Pull down to refresh | Refreshes both lists | +| 4 | Empty list | "No service points" message | + +### 4.2 Assign Beacon + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Find service point with "No beacon assigned" | "Assign" button visible | +| 2 | Tap "Assign" | BeaconPickerSheet opens with all beacons | +| 3 | Tap a beacon | Sheet dismisses, spinner on row, then beacon name in green | + +### 4.3 Change Beacon Assignment + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Find service point with assigned beacon | "Change" button + X button visible | +| 2 | Tap "Change" | Picker opens, current beacon has checkmark | +| 3 | Select different beacon | Assignment updated | + +### 4.4 Unassign Beacon + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Find service point with assigned beacon | X button visible | +| 2 | Tap X button | API called with nil beaconId, shows "No beacon assigned" | + +### 4.5 Edge Cases + +| Scenario | Expected | +|----------|----------| +| No beacons exist | Picker opens with empty list | +| Network error during assign | Error shown, assignment reverted | +| 401 during any operation | Auto-logout | + +--- + +## 5. Scanner (Scanner Tab) + +### 5.1 Basic Scanning Flow + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Tap "Scanner" tab | Beacon picker loads at top | +| 2 | No beacon selected | "Start Scanning" button disabled | +| 3 | Select a beacon from picker | Button becomes enabled | +| 4 | Tap "Start Scanning" | Status: "Scanning..." (blue antenna icon) | +| 5 | Move close to matching beacon | RSSI value appears, samples count up (X/5) | +| 6 | Stay close for 5+ samples with RSSI >= -75 | "Beacon Detected! (avg -XX dBm)" (green checkmark) | +| 7 | Tap "Stop Scanning" | Status resets to "Select a beacon to scan" | + +### 5.2 Signal Strength Visualization + +| RSSI Range | Bar Color | Signal Quality | +|------------|-----------|---------------| +| -30 to -51 | Green | Strong | +| -52 to -72 | Yellow | Medium | +| -73 to -100 | Red | Weak | + +### 5.3 RSSI Threshold Behavior + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Scanning, RSSI >= -75 | Sample appended, count increments | +| 2 | RSSI drops below -75 | All samples cleared, count resets to 0 | +| 3 | RSSI returns above -75 | Samples start accumulating again from 0 | +| 4 | Beacon disappears entirely (RSSI = 0) | Samples cleared, "Searching for beacon signal..." | + +### 5.4 Permission Handling + +| Scenario | Expected | +|----------|----------| +| Location not determined | System prompt shown, scanning waits | +| Location granted after prompt | Scanning starts automatically | +| Location denied | "Location Permission Denied" (red), scanning stops | +| Location denied in Settings | Same as above on next scan attempt | +| Bluetooth off | "Bluetooth is OFF" (orange), scanning stops | +| Bluetooth turned off mid-scan | Detected within 5 seconds, scanning stops | + +### 5.5 Beacon Change During Scan + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Start scanning for Beacon A | Scanning active | +| 2 | Change picker to Beacon B | Scan stops immediately | +| 3 | Tap "Start Scanning" | New scan starts for Beacon B | + +### 5.6 Screen Lock Prevention + +| Step | Action | Expected | +|------|--------|----------| +| 1 | Start scanning | Screen stays awake (idle timer disabled) | +| 2 | Stop scanning | Idle timer re-enabled, screen can lock normally | + +### 5.7 Error Cases + +| Scenario | Expected | +|----------|----------| +| Invalid UUID format on beacon | Error: "Invalid beacon UUID format" | +| Ranging failure (CLLocationManager error) | Error: "Beacon ranging failed: [description]" | +| Beacon list fails to load | Picker empty, scanner still functional if UUID known | + +--- + +## 6. Cross-Cutting Concerns + +### 6.1 Network Error Handling (All Screens) + +| Scenario | Expected | +|----------|----------| +| Airplane mode during API call | Network error displayed | +| Server returns 500 | "HTTP 500" error shown | +| Server returns non-JSON | "Decoding error: Non-JSON response" | +| Server returns `{"OK": false, "ERROR": "..."}` | Error message from server shown | +| 401 on any authenticated endpoint | Auto-logout to LoginScreen | + +### 6.2 Navigation + +| Test | Expected | +|------|----------| +| Back button from BeaconDetailScreen | Returns to BeaconListScreen | +| Back button from BeaconDashboard | Returns to BusinessSelectionScreen | +| Tab switching in BeaconDashboard | Beacons / Service Points / Scanner tabs all functional | +| Deep link: Business > Beacon > Detail > Back > Back | Full nav stack unwinds cleanly | + +### 6.3 Data Consistency + +| Test | Expected | +|------|----------| +| Add beacon, switch to Service Points tab | New beacon available in picker | +| Delete beacon assigned to service point | Service point shows "No beacon assigned" on refresh | +| Edit beacon name | Updated name shows in list and service point rows | + +--- + +## 7. API Endpoints Reference + +| Action | Method | Endpoint | +|--------|--------|----------| +| Login | POST | `/auth/login.cfm` | +| List businesses | POST | `/workers/myBusinesses.cfm` | +| List beacons | POST | `/beacons/list.cfm` | +| Get beacon | POST | `/beacons/get.cfm` | +| Create beacon | POST | `/beacons/create.cfm` | +| Update beacon | POST | `/beacons/update.cfm` | +| Delete beacon | POST | `/beacons/delete.cfm` | +| List service points | POST | `/servicePoints/list.cfm` | +| List SP types | POST | `/servicePoints/types.cfm` | +| Assign beacon to SP | POST | `/servicePoints/assignBeacon.cfm` | + +All requests include headers: +- `Content-Type: application/json` +- `X-User-Token: ` (after login) +- `X-Business-ID: ` (after business selection) + +--- + +## 8. Permissions Checklist + +| Permission | Info.plist Key | When Prompted | +|-----------|----------------|---------------| +| Location (When In Use) | `NSLocationWhenInUseUsageDescription` | First beacon scan | +| Location (Always) | `NSLocationAlwaysAndWhenInUseUsageDescription` | Background scanning | +| Bluetooth | `NSBluetoothAlwaysUsageDescription` | First beacon scan | +| Face ID | `NSFaceIDUsageDescription` | App relaunch with saved auth | + +--- + +## 9. Known Limitations + +1. **Environment switching** requires code change + recompile (`APIService.swift:50`) +2. **Service points are read-only** — can only assign/unassign beacons, not create/edit/delete SPs +3. **No token refresh** — expired tokens force full re-login +4. **Single scanner** — only one scan at a time, changing beacon stops previous +5. **Background scanning** depends on iOS version and background mode support +6. **Photo URLs** resolved but not cached across sessions