Initial commit: Payfrit Beacon iOS native app
This commit is contained in:
commit
4faec5499d
25 changed files with 3232 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
*~
|
||||
468
PayfritBeacon.xcodeproj/project.pbxproj
Normal file
468
PayfritBeacon.xcodeproj/project.pbxproj
Normal file
|
|
@ -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 = "<group>"; };
|
||||
C02000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
|
||||
/* Models */
|
||||
C02000000010 /* Beacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Beacon.swift; sourceTree = "<group>"; };
|
||||
C02000000011 /* ServicePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePoint.swift; sourceTree = "<group>"; };
|
||||
C02000000012 /* Employment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Employment.swift; sourceTree = "<group>"; };
|
||||
|
||||
/* ViewModels */
|
||||
C02000000020 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
|
||||
/* Services */
|
||||
C02000000030 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
|
||||
C02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; };
|
||||
C02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
|
||||
|
||||
/* Views */
|
||||
C02000000040 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
C02000000041 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
|
||||
C02000000042 /* BusinessSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessSelectionScreen.swift; sourceTree = "<group>"; };
|
||||
C02000000043 /* BeaconDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDashboard.swift; sourceTree = "<group>"; };
|
||||
C02000000044 /* BeaconListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconListScreen.swift; sourceTree = "<group>"; };
|
||||
C02000000045 /* BeaconDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconDetailScreen.swift; sourceTree = "<group>"; };
|
||||
C02000000046 /* BeaconEditSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconEditSheet.swift; sourceTree = "<group>"; };
|
||||
C02000000047 /* ServicePointListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicePointListScreen.swift; sourceTree = "<group>"; };
|
||||
C02000000048 /* ScannerScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerScreen.swift; sourceTree = "<group>"; };
|
||||
|
||||
/* Resources */
|
||||
C02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
C05000000002 /* PayfritBeacon */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000001 /* PayfritBeaconApp.swift */,
|
||||
C02000000002 /* Info.plist */,
|
||||
C05000000003 /* Models */,
|
||||
C05000000004 /* ViewModels */,
|
||||
C05000000005 /* Services */,
|
||||
C05000000006 /* Views */,
|
||||
C05000000007 /* Resources */,
|
||||
);
|
||||
path = PayfritBeacon;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000003 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000010 /* Beacon.swift */,
|
||||
C02000000011 /* ServicePoint.swift */,
|
||||
C02000000012 /* Employment.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000004 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000020 /* AppState.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000005 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000030 /* APIService.swift */,
|
||||
C02000000031 /* AuthStorage.swift */,
|
||||
C02000000032 /* BeaconScanner.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000006 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000040 /* RootView.swift */,
|
||||
C02000000041 /* LoginScreen.swift */,
|
||||
C02000000042 /* BusinessSelectionScreen.swift */,
|
||||
C02000000043 /* BeaconDashboard.swift */,
|
||||
C02000000044 /* BeaconListScreen.swift */,
|
||||
C02000000045 /* BeaconDetailScreen.swift */,
|
||||
C02000000046 /* BeaconEditSheet.swift */,
|
||||
C02000000047 /* ServicePointListScreen.swift */,
|
||||
C02000000048 /* ScannerScreen.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000007 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C02000000060 /* Assets.xcassets */,
|
||||
);
|
||||
name = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05000000009 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C03000000001 /* PayfritBeacon.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C06000000001"
|
||||
BuildableName = "PayfritBeacon.app"
|
||||
BlueprintName = "PayfritBeacon"
|
||||
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C06000000001"
|
||||
BuildableName = "PayfritBeacon.app"
|
||||
BlueprintName = "PayfritBeacon"
|
||||
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C06000000001"
|
||||
BuildableName = "PayfritBeacon.app"
|
||||
BlueprintName = "PayfritBeacon"
|
||||
ReferencedContainer = "container:PayfritBeacon.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
PayfritBeacon/Assets.xcassets/Contents.json
Normal file
6
PayfritBeacon/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
40
PayfritBeacon/Info.plist
Normal file
40
PayfritBeacon/Info.plist
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Payfrit Beacon uses your location to detect nearby BLE beacons.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Payfrit Beacon uses your location to detect nearby BLE beacons.</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>Payfrit Beacon uses Bluetooth to scan for and manage nearby beacons.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Payfrit Beacon uses Face ID for quick sign-in.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
<string>location</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
68
PayfritBeacon/Models/Beacon.swift
Normal file
68
PayfritBeacon/Models/Beacon.swift
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import Foundation
|
||||
|
||||
struct Beacon: Identifiable {
|
||||
let id: Int
|
||||
let businessId: Int
|
||||
let name: String
|
||||
let uuid: String
|
||||
let namespaceId: String
|
||||
let instanceId: String
|
||||
let isActive: Bool
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
|
||||
init(json: [String: Any]) {
|
||||
id = Self.parseInt(json["ID"] ?? json["BeaconID"]) ?? 0
|
||||
businessId = Self.parseInt(json["BusinessID"]) ?? 0
|
||||
name = (json["Name"] as? String) ?? (json["BeaconName"] as? String) ?? ""
|
||||
uuid = (json["UUID"] as? String) ?? (json["BeaconUUID"] as? String) ?? ""
|
||||
namespaceId = (json["NamespaceId"] as? String) ?? ""
|
||||
instanceId = (json["InstanceId"] as? String) ?? ""
|
||||
isActive = Self.parseBool(json["IsActive"]) ?? true
|
||||
createdAt = Self.parseDate(json["CreatedAt"])
|
||||
updatedAt = Self.parseDate(json["UpdatedAt"])
|
||||
}
|
||||
|
||||
/// Format the raw 32-char hex UUID into standard 8-4-4-4-12 format
|
||||
var formattedUUID: String {
|
||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard clean.count == 32 else { return uuid }
|
||||
let i = clean.startIndex
|
||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
||||
}
|
||||
|
||||
// MARK: - Parse Helpers
|
||||
|
||||
static func parseInt(_ value: Any?) -> Int? {
|
||||
guard let value = value else { return nil }
|
||||
if let v = value as? Int { return v }
|
||||
if let v = value as? Double { return Int(v) }
|
||||
if let v = value as? NSNumber { return v.intValue }
|
||||
if let v = value as? String, let i = Int(v) { return i }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parseBool(_ value: Any?) -> Bool? {
|
||||
guard let value = value else { return nil }
|
||||
if let b = value as? Bool { return b }
|
||||
if let i = value as? Int { return i == 1 }
|
||||
if let s = value as? String {
|
||||
let lower = s.lowercased()
|
||||
if lower == "true" || lower == "1" || lower == "yes" { return true }
|
||||
if lower == "false" || lower == "0" || lower == "no" { return false }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parseDate(_ value: Any?) -> Date? {
|
||||
guard let value = value else { return nil }
|
||||
if let d = value as? Date { return d }
|
||||
if let s = value as? String { return APIService.parseDate(s) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
32
PayfritBeacon/Models/Employment.swift
Normal file
32
PayfritBeacon/Models/Employment.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import Foundation
|
||||
|
||||
struct Employment: Identifiable {
|
||||
/// Composite ID to avoid collisions when same employee works at multiple businesses
|
||||
var id: String { "\(employeeId)-\(businessId)" }
|
||||
let employeeId: Int
|
||||
let businessId: Int
|
||||
let businessName: String
|
||||
let businessAddress: String
|
||||
let businessCity: String
|
||||
let employeeStatusId: Int
|
||||
let pendingTaskCount: Int
|
||||
|
||||
var statusName: String {
|
||||
switch employeeStatusId {
|
||||
case 1: return "Active"
|
||||
case 2: return "Suspended"
|
||||
case 3: return "Terminated"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
init(json: [String: Any]) {
|
||||
employeeId = Beacon.parseInt(json["EmployeeID"]) ?? 0
|
||||
businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
|
||||
businessName = (json["BusinessName"] as? String) ?? (json["Name"] as? String) ?? ""
|
||||
businessAddress = (json["BusinessAddress"] as? String) ?? (json["Address"] as? String) ?? ""
|
||||
businessCity = (json["BusinessCity"] as? String) ?? (json["City"] as? String) ?? ""
|
||||
employeeStatusId = Beacon.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0
|
||||
pendingTaskCount = Beacon.parseInt(json["PendingTaskCount"]) ?? 0
|
||||
}
|
||||
}
|
||||
47
PayfritBeacon/Models/ServicePoint.swift
Normal file
47
PayfritBeacon/Models/ServicePoint.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Foundation
|
||||
|
||||
struct ServicePoint: Identifiable {
|
||||
let id: Int
|
||||
let businessId: Int
|
||||
let name: String
|
||||
let typeId: Int
|
||||
let typeName: String
|
||||
let code: String
|
||||
let description: String
|
||||
let sortOrder: Int
|
||||
let isActive: Bool
|
||||
let isClosedToNewMembers: Bool
|
||||
let beaconId: Int?
|
||||
let assignedByUserId: Int?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
|
||||
init(json: [String: Any]) {
|
||||
id = Beacon.parseInt(json["ID"] ?? json["ServicePointID"]) ?? 0
|
||||
businessId = Beacon.parseInt(json["BusinessID"]) ?? 0
|
||||
name = (json["Name"] as? String) ?? (json["ServicePointName"] as? String) ?? ""
|
||||
typeId = Beacon.parseInt(json["TypeID"] ?? json["ServicePointTypeID"]) ?? 0
|
||||
typeName = (json["TypeName"] as? String) ?? ""
|
||||
code = (json["Code"] as? String) ?? ""
|
||||
description = (json["Description"] as? String) ?? ""
|
||||
sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
|
||||
isActive = Beacon.parseBool(json["IsActive"]) ?? true
|
||||
isClosedToNewMembers = Beacon.parseBool(json["IsClosedToNewMembers"]) ?? false
|
||||
beaconId = Beacon.parseInt(json["BeaconID"])
|
||||
assignedByUserId = Beacon.parseInt(json["AssignedByUserID"])
|
||||
createdAt = Beacon.parseDate(json["CreatedAt"])
|
||||
updatedAt = Beacon.parseDate(json["UpdatedAt"])
|
||||
}
|
||||
}
|
||||
|
||||
struct ServicePointType: Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let sortOrder: Int
|
||||
|
||||
init(json: [String: Any]) {
|
||||
id = Beacon.parseInt(json["ID"]) ?? 0
|
||||
name = (json["Name"] as? String) ?? ""
|
||||
sortOrder = Beacon.parseInt(json["SortOrder"]) ?? 0
|
||||
}
|
||||
}
|
||||
17
PayfritBeacon/PayfritBeaconApp.swift
Normal file
17
PayfritBeacon/PayfritBeaconApp.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct PayfritBeaconApp: App {
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let payfritGreen = Color(red: 0.133, green: 0.698, blue: 0.294)
|
||||
}
|
||||
416
PayfritBeacon/Services/APIService.swift
Normal file
416
PayfritBeacon/Services/APIService.swift
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - API Errors
|
||||
|
||||
enum APIError: LocalizedError, Equatable {
|
||||
case invalidURL
|
||||
case noData
|
||||
case decodingError(String)
|
||||
case serverError(String)
|
||||
case unauthorized
|
||||
case networkError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .noData: return "No data received"
|
||||
case .decodingError(let msg): return "Decoding error: \(msg)"
|
||||
case .serverError(let msg): return msg
|
||||
case .unauthorized: return "Unauthorized"
|
||||
case .networkError(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login Response
|
||||
|
||||
struct LoginResponse {
|
||||
let userId: Int
|
||||
let userFirstName: String
|
||||
let token: String
|
||||
let photoUrl: String
|
||||
}
|
||||
|
||||
// MARK: - API Service
|
||||
|
||||
actor APIService {
|
||||
static let shared = APIService()
|
||||
|
||||
private enum Environment {
|
||||
case development, production
|
||||
|
||||
var baseURL: String {
|
||||
switch self {
|
||||
case .development: return "https://dev.payfrit.com/api"
|
||||
case .production: return "https://biz.payfrit.com/api"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let environment: Environment = .development
|
||||
var isDev: Bool { environment == .development }
|
||||
private var userToken: String?
|
||||
private var userId: Int?
|
||||
private var businessId: Int = 0
|
||||
|
||||
var baseURL: String { environment.baseURL }
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func setAuth(token: String?, userId: Int?) {
|
||||
self.userToken = token
|
||||
self.userId = userId
|
||||
}
|
||||
|
||||
func setBusinessId(_ id: Int) {
|
||||
self.businessId = id
|
||||
}
|
||||
|
||||
func getToken() -> String? { userToken }
|
||||
func getUserId() -> Int? { userId }
|
||||
func getBusinessId() -> Int { businessId }
|
||||
|
||||
// MARK: - Core Request
|
||||
|
||||
private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
|
||||
let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
||||
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token = userToken, !token.isEmpty {
|
||||
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
||||
}
|
||||
if businessId > 0 {
|
||||
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
||||
}
|
||||
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
if let json = tryDecodeJSON(data) {
|
||||
return json
|
||||
}
|
||||
throw APIError.decodingError("Non-JSON response")
|
||||
}
|
||||
|
||||
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return json
|
||||
}
|
||||
guard let body = String(data: data, encoding: .utf8),
|
||||
let start = body.firstIndex(of: "{"),
|
||||
let end = body.lastIndex(of: "}") else { return nil }
|
||||
let jsonStr = String(body[start...end])
|
||||
guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
|
||||
}
|
||||
|
||||
private func ok(_ json: [String: Any]) -> Bool {
|
||||
for key in ["OK", "ok", "Ok"] {
|
||||
if let b = json[key] as? Bool { return b }
|
||||
if let i = json[key] as? Int { return i == 1 }
|
||||
if let s = json[key] as? String {
|
||||
let lower = s.lowercased()
|
||||
return lower == "true" || lower == "1" || lower == "yes"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func err(_ json: [String: Any]) -> String {
|
||||
let msg = (json["ERROR"] as? String) ?? (json["error"] as? String)
|
||||
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
|
||||
return msg.isEmpty ? "Unknown error" : msg
|
||||
}
|
||||
|
||||
nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
|
||||
for key in keys {
|
||||
if let arr = json[key] as? [[String: Any]] { return arr }
|
||||
}
|
||||
for (_, value) in json {
|
||||
if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
func login(username: String, password: String) async throws -> LoginResponse {
|
||||
let json = try await postJSON("/auth/login.cfm", payload: [
|
||||
"username": username,
|
||||
"password": password
|
||||
])
|
||||
|
||||
guard ok(json) else {
|
||||
let e = err(json)
|
||||
if e == "bad_credentials" {
|
||||
throw APIError.serverError("Invalid email/phone or password")
|
||||
}
|
||||
throw APIError.serverError("Login failed: \(e)")
|
||||
}
|
||||
|
||||
let uid = (json["UserID"] as? Int)
|
||||
?? Int(json["UserID"] as? String ?? "")
|
||||
?? (json["UserId"] as? Int)
|
||||
?? 0
|
||||
let token = (json["Token"] as? String)
|
||||
?? (json["token"] as? String)
|
||||
?? ""
|
||||
|
||||
guard uid > 0 else {
|
||||
throw APIError.serverError("Login failed: no user ID returned")
|
||||
}
|
||||
guard !token.isEmpty else {
|
||||
throw APIError.serverError("Login failed: no token returned")
|
||||
}
|
||||
|
||||
let firstName = (json["UserFirstName"] as? String)
|
||||
?? (json["FirstName"] as? String)
|
||||
?? (json["firstName"] as? String)
|
||||
?? (json["Name"] as? String)
|
||||
?? (json["name"] as? String)
|
||||
?? ""
|
||||
let photoUrl = (json["UserPhotoUrl"] as? String)
|
||||
?? (json["PhotoUrl"] as? String)
|
||||
?? (json["photoUrl"] as? String)
|
||||
?? ""
|
||||
|
||||
self.userToken = token
|
||||
self.userId = uid
|
||||
|
||||
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
||||
}
|
||||
|
||||
func logout() {
|
||||
userToken = nil
|
||||
userId = nil
|
||||
businessId = 0
|
||||
}
|
||||
|
||||
// MARK: - Businesses
|
||||
|
||||
func getMyBusinesses() async throws -> [Employment] {
|
||||
guard let uid = userId, uid > 0 else {
|
||||
throw APIError.serverError("User not logged in")
|
||||
}
|
||||
|
||||
let json = try await postJSON("/workers/myBusinesses.cfm", payload: [
|
||||
"UserID": uid
|
||||
])
|
||||
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to load businesses: \(err(json))")
|
||||
}
|
||||
|
||||
guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
|
||||
return []
|
||||
}
|
||||
return arr.map { Employment(json: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Beacons
|
||||
|
||||
func listBeacons() async throws -> [Beacon] {
|
||||
let json = try await postJSON("/beacons/list.cfm", payload: [
|
||||
"BusinessID": businessId
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to load beacons: \(err(json))")
|
||||
}
|
||||
guard let arr = Self.findArray(json, ["BEACONS", "Beacons", "beacons"]) else { return [] }
|
||||
return arr.map { Beacon(json: $0) }
|
||||
}
|
||||
|
||||
func getBeacon(beaconId: Int) async throws -> Beacon {
|
||||
let json = try await postJSON("/beacons/get.cfm", payload: [
|
||||
"BeaconID": beaconId,
|
||||
"BusinessID": businessId
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to load beacon: \(err(json))")
|
||||
}
|
||||
var beaconJson: [String: Any]?
|
||||
for key in ["BEACON", "Beacon", "beacon"] {
|
||||
if let d = json[key] as? [String: Any] { beaconJson = d; break }
|
||||
}
|
||||
if beaconJson == nil {
|
||||
for (_, value) in json {
|
||||
if let d = value as? [String: Any], d.count > 3 { beaconJson = d; break }
|
||||
}
|
||||
}
|
||||
guard let beaconJson = beaconJson else {
|
||||
throw APIError.serverError("Invalid beacon response")
|
||||
}
|
||||
return Beacon(json: beaconJson)
|
||||
}
|
||||
|
||||
func createBeacon(name: String, uuid: String) async throws -> Int {
|
||||
let json = try await postJSON("/beacons/create.cfm", payload: [
|
||||
"BusinessID": businessId,
|
||||
"Name": name,
|
||||
"UUID": uuid
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to create beacon: \(err(json))")
|
||||
}
|
||||
return (json["BeaconID"] as? Int)
|
||||
?? (json["ID"] as? Int)
|
||||
?? Int(json["BeaconID"] as? String ?? "")
|
||||
?? 0
|
||||
}
|
||||
|
||||
func updateBeacon(beaconId: Int, name: String, uuid: String, isActive: Bool) async throws {
|
||||
let json = try await postJSON("/beacons/update.cfm", payload: [
|
||||
"BeaconID": beaconId,
|
||||
"BusinessID": businessId,
|
||||
"Name": name,
|
||||
"UUID": uuid,
|
||||
"IsActive": isActive
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to update beacon: \(err(json))")
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBeacon(beaconId: Int) async throws {
|
||||
let json = try await postJSON("/beacons/delete.cfm", payload: [
|
||||
"BeaconID": beaconId,
|
||||
"BusinessID": businessId
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to delete beacon: \(err(json))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Points
|
||||
|
||||
func listServicePoints() async throws -> [ServicePoint] {
|
||||
let json = try await postJSON("/servicePoints/list.cfm", payload: [
|
||||
"BusinessID": businessId
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to load service points: \(err(json))")
|
||||
}
|
||||
guard let arr = Self.findArray(json, ["SERVICE_POINTS", "ServicePoints", "servicePoints", "SERVICEPOINTS"]) else { return [] }
|
||||
return arr.map { ServicePoint(json: $0) }
|
||||
}
|
||||
|
||||
func assignBeaconToServicePoint(servicePointId: Int, beaconId: Int?) async throws {
|
||||
var payload: [String: Any] = [
|
||||
"ServicePointID": servicePointId,
|
||||
"BusinessID": businessId
|
||||
]
|
||||
if let bid = beaconId {
|
||||
payload["BeaconID"] = bid
|
||||
}
|
||||
let json = try await postJSON("/servicePoints/assignBeacon.cfm", payload: payload)
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to assign beacon: \(err(json))")
|
||||
}
|
||||
}
|
||||
|
||||
func listServicePointTypes() async throws -> [ServicePointType] {
|
||||
let json = try await postJSON("/servicePoints/types.cfm", payload: [
|
||||
"BusinessID": businessId
|
||||
])
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to load service point types: \(err(json))")
|
||||
}
|
||||
guard let arr = Self.findArray(json, ["TYPES", "Types", "types"]) else { return [] }
|
||||
return arr.map { ServicePointType(json: $0) }
|
||||
}
|
||||
|
||||
// MARK: - URL Helpers
|
||||
|
||||
func resolvePhotoUrl(_ rawUrl: String) -> String {
|
||||
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "" }
|
||||
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
|
||||
let baseDomain = environment == .development ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
||||
if trimmed.hasPrefix("/") { return baseDomain + trimmed }
|
||||
return baseDomain + "/" + trimmed
|
||||
}
|
||||
|
||||
// MARK: - Date Parsing
|
||||
|
||||
private static let iso8601Formatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let iso8601NoFrac: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let simpleDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let cfmlDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let cfmlShortFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let cfmlAltFormatters: [DateFormatter] = {
|
||||
let formats = [
|
||||
"MMM dd, yyyy HH:mm:ss",
|
||||
"MM/dd/yyyy HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm:ss.S",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.S",
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SZ",
|
||||
]
|
||||
return formats.map { fmt in
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = fmt
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}
|
||||
}()
|
||||
|
||||
nonisolated static func parseDate(_ string: String) -> Date? {
|
||||
let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.isEmpty { return nil }
|
||||
|
||||
if let epoch = Double(s) {
|
||||
if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
|
||||
if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
|
||||
}
|
||||
|
||||
if let d = iso8601Formatter.date(from: s) { return d }
|
||||
if let d = iso8601NoFrac.date(from: s) { return d }
|
||||
if let d = simpleDateFormatter.date(from: s) { return d }
|
||||
if let d = cfmlDateFormatter.date(from: s) { return d }
|
||||
if let d = cfmlShortFormatter.date(from: s) { return d }
|
||||
for formatter in cfmlAltFormatters {
|
||||
if let d = formatter.date(from: s) { return d }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
95
PayfritBeacon/Services/AuthStorage.swift
Normal file
95
PayfritBeacon/Services/AuthStorage.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
struct AuthCredentials {
|
||||
let userId: Int
|
||||
let token: String
|
||||
let userName: String?
|
||||
let photoUrl: String?
|
||||
}
|
||||
|
||||
actor AuthStorage {
|
||||
static let shared = AuthStorage()
|
||||
|
||||
private let userIdKey = "payfrit_beacon_user_id"
|
||||
private let userNameKey = "payfrit_beacon_user_name"
|
||||
private let userPhotoKey = "payfrit_beacon_user_photo"
|
||||
private let serviceName = "com.payfrit.beacon"
|
||||
private let tokenAccount = "auth_token"
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
func saveAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
|
||||
UserDefaults.standard.set(userId, forKey: userIdKey)
|
||||
// Always overwrite name/photo to prevent stale data from previous user
|
||||
if let name = userName, !name.isEmpty {
|
||||
UserDefaults.standard.set(name, forKey: userNameKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: userNameKey)
|
||||
}
|
||||
if let photo = photoUrl, !photo.isEmpty {
|
||||
UserDefaults.standard.set(photo, forKey: userPhotoKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: userPhotoKey)
|
||||
}
|
||||
saveToKeychain(token)
|
||||
}
|
||||
|
||||
// MARK: - Load
|
||||
|
||||
func loadAuth() -> AuthCredentials? {
|
||||
let userId = UserDefaults.standard.integer(forKey: userIdKey)
|
||||
guard userId > 0 else { return nil }
|
||||
guard let token = loadFromKeychain(), !token.isEmpty else { return nil }
|
||||
let userName = UserDefaults.standard.string(forKey: userNameKey)
|
||||
let photoUrl = UserDefaults.standard.string(forKey: userPhotoKey)
|
||||
return AuthCredentials(userId: userId, token: token, userName: userName, photoUrl: photoUrl)
|
||||
}
|
||||
|
||||
// MARK: - Clear
|
||||
|
||||
func clearAuth() {
|
||||
UserDefaults.standard.removeObject(forKey: userIdKey)
|
||||
UserDefaults.standard.removeObject(forKey: userNameKey)
|
||||
UserDefaults.standard.removeObject(forKey: userPhotoKey)
|
||||
deleteFromKeychain()
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func saveToKeychain(_ token: String) {
|
||||
deleteFromKeychain()
|
||||
guard let data = token.data(using: .utf8) else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: tokenAccount,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
]
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadFromKeychain() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: tokenAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func deleteFromKeychain() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: tokenAccount
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
262
PayfritBeacon/Services/BeaconScanner.swift
Normal file
262
PayfritBeacon/Services/BeaconScanner.swift
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import UIKit
|
||||
import CoreBluetooth
|
||||
import CoreLocation
|
||||
|
||||
/// Beacon scanner for detecting BLE beacons by UUID.
|
||||
/// Uses CoreLocation iBeacon ranging with RSSI-based dwell time enforcement.
|
||||
/// All mutable state is confined to the main thread via @MainActor.
|
||||
@MainActor
|
||||
final class BeaconScanner: NSObject, ObservableObject {
|
||||
private let targetUUID: String
|
||||
private let normalizedTargetUUID: String
|
||||
private let onBeaconDetected: (Double) -> Void
|
||||
private let onRSSIUpdate: ((Int, Int) -> Void)?
|
||||
private let onBluetoothOff: (() -> Void)?
|
||||
private let onPermissionDenied: (() -> Void)?
|
||||
private let onError: ((String) -> Void)?
|
||||
|
||||
@Published var isScanning = false
|
||||
|
||||
private var locationManager: CLLocationManager?
|
||||
private var activeConstraint: CLBeaconIdentityConstraint?
|
||||
private var checkTimer: Timer?
|
||||
private var bluetoothManager: CBCentralManager?
|
||||
|
||||
// RSSI samples for dwell time enforcement
|
||||
private var rssiSamples: [Int] = []
|
||||
private let minSamplesToConfirm = 5 // ~5 seconds
|
||||
private let rssiThreshold = -75
|
||||
private var hasConfirmed = false
|
||||
private var isPendingPermission = false
|
||||
|
||||
init(targetUUID: String,
|
||||
onBeaconDetected: @escaping (Double) -> Void,
|
||||
onRSSIUpdate: ((Int, Int) -> Void)? = nil,
|
||||
onBluetoothOff: (() -> Void)? = nil,
|
||||
onPermissionDenied: (() -> Void)? = nil,
|
||||
onError: ((String) -> Void)? = nil) {
|
||||
self.targetUUID = targetUUID
|
||||
self.normalizedTargetUUID = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
self.onBeaconDetected = onBeaconDetected
|
||||
self.onRSSIUpdate = onRSSIUpdate
|
||||
self.onBluetoothOff = onBluetoothOff
|
||||
self.onPermissionDenied = onPermissionDenied
|
||||
self.onError = onError
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - UUID formatting
|
||||
|
||||
private nonisolated func formatUUID(_ uuid: String) -> String {
|
||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard clean.count == 32 else { return uuid }
|
||||
let i = clean.startIndex
|
||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
||||
}
|
||||
|
||||
// MARK: - Start/Stop
|
||||
|
||||
func startScanning() {
|
||||
guard !isScanning else { return }
|
||||
|
||||
let formatted = formatUUID(targetUUID)
|
||||
guard let uuid = UUID(uuidString: formatted) else {
|
||||
onError?("Invalid beacon UUID format")
|
||||
return
|
||||
}
|
||||
|
||||
let lm = CLLocationManager()
|
||||
lm.delegate = self
|
||||
locationManager = lm
|
||||
|
||||
let status = lm.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
isPendingPermission = true
|
||||
lm.requestWhenInUseAuthorization()
|
||||
// Delegate will call locationManagerDidChangeAuthorization
|
||||
return
|
||||
}
|
||||
|
||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||
onPermissionDenied?()
|
||||
return
|
||||
}
|
||||
|
||||
beginRanging(uuid: uuid)
|
||||
}
|
||||
|
||||
private func beginRanging(uuid: UUID) {
|
||||
guard let lm = locationManager else { return }
|
||||
|
||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||
activeConstraint = constraint
|
||||
lm.startRangingBeacons(satisfying: constraint)
|
||||
|
||||
isScanning = true
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
|
||||
// Monitor Bluetooth power state with a real CBCentralManager
|
||||
bluetoothManager = CBCentralManager(delegate: self, queue: .main)
|
||||
|
||||
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.checkBluetoothState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBluetoothState() {
|
||||
if let bm = bluetoothManager, bm.state == .poweredOff {
|
||||
stopScanning()
|
||||
onBluetoothOff?()
|
||||
}
|
||||
if CBCentralManager.authorization == .denied ||
|
||||
CBCentralManager.authorization == .restricted {
|
||||
stopScanning()
|
||||
onBluetoothOff?()
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
isPendingPermission = false
|
||||
guard isScanning else { return }
|
||||
isScanning = false
|
||||
if let constraint = activeConstraint {
|
||||
locationManager?.stopRangingBeacons(satisfying: constraint)
|
||||
}
|
||||
activeConstraint = nil
|
||||
checkTimer?.invalidate()
|
||||
checkTimer = nil
|
||||
bluetoothManager = nil
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
|
||||
func resetSamples() {
|
||||
rssiSamples.removeAll()
|
||||
hasConfirmed = false
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
stopScanning()
|
||||
locationManager?.delegate = nil
|
||||
locationManager = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Safety net: clean up resources
|
||||
checkTimer?.invalidate()
|
||||
locationManager?.delegate = nil
|
||||
Task { @MainActor in
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate handling (called on main thread, forwarded from nonisolated delegate)
|
||||
|
||||
fileprivate func handleRangedBeacons(_ beacons: [CLBeacon]) {
|
||||
guard isScanning, !hasConfirmed else { return }
|
||||
|
||||
var foundThisCycle = false
|
||||
|
||||
for beacon in beacons {
|
||||
let rssi = beacon.rssi
|
||||
guard rssi != 0 else { continue }
|
||||
|
||||
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard detectedUUID == normalizedTargetUUID else { continue }
|
||||
|
||||
foundThisCycle = true
|
||||
|
||||
if rssi >= rssiThreshold {
|
||||
rssiSamples.append(rssi)
|
||||
onRSSIUpdate?(rssi, rssiSamples.count)
|
||||
|
||||
if rssiSamples.count >= minSamplesToConfirm {
|
||||
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
||||
hasConfirmed = true
|
||||
onBeaconDetected(avg)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !rssiSamples.isEmpty {
|
||||
rssiSamples.removeAll()
|
||||
onRSSIUpdate?(rssi, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundThisCycle && !rssiSamples.isEmpty {
|
||||
rssiSamples.removeAll()
|
||||
onRSSIUpdate?(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func handleRangingError(_ error: Error) {
|
||||
onError?("Beacon ranging failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
fileprivate func handleAuthorizationChange(_ status: CLAuthorizationStatus) {
|
||||
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
||||
// Permission granted — start ranging only if we were waiting for permission
|
||||
if isPendingPermission && !isScanning {
|
||||
isPendingPermission = false
|
||||
let formatted = formatUUID(targetUUID)
|
||||
if let uuid = UUID(uuidString: formatted) {
|
||||
beginRanging(uuid: uuid)
|
||||
}
|
||||
}
|
||||
} else if status == .denied || status == .restricted {
|
||||
isPendingPermission = false
|
||||
stopScanning()
|
||||
onPermissionDenied?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
// These delegate callbacks arrive on the main thread since CLLocationManager was created on main.
|
||||
// We forward to @MainActor methods above.
|
||||
|
||||
extension BeaconScanner: CLLocationManagerDelegate {
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
||||
satisfying constraint: CLBeaconIdentityConstraint) {
|
||||
Task { @MainActor in
|
||||
self.handleRangedBeacons(beacons)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
||||
Task { @MainActor in
|
||||
self.handleRangingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
Task { @MainActor in
|
||||
self.handleAuthorizationChange(manager.authorizationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension BeaconScanner: CBCentralManagerDelegate {
|
||||
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
Task { @MainActor in
|
||||
if central.state == .poweredOff {
|
||||
self.stopScanning()
|
||||
self.onBluetoothOff?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
PayfritBeacon/ViewModels/AppState.swift
Normal file
49
PayfritBeacon/ViewModels/AppState.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppState: ObservableObject {
|
||||
@Published var userId: Int?
|
||||
@Published var userName: String?
|
||||
@Published var userPhotoUrl: String?
|
||||
@Published var userToken: String?
|
||||
@Published var businessId: Int = 0
|
||||
@Published var businessName: String = ""
|
||||
@Published var isAuthenticated = false
|
||||
|
||||
func setAuth(userId: Int, token: String, userName: String? = nil, photoUrl: String? = nil) {
|
||||
self.userId = userId
|
||||
self.userToken = token
|
||||
self.userName = userName
|
||||
self.userPhotoUrl = photoUrl
|
||||
self.isAuthenticated = true
|
||||
}
|
||||
|
||||
func setBusiness(id: Int, name: String) {
|
||||
self.businessId = id
|
||||
self.businessName = name
|
||||
}
|
||||
|
||||
func clearAuth() {
|
||||
userId = nil
|
||||
userToken = nil
|
||||
userName = nil
|
||||
userPhotoUrl = nil
|
||||
isAuthenticated = false
|
||||
businessId = 0
|
||||
businessName = ""
|
||||
}
|
||||
|
||||
/// Handle 401 unauthorized — clear everything and force re-login
|
||||
func handleUnauthorized() async {
|
||||
await AuthStorage.shared.clearAuth()
|
||||
await APIService.shared.logout()
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
func loadSavedAuth() async {
|
||||
let creds = await AuthStorage.shared.loadAuth()
|
||||
guard let creds = creds else { return }
|
||||
await APIService.shared.setAuth(token: creds.token, userId: creds.userId)
|
||||
setAuth(userId: creds.userId, token: creds.token, userName: creds.userName, photoUrl: creds.photoUrl)
|
||||
}
|
||||
}
|
||||
39
PayfritBeacon/Views/BeaconDashboard.swift
Normal file
39
PayfritBeacon/Views/BeaconDashboard.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BeaconDashboard: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let business: Employment
|
||||
@State private var isReady = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isReady {
|
||||
TabView {
|
||||
BeaconListScreen()
|
||||
.tabItem {
|
||||
Label("Beacons", systemImage: "sensor.tag.radiowaves.forward.fill")
|
||||
}
|
||||
|
||||
ServicePointListScreen()
|
||||
.tabItem {
|
||||
Label("Service Points", systemImage: "mappin.and.ellipse")
|
||||
}
|
||||
|
||||
ScannerScreen()
|
||||
.tabItem {
|
||||
Label("Scanner", systemImage: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
}
|
||||
.tint(.payfritGreen)
|
||||
} else {
|
||||
ProgressView("Loading...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await APIService.shared.setBusinessId(business.businessId)
|
||||
appState.setBusiness(id: business.businessId, name: business.businessName)
|
||||
isReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
116
PayfritBeacon/Views/BeaconDetailScreen.swift
Normal file
116
PayfritBeacon/Views/BeaconDetailScreen.swift
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BeaconDetailScreen: View {
|
||||
let beacon: Beacon
|
||||
var onSaved: () -> Void
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var uuid: String = ""
|
||||
@State private var isActive: Bool = true
|
||||
@State private var isSaving = false
|
||||
@State private var isDeleting = false
|
||||
@State private var error: String?
|
||||
@State private var showDeleteConfirm = false
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Beacon Info") {
|
||||
TextField("Name", text: $name)
|
||||
TextField("UUID (32 hex characters)", text: $uuid)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Toggle("Active", isOn: $isActive)
|
||||
}
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("ID", value: "\(beacon.id)")
|
||||
LabeledContent("Business ID", value: "\(beacon.businessId)")
|
||||
if let date = beacon.createdAt {
|
||||
LabeledContent("Created", value: date.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
if let date = beacon.updatedAt {
|
||||
LabeledContent("Updated", value: date.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save Changes") { save() }
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isSaving || isDeleting || name.isEmpty || uuid.isEmpty)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(isDeleting ? "Deleting..." : "Delete Beacon", role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isSaving || isDeleting)
|
||||
}
|
||||
}
|
||||
.navigationTitle(beacon.name)
|
||||
.onAppear {
|
||||
name = beacon.name
|
||||
uuid = beacon.uuid
|
||||
isActive = beacon.isActive
|
||||
}
|
||||
.alert("Delete Beacon?", isPresented: $showDeleteConfirm) {
|
||||
Button("Delete", role: .destructive) { deleteBeacon() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will permanently remove \"\(beacon.name)\". Service points using this beacon will be unassigned.")
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
isSaving = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await APIService.shared.updateBeacon(
|
||||
beaconId: beacon.id,
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
uuid: uuid.trimmingCharacters(in: .whitespaces),
|
||||
isActive: isActive
|
||||
)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteBeacon() {
|
||||
isDeleting = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await APIService.shared.deleteBeacon(beaconId: beacon.id)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isDeleting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
83
PayfritBeacon/Views/BeaconEditSheet.swift
Normal file
83
PayfritBeacon/Views/BeaconEditSheet.swift
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BeaconEditSheet: View {
|
||||
var onSaved: () -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var uuid = ""
|
||||
@State private var isSaving = false
|
||||
@State private var error: String?
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("New Beacon") {
|
||||
TextField("Name (e.g. Table 1 Beacon)", text: $name)
|
||||
TextField("UUID (32 hex characters)", text: $uuid)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("The UUID should be a 32-character hexadecimal string that uniquely identifies this beacon. Example: 626C7565636861726D31000000000001")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Beacon")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(isSaving || name.isEmpty || uuid.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedUUID = uuid.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard !trimmedName.isEmpty else {
|
||||
error = "Name is required"
|
||||
return
|
||||
}
|
||||
guard !trimmedUUID.isEmpty else {
|
||||
error = "UUID is required"
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
_ = try await APIService.shared.createBeacon(name: trimmedName, uuid: trimmedUUID)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
154
PayfritBeacon/Views/BeaconListScreen.swift
Normal file
154
PayfritBeacon/Views/BeaconListScreen.swift
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BeaconListScreen: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var beacons: [Beacon] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
@State private var showAddSheet = false
|
||||
@State private var isDeleting = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading beacons...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.orange)
|
||||
Text(error)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadBeacons() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
}
|
||||
.padding()
|
||||
} else if beacons.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No beacons yet")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Tap + to add your first beacon")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(beacons) { beacon in
|
||||
NavigationLink(value: beacon) {
|
||||
BeaconRow(beacon: beacon)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteBeacons)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Beacons")
|
||||
.navigationDestination(for: Beacon.self) { beacon in
|
||||
BeaconDetailScreen(beacon: beacon, onSaved: { loadBeacons() })
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddSheet) {
|
||||
BeaconEditSheet(onSaved: { loadBeacons() })
|
||||
}
|
||||
.refreshable {
|
||||
await withCheckedContinuation { continuation in
|
||||
loadBeacons { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadBeacons() }
|
||||
}
|
||||
|
||||
private func loadBeacons(completion: (() -> Void)? = nil) {
|
||||
isLoading = beacons.isEmpty
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
beacons = try await APIService.shared.listBeacons()
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteBeacons(at offsets: IndexSet) {
|
||||
guard !isDeleting else { return }
|
||||
let toDelete = offsets.map { beacons[$0] }
|
||||
// Optimistic removal
|
||||
beacons.remove(atOffsets: offsets)
|
||||
isDeleting = true
|
||||
|
||||
Task {
|
||||
var failedBeacons: [Beacon] = []
|
||||
for beacon in toDelete {
|
||||
do {
|
||||
try await APIService.shared.deleteBeacon(beaconId: beacon.id)
|
||||
} catch {
|
||||
failedBeacons.append(beacon)
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
// Restore any that failed to delete
|
||||
if !failedBeacons.isEmpty {
|
||||
beacons.append(contentsOf: failedBeacons)
|
||||
}
|
||||
isDeleting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beacon Row
|
||||
|
||||
struct BeaconRow: View {
|
||||
let beacon: Beacon
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(beacon.name)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if beacon.isActive {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text(beacon.formattedUUID)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// Make Beacon Hashable for NavigationLink
|
||||
extension Beacon: Hashable {
|
||||
static func == (lhs: Beacon, rhs: Beacon) -> Bool { lhs.id == rhs.id }
|
||||
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||
}
|
||||
196
PayfritBeacon/Views/BusinessSelectionScreen.swift
Normal file
196
PayfritBeacon/Views/BusinessSelectionScreen.swift
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BusinessSelectionScreen: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var businesses: [Employment] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading businesses...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.orange)
|
||||
Text(error)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadBusinesses() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
}
|
||||
.padding()
|
||||
} else if businesses.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "building.2")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No businesses found")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(businesses) { biz in
|
||||
NavigationLink(value: biz) {
|
||||
VStack(spacing: 0) {
|
||||
BusinessHeaderImage(businessId: biz.businessId)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(biz.businessName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
if !biz.businessCity.isEmpty {
|
||||
Text(biz.businessCity)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color(.systemGray4), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.06), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Business")
|
||||
.navigationDestination(for: Employment.self) { biz in
|
||||
BeaconDashboard(business: biz)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
logout()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadBusinesses() }
|
||||
}
|
||||
|
||||
private func loadBusinesses() {
|
||||
isLoading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
businesses = try await APIService.shared.getMyBusinesses()
|
||||
isLoading = false
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
Task {
|
||||
await AuthStorage.shared.clearAuth()
|
||||
await APIService.shared.logout()
|
||||
appState.clearAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make Employment Hashable for NavigationLink
|
||||
extension Employment: Hashable {
|
||||
static func == (lhs: Employment, rhs: Employment) -> Bool {
|
||||
lhs.employeeId == rhs.employeeId && lhs.businessId == rhs.businessId
|
||||
}
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(employeeId)
|
||||
hasher.combine(businessId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Business Header Image
|
||||
|
||||
struct BusinessHeaderImage: View {
|
||||
let businessId: Int
|
||||
|
||||
@State private var loadedImage: UIImage?
|
||||
@State private var isLoading = true
|
||||
|
||||
private var imageURLs: [URL] {
|
||||
[
|
||||
"https://dev.payfrit.com/uploads/headers/\(businessId).png",
|
||||
"https://dev.payfrit.com/uploads/headers/\(businessId).jpg",
|
||||
].compactMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemGray6)
|
||||
|
||||
if let image = loadedImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if isLoading {
|
||||
ProgressView()
|
||||
.tint(.payfritGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 100)
|
||||
} else {
|
||||
Image(systemName: "building.2")
|
||||
.font(.system(size: 30))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 100)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadImage() async {
|
||||
for url in imageURLs {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200,
|
||||
let image = UIImage(data: data) {
|
||||
await MainActor.run {
|
||||
loadedImage = image
|
||||
isLoading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
136
PayfritBeacon/Views/LoginScreen.swift
Normal file
136
PayfritBeacon/Views/LoginScreen.swift
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import SwiftUI
|
||||
|
||||
struct LoginScreen: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var showPassword = false
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
@State private var isDev = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.payfritGreen)
|
||||
.padding(.top, 40)
|
||||
|
||||
Text("Payfrit Beacon")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
|
||||
Text("Sign in to manage beacons")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if isDev {
|
||||
Text("DEV MODE — password: 123456")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
TextField("Email or Phone", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
ZStack(alignment: .trailing) {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
} else {
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { login() }
|
||||
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
if let error = error {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Button(action: login) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.frame(minHeight: geo.size.height)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.task { isDev = await APIService.shared.isDev }
|
||||
}
|
||||
|
||||
private func login() {
|
||||
let user = username.trimmingCharacters(in: .whitespaces)
|
||||
let pass = password
|
||||
guard !user.isEmpty, !pass.isEmpty else {
|
||||
error = "Please enter username and password"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try await APIService.shared.login(username: user, password: pass)
|
||||
let resolvedPhoto = await APIService.shared.resolvePhotoUrl(response.photoUrl)
|
||||
await AuthStorage.shared.saveAuth(
|
||||
userId: response.userId,
|
||||
token: response.token,
|
||||
userName: response.userFirstName,
|
||||
photoUrl: resolvedPhoto
|
||||
)
|
||||
appState.setAuth(
|
||||
userId: response.userId,
|
||||
token: response.token,
|
||||
userName: response.userFirstName,
|
||||
photoUrl: resolvedPhoto
|
||||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
88
PayfritBeacon/Views/RootView.swift
Normal file
88
PayfritBeacon/Views/RootView.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var isCheckingAuth = true
|
||||
@State private var isDev = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isCheckingAuth {
|
||||
loadingView
|
||||
} else if appState.isAuthenticated {
|
||||
BusinessSelectionScreen()
|
||||
} else {
|
||||
LoginScreen()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: appState.isAuthenticated)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if isDev {
|
||||
Text("DEV")
|
||||
.font(.caption.bold())
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 80, height: 20)
|
||||
.background(Color.orange)
|
||||
.rotationEffect(.degrees(45))
|
||||
.offset(x: -20, y: -6)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
isDev = await APIService.shared.isDev
|
||||
await checkAuthWithBiometrics()
|
||||
isCheckingAuth = false
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
ZStack {
|
||||
Color.white.ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.payfritGreen)
|
||||
Text("Payfrit Beacon")
|
||||
.font(.title2.bold())
|
||||
ProgressView()
|
||||
.tint(.payfritGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkAuthWithBiometrics() async {
|
||||
let creds = await AuthStorage.shared.loadAuth()
|
||||
guard creds != nil else { return }
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
await appState.loadSavedAuth()
|
||||
return
|
||||
#else
|
||||
let context = LAContext()
|
||||
context.localizedCancelTitle = "Use Password"
|
||||
var error: NSError?
|
||||
let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
|
||||
guard canUseBiometrics else {
|
||||
// No biometrics available — allow login with saved credentials
|
||||
await appState.loadSavedAuth()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let success = try await context.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: "Sign in to Payfrit Beacon"
|
||||
)
|
||||
if success {
|
||||
await appState.loadSavedAuth()
|
||||
}
|
||||
} catch {
|
||||
// User cancelled biometrics — still allow them in with saved credentials
|
||||
NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)")
|
||||
await appState.loadSavedAuth()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
225
PayfritBeacon/Views/ScannerScreen.swift
Normal file
225
PayfritBeacon/Views/ScannerScreen.swift
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import SwiftUI
|
||||
import CoreLocation
|
||||
|
||||
struct ScannerScreen: View {
|
||||
@State private var beacons: [Beacon] = []
|
||||
@State private var selectedBeacon: Beacon?
|
||||
@State private var isLoading = true
|
||||
|
||||
// Scanner state
|
||||
@StateObject private var scanner = ScannerViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Beacon selector
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
Picker("Select Beacon", selection: $selectedBeacon) {
|
||||
Text("Choose a beacon...").tag(nil as Beacon?)
|
||||
ForEach(beacons) { beacon in
|
||||
Text(beacon.name).tag(beacon as Beacon?)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Scanner display
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
// Status indicator
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(scanner.statusColor.opacity(0.15))
|
||||
.frame(width: 160, height: 160)
|
||||
|
||||
Circle()
|
||||
.fill(scanner.statusColor.opacity(0.3))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: scanner.statusIcon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(scanner.statusColor)
|
||||
}
|
||||
|
||||
Text(scanner.statusText)
|
||||
.font(.title3.bold())
|
||||
|
||||
if scanner.isScanning {
|
||||
VStack(spacing: 8) {
|
||||
if scanner.rssi != 0 {
|
||||
HStack {
|
||||
Text("RSSI:")
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(scanner.rssi) dBm")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.bold()
|
||||
}
|
||||
HStack {
|
||||
Text("Samples:")
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(scanner.sampleCount)/\(scanner.requiredSamples)")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
// Signal strength bar
|
||||
SignalStrengthBar(rssi: scanner.rssi)
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 40)
|
||||
} else {
|
||||
Text("Searching for beacon signal...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Start/Stop button
|
||||
Button {
|
||||
if scanner.isScanning {
|
||||
scanner.stop()
|
||||
} else if let beacon = selectedBeacon {
|
||||
scanner.start(uuid: beacon.uuid)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill")
|
||||
Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning")
|
||||
}
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(scanner.isScanning ? .red : .payfritGreen)
|
||||
.disabled(selectedBeacon == nil && !scanner.isScanning)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Beacon Scanner")
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
beacons = try await APIService.shared.listBeacons()
|
||||
} catch {
|
||||
// Silently fail — user can still see the scanner
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
.onChange(of: selectedBeacon) { _ in
|
||||
if scanner.isScanning {
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scanner ViewModel
|
||||
|
||||
@MainActor
|
||||
final class ScannerViewModel: ObservableObject {
|
||||
@Published var isScanning = false
|
||||
@Published var statusText = "Select a beacon to scan"
|
||||
@Published var statusColor: Color = .secondary
|
||||
@Published var statusIcon = "sensor.tag.radiowaves.forward.fill"
|
||||
@Published var rssi: Int = 0
|
||||
@Published var sampleCount = 0
|
||||
let requiredSamples = 5
|
||||
|
||||
private var beaconScanner: BeaconScanner?
|
||||
|
||||
func start(uuid: String) {
|
||||
beaconScanner?.dispose()
|
||||
|
||||
beaconScanner = BeaconScanner(
|
||||
targetUUID: uuid,
|
||||
onBeaconDetected: { [weak self] avgRssi in
|
||||
self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)"
|
||||
self?.statusColor = .green
|
||||
self?.statusIcon = "checkmark.circle.fill"
|
||||
},
|
||||
onRSSIUpdate: { [weak self] currentRssi, samples in
|
||||
self?.rssi = currentRssi
|
||||
self?.sampleCount = samples
|
||||
},
|
||||
onBluetoothOff: { [weak self] in
|
||||
self?.statusText = "Bluetooth is OFF"
|
||||
self?.statusColor = .orange
|
||||
self?.statusIcon = "bluetooth.slash"
|
||||
},
|
||||
onPermissionDenied: { [weak self] in
|
||||
self?.statusText = "Location Permission Denied"
|
||||
self?.statusColor = .red
|
||||
self?.statusIcon = "location.slash.fill"
|
||||
self?.isScanning = false
|
||||
},
|
||||
onError: { [weak self] message in
|
||||
self?.statusText = message
|
||||
self?.statusColor = .red
|
||||
self?.statusIcon = "exclamationmark.triangle.fill"
|
||||
self?.isScanning = false
|
||||
}
|
||||
)
|
||||
|
||||
beaconScanner?.startScanning()
|
||||
isScanning = true
|
||||
statusText = "Scanning..."
|
||||
statusColor = .blue
|
||||
statusIcon = "antenna.radiowaves.left.and.right"
|
||||
rssi = 0
|
||||
sampleCount = 0
|
||||
}
|
||||
|
||||
func stop() {
|
||||
beaconScanner?.dispose()
|
||||
beaconScanner = nil
|
||||
isScanning = false
|
||||
statusText = "Select a beacon to scan"
|
||||
statusColor = .secondary
|
||||
statusIcon = "sensor.tag.radiowaves.forward.fill"
|
||||
rssi = 0
|
||||
sampleCount = 0
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Ensure cleanup if view is removed while scanning
|
||||
// Note: deinit runs on main actor since class is @MainActor
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signal Strength Bar
|
||||
|
||||
struct SignalStrengthBar: View {
|
||||
let rssi: Int
|
||||
|
||||
private var strength: Double {
|
||||
// Map RSSI from -100..-30 to 0..1
|
||||
let clamped = max(-100, min(-30, rssi))
|
||||
return Double(clamped + 100) / 70.0
|
||||
}
|
||||
|
||||
private var barColor: Color {
|
||||
if strength > 0.7 { return .green }
|
||||
if strength > 0.4 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(barColor)
|
||||
.frame(width: geo.size.width * strength)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
232
PayfritBeacon/Views/ServicePointListScreen.swift
Normal file
232
PayfritBeacon/Views/ServicePointListScreen.swift
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ServicePointListScreen: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var servicePoints: [ServicePoint] = []
|
||||
@State private var beacons: [Beacon] = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
@State private var assigningPointId: Int?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading service points...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.orange)
|
||||
Text(error)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadData() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
}
|
||||
.padding()
|
||||
} else if servicePoints.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text("No service points")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
List(servicePoints) { sp in
|
||||
ServicePointRow(
|
||||
servicePoint: sp,
|
||||
beacons: beacons,
|
||||
isAssigning: assigningPointId == sp.id,
|
||||
onAssignBeacon: { beaconId in
|
||||
assignBeacon(servicePointId: sp.id, beaconId: beaconId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Service Points")
|
||||
.refreshable {
|
||||
await withCheckedContinuation { continuation in
|
||||
loadData { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadData() }
|
||||
}
|
||||
|
||||
private func loadData(completion: (() -> Void)? = nil) {
|
||||
isLoading = servicePoints.isEmpty
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
async let sp = APIService.shared.listServicePoints()
|
||||
async let b = APIService.shared.listBeacons()
|
||||
servicePoints = try await sp
|
||||
beacons = try await b
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
private func assignBeacon(servicePointId: Int, beaconId: Int?) {
|
||||
assigningPointId = servicePointId
|
||||
Task {
|
||||
do {
|
||||
try await APIService.shared.assignBeaconToServicePoint(
|
||||
servicePointId: servicePointId,
|
||||
beaconId: beaconId
|
||||
)
|
||||
loadData()
|
||||
} catch let apiError as APIError where apiError == .unauthorized {
|
||||
await appState.handleUnauthorized()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
assigningPointId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Service Point Row
|
||||
|
||||
struct ServicePointRow: View {
|
||||
let servicePoint: ServicePoint
|
||||
let beacons: [Beacon]
|
||||
let isAssigning: Bool
|
||||
var onAssignBeacon: (Int?) -> Void
|
||||
|
||||
@State private var showBeaconPicker = false
|
||||
|
||||
private var assignedBeacon: Beacon? {
|
||||
guard let bid = servicePoint.beaconId else { return nil }
|
||||
return beacons.first { $0.id == bid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(servicePoint.name)
|
||||
.font(.headline)
|
||||
if !servicePoint.typeName.isEmpty {
|
||||
Text(servicePoint.typeName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if servicePoint.isActive {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 8, height: 8)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Beacon assignment
|
||||
HStack {
|
||||
Image(systemName: "sensor.tag.radiowaves.forward.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if isAssigning {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if let beacon = assignedBeacon {
|
||||
Text(beacon.name)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.payfritGreen)
|
||||
} else {
|
||||
Text("No beacon assigned")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showBeaconPicker = true
|
||||
} label: {
|
||||
Text(assignedBeacon != nil ? "Change" : "Assign")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
if assignedBeacon != nil {
|
||||
Button {
|
||||
onAssignBeacon(nil)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.sheet(isPresented: $showBeaconPicker) {
|
||||
BeaconPickerSheet(beacons: beacons, currentBeaconId: servicePoint.beaconId) { selectedId in
|
||||
onAssignBeacon(selectedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beacon Picker Sheet
|
||||
|
||||
struct BeaconPickerSheet: View {
|
||||
let beacons: [Beacon]
|
||||
let currentBeaconId: Int?
|
||||
var onSelect: (Int) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(beacons) { beacon in
|
||||
Button {
|
||||
onSelect(beacon.id)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(beacon.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text(beacon.formattedUUID)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if beacon.id == currentBeaconId {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Beacon")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
329
QA_WALKTHROUGH.md
Normal file
329
QA_WALKTHROUGH.md
Normal file
|
|
@ -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: <token>` (after login)
|
||||
- `X-Business-ID: <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
|
||||
Loading…
Add table
Reference in a new issue