Initial commit: Payfrit Works iOS native app

This commit is contained in:
John Pinkyfloyd 2026-02-01 23:38:34 -08:00
commit 3d057b481d
2599 changed files with 13880 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
*~

View file

@ -0,0 +1,484 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
/* App Entry */
B01000000001 /* PayfritWorksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000001; };
/* Models */
B01000000010 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000010; };
B01000000011 /* TaskDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000011; };
B01000000012 /* Employment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000012; };
B01000000013 /* OrderLineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000013; };
B01000000014 /* TableMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000014; };
B01000000015 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000015; };
B01000000016 /* TierStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000016; };
/* ViewModels */
B01000000020 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000020; };
/* Services */
B01000000030 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000030; };
B01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000031; };
B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; };
B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; };
/* Views */
B01000000040 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000040; };
B01000000041 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000041; };
B01000000042 /* BusinessSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000042; };
B01000000043 /* TaskListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000043; };
B01000000044 /* TaskDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000044; };
B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; };
B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; };
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
/* Resources */
B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
/* Product */
B03000000001 /* PayfritWorks.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritWorks.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* App Entry */
B02000000001 /* PayfritWorksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritWorksApp.swift; sourceTree = "<group>"; };
B02000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* Models */
B02000000010 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
B02000000011 /* TaskDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskDetails.swift; sourceTree = "<group>"; };
B02000000012 /* Employment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Employment.swift; sourceTree = "<group>"; };
B02000000013 /* OrderLineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderLineItem.swift; sourceTree = "<group>"; };
B02000000014 /* TableMember.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableMember.swift; sourceTree = "<group>"; };
B02000000015 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
B02000000016 /* TierStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TierStatus.swift; sourceTree = "<group>"; };
/* ViewModels */
B02000000020 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
/* Services */
B02000000030 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
B02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; };
B02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
B02000000033 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = "<group>"; };
/* Views */
B02000000040 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
B02000000041 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
B02000000042 /* BusinessSelectionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BusinessSelectionScreen.swift; sourceTree = "<group>"; };
B02000000043 /* TaskListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListScreen.swift; sourceTree = "<group>"; };
B02000000044 /* TaskDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskDetailScreen.swift; sourceTree = "<group>"; };
B02000000045 /* MyTasksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksScreen.swift; sourceTree = "<group>"; };
B02000000046 /* ChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScreen.swift; sourceTree = "<group>"; };
B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; };
/* Resources */
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
B04000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
B05000000001 = {
isa = PBXGroup;
children = (
B05000000002 /* PayfritWorks */,
B05000000009 /* Products */,
);
sourceTree = "<group>";
};
B05000000002 /* PayfritWorks */ = {
isa = PBXGroup;
children = (
B02000000001 /* PayfritWorksApp.swift */,
B02000000002 /* Info.plist */,
B05000000003 /* Models */,
B05000000004 /* ViewModels */,
B05000000005 /* Services */,
B05000000006 /* Views */,
B05000000007 /* Resources */,
);
path = PayfritWorks;
sourceTree = "<group>";
};
B05000000003 /* Models */ = {
isa = PBXGroup;
children = (
B02000000010 /* Task.swift */,
B02000000011 /* TaskDetails.swift */,
B02000000012 /* Employment.swift */,
B02000000013 /* OrderLineItem.swift */,
B02000000014 /* TableMember.swift */,
B02000000015 /* ChatMessage.swift */,
B02000000016 /* TierStatus.swift */,
);
path = Models;
sourceTree = "<group>";
};
B05000000004 /* ViewModels */ = {
isa = PBXGroup;
children = (
B02000000020 /* AppState.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
B05000000005 /* Services */ = {
isa = PBXGroup;
children = (
B02000000030 /* APIService.swift */,
B02000000031 /* AuthStorage.swift */,
B02000000032 /* BeaconScanner.swift */,
B02000000033 /* ChatService.swift */,
);
path = Services;
sourceTree = "<group>";
};
B05000000006 /* Views */ = {
isa = PBXGroup;
children = (
B02000000040 /* RootView.swift */,
B02000000041 /* LoginScreen.swift */,
B02000000042 /* BusinessSelectionScreen.swift */,
B02000000043 /* TaskListScreen.swift */,
B02000000044 /* TaskDetailScreen.swift */,
B02000000045 /* MyTasksScreen.swift */,
B02000000046 /* ChatScreen.swift */,
B02000000047 /* AccountScreen.swift */,
);
path = Views;
sourceTree = "<group>";
};
B05000000007 /* Resources */ = {
isa = PBXGroup;
children = (
B02000000060 /* Assets.xcassets */,
);
name = Resources;
sourceTree = "<group>";
};
B05000000009 /* Products */ = {
isa = PBXGroup;
children = (
B03000000001 /* PayfritWorks.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B06000000001 /* PayfritWorks */ = {
isa = PBXNativeTarget;
buildConfigurationList = B08000000003 /* Build configuration list for PBXNativeTarget "PayfritWorks" */;
buildPhases = (
B07000000001 /* Sources */,
B04000000001 /* Frameworks */,
B09000000001 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PayfritWorks;
productName = PayfritWorks;
productReference = B03000000001 /* PayfritWorks.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
B0A000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
B06000000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = B08000000001 /* Build configuration list for PBXProject "PayfritWorks" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = B05000000001;
productRefGroup = B05000000009 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B06000000001 /* PayfritWorks */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
B09000000001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B01000000060 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
B07000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B01000000001 /* PayfritWorksApp.swift in Sources */,
B01000000010 /* Task.swift in Sources */,
B01000000011 /* TaskDetails.swift in Sources */,
B01000000012 /* Employment.swift in Sources */,
B01000000013 /* OrderLineItem.swift in Sources */,
B01000000014 /* TableMember.swift in Sources */,
B01000000015 /* ChatMessage.swift in Sources */,
B01000000016 /* TierStatus.swift in Sources */,
B01000000020 /* AppState.swift in Sources */,
B01000000030 /* APIService.swift in Sources */,
B01000000031 /* AuthStorage.swift in Sources */,
B01000000032 /* BeaconScanner.swift in Sources */,
B01000000033 /* ChatService.swift in Sources */,
B01000000040 /* RootView.swift in Sources */,
B01000000041 /* LoginScreen.swift in Sources */,
B01000000042 /* BusinessSelectionScreen.swift in Sources */,
B01000000043 /* TaskListScreen.swift in Sources */,
B01000000044 /* TaskDetailScreen.swift in Sources */,
B01000000045 /* MyTasksScreen.swift in Sources */,
B01000000046 /* ChatScreen.swift in Sources */,
B01000000047 /* AccountScreen.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
B0B000000001 /* 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;
};
B0B000000002 /* 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;
};
B0B000000003 /* 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 = PayfritWorks/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Works IOS";
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.works;
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;
};
B0B000000004 /* 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 = PayfritWorks/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Works IOS";
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.works;
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 */
B08000000001 /* Build configuration list for PBXProject "PayfritWorks" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B0B000000001 /* Debug */,
B0B000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B08000000003 /* Build configuration list for PBXNativeTarget "PayfritWorks" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B0B000000003 /* Debug */,
B0B000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = B0A000000001 /* Project object */;
}

View file

@ -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
}
}

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "appicon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "payfrit-logo-white.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "original"
}
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
width="960px" height="560px" viewBox="0 0 960 560">
<rect x="9.109" y="12.221" fill="#22B24B" width="942.891" height="68.608"/>
<g fill="#FFFFFF">
<path d="M9.256,291.354V123.396h54.42c20.623,0,34.064,0.842,40.328,2.521c9.624,2.521,17.682,8.002,24.174,16.44
c6.491,8.441,9.738,19.345,9.738,32.71c0,10.311-1.873,18.98-5.614,26.007c-3.743,7.028-8.498,12.545-14.264,16.556
c-5.768,4.01-11.628,6.664-17.586,7.962c-8.097,1.604-19.82,2.406-35.173,2.406H43.168v63.356H9.256z M43.168,151.809v47.661h18.56
c13.365,0,22.302-0.878,26.81-2.636c4.505-1.756,8.038-4.506,10.597-8.249c2.558-3.741,3.838-8.095,3.838-13.061
c0-6.109-1.795-11.15-5.385-15.123c-3.591-3.971-8.134-6.453-13.634-7.447c-4.049-0.763-12.184-1.146-24.403-1.146H43.168z"/>
<path d="M299.803,291.354h-36.891l-14.666-38.151h-67.137l-13.863,38.151h-35.975l65.419-167.958h35.86L299.803,291.354z
M237.363,224.904l-23.144-62.325l-22.685,62.325H237.363z"/>
<path d="M340.36,291.354v-70.688l-61.523-97.27h39.756l39.525,66.45l38.725-66.45h39.068l-61.753,97.498v70.459H340.36z"/>
<path d="M452.98,291.354V123.396h115.142v28.413h-81.229v39.756h70.116v28.413h-70.116v71.375H452.98z"/>
<path d="M596.192,291.354V123.396h71.376c17.948,0,30.991,1.51,39.125,4.525c8.135,3.019,14.646,8.384,19.534,16.098
c4.887,7.715,7.332,16.537,7.332,26.465c0,12.603-3.705,23.011-11.112,31.22c-7.41,8.212-18.485,13.387-33.226,15.524
c7.333,4.278,13.385,8.976,18.159,14.092c4.772,5.118,11.208,14.207,19.305,27.268l20.508,32.766h-40.557l-24.518-36.547
c-8.708-13.061-14.665-21.29-17.873-24.689c-3.208-3.397-6.607-5.729-10.196-6.988c-3.592-1.261-9.28-1.891-17.071-1.891h-6.874
v70.116H596.192z M630.104,194.429h25.091c16.269,0,26.426-0.688,30.475-2.063c4.048-1.375,7.218-3.741,9.51-7.104
c2.291-3.36,3.437-7.562,3.437-12.603c0-5.651-1.509-10.215-4.525-13.69c-3.018-3.475-7.274-5.672-12.774-6.588
c-2.749-0.382-10.998-0.573-24.746-0.573h-26.466V194.429z"/>
<path d="M764.494,291.354V123.396h33.912v167.958H764.494z"/>
<path d="M868.522,291.354V151.809h-49.838v-28.413h133.473v28.413h-49.723v139.544H868.522z"/>
</g>
<rect x="9.109" y="334.846" fill="#22B24B" width="942.891" height="68.609"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "payfrit-logo-light.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "original"
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="960px" height="560px" viewBox="0 0 960 560" enable-background="new 0 0 960 560" xml:space="preserve">
<rect x="9.109" y="12.221" fill="#22B24B" width="942.891" height="68.608"/>
<g enable-background="new ">
<path d="M9.256,291.354V123.396h54.42c20.623,0,34.064,0.842,40.328,2.521c9.624,2.521,17.682,8.002,24.174,16.44
c6.491,8.441,9.738,19.345,9.738,32.71c0,10.311-1.873,18.98-5.614,26.007c-3.743,7.028-8.498,12.545-14.264,16.556
c-5.768,4.01-11.628,6.664-17.586,7.962c-8.097,1.604-19.82,2.406-35.173,2.406H43.168v63.356H9.256z M43.168,151.809v47.661h18.56
c13.365,0,22.302-0.878,26.81-2.636c4.505-1.756,8.038-4.506,10.597-8.249c2.558-3.741,3.838-8.095,3.838-13.061
c0-6.109-1.795-11.15-5.385-15.123c-3.591-3.971-8.134-6.453-13.634-7.447c-4.049-0.763-12.184-1.146-24.403-1.146H43.168z"/>
<path d="M299.803,291.354h-36.891l-14.666-38.151h-67.137l-13.863,38.151h-35.975l65.419-167.958h35.86L299.803,291.354z
M237.363,224.904l-23.144-62.325l-22.685,62.325H237.363z"/>
<path d="M340.36,291.354v-70.688l-61.523-97.27h39.756l39.525,66.45l38.725-66.45h39.068l-61.753,97.498v70.459H340.36z"/>
<path d="M452.98,291.354V123.396h115.142v28.413h-81.229v39.756h70.116v28.413h-70.116v71.375H452.98z"/>
<path d="M596.192,291.354V123.396h71.376c17.948,0,30.991,1.51,39.125,4.525c8.135,3.019,14.646,8.384,19.534,16.098
c4.887,7.715,7.332,16.537,7.332,26.465c0,12.603-3.705,23.011-11.112,31.22c-7.41,8.212-18.485,13.387-33.226,15.524
c7.333,4.278,13.385,8.976,18.159,14.092c4.772,5.118,11.208,14.207,19.305,27.268l20.508,32.766h-40.557l-24.518-36.547
c-8.708-13.061-14.665-21.29-17.873-24.689c-3.208-3.397-6.607-5.729-10.196-6.988c-3.592-1.261-9.28-1.891-17.071-1.891h-6.874
v70.116H596.192z M630.104,194.429h25.091c16.269,0,26.426-0.688,30.475-2.063c4.048-1.375,7.218-3.741,9.51-7.104
c2.291-3.36,3.437-7.562,3.437-12.603c0-5.651-1.509-10.215-4.525-13.69c-3.018-3.475-7.274-5.672-12.774-6.588
c-2.749-0.382-10.998-0.573-24.746-0.573h-26.466V194.429z"/>
<path d="M764.494,291.354V123.396h33.912v167.958H764.494z"/>
<path d="M868.522,291.354V151.809h-49.838v-28.413h133.473v28.413h-49.723v139.544H868.522z"/>
</g>
<rect x="9.109" y="334.846" fill="#22B24B" width="942.891" height="68.609"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

35
PayfritWorks/Info.plist Normal file
View file

@ -0,0 +1,35 @@
<?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 Works uses your location to detect nearby beacons for automatic task completion.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Payfrit Works uses your location to detect nearby beacons for automatic task completion.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Payfrit Works uses Bluetooth to scan for nearby beacons.</string>
<key>NSFaceIDUsageDescription</key>
<string>Payfrit Works uses Face ID for quick sign-in.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,53 @@
import Foundation
struct ChatMessage: Identifiable {
let messageId: Int
let taskId: Int
let senderUserId: Int
let senderType: String // "customer" or "worker"
let senderName: String
let text: String
let createdOn: Date
let isRead: Bool
var id: Int { messageId }
/// Manual init for creating messages locally (e.g. WebSocket)
init(messageId: Int, taskId: Int, senderUserId: Int, senderType: String,
senderName: String, text: String, createdOn: Date, isRead: Bool = false) {
self.messageId = messageId
self.taskId = taskId
self.senderUserId = senderUserId
self.senderType = senderType
self.senderName = senderName
self.text = text
self.createdOn = createdOn
self.isRead = isRead
}
/// Decode directly from a [String: Any] dictionary
init(json: [String: Any]) {
messageId = WorkTask.parseInt(json["MessageID"] ?? json["messageId"]) ?? 0
taskId = WorkTask.parseInt(json["TaskID"] ?? json["taskId"]) ?? 0
senderUserId = WorkTask.parseInt(json["SenderUserID"] ?? json["senderUserId"]) ?? 0
senderType = (json["SenderType"] as? String) ?? (json["senderType"] as? String) ?? "customer"
senderName = (json["SenderName"] as? String) ?? (json["senderName"] as? String) ?? ""
text = (json["Text"] as? String) ?? (json["MessageText"] as? String) ?? (json["text"] as? String) ?? ""
let dateVal = json["CreatedOn"] ?? json["timestamp"]
if let s = dateVal as? String {
createdOn = APIService.parseDate(s) ?? Date()
} else {
createdOn = Date()
}
if let b = json["IsRead"] as? Bool { isRead = b }
else if let i = json["IsRead"] as? Int { isRead = i == 1 }
else if let b = json["isRead"] as? Bool { isRead = b }
else { isRead = false }
}
func isMine(userType: String) -> Bool {
senderType == userType
}
}

View file

@ -0,0 +1,35 @@
import Foundation
struct Employment: Identifiable {
let employeeId: Int
let businessId: Int
let businessName: String
let businessAddress: String
let businessCity: String
let employeeStatusId: Int
let pendingTaskCount: Int
var id: Int { employeeId }
/// Decode directly from a [String: Any] dictionary (matches Flutter's fromJson)
init(json: [String: Any]) {
employeeId = WorkTask.parseInt(json["EmployeeID"]) ?? 0
businessId = WorkTask.parseInt(json["BusinessID"]) ?? 0
// Server returns "Name" not "BusinessName"
businessName = (json["Name"] as? String) ?? (json["BusinessName"] as? String) ?? ""
businessAddress = (json["Address"] as? String) ?? (json["BusinessAddress"] as? String) ?? ""
businessCity = (json["City"] as? String) ?? (json["BusinessCity"] as? String) ?? ""
// Match Flutter: read EmployeeStatusID first (server sends StatusID, which may differ)
employeeStatusId = WorkTask.parseInt(json["EmployeeStatusID"] ?? json["StatusID"]) ?? 0
pendingTaskCount = WorkTask.parseInt(json["PendingTaskCount"]) ?? 0
}
var statusName: String {
switch employeeStatusId {
case 0: return "Pending"
case 1: return "Active"
case 2: return "Inactive"
default: return "Unknown"
}
}
}

View file

@ -0,0 +1,30 @@
import Foundation
struct OrderLineItem: Identifiable {
let lineItemId: Int
let parentLineItemId: Int
let itemId: Int
let itemName: String
let itemPrice: Double
let quantity: Int
let remark: String
let isModifier: Bool
var id: Int { lineItemId }
init(json: [String: Any]) {
lineItemId = WorkTask.parseInt(json["LineItemID"]) ?? 0
parentLineItemId = WorkTask.parseInt(json["ParentLineItemID"]) ?? 0
itemId = WorkTask.parseInt(json["ItemID"]) ?? 0
itemName = (json["ItemName"] as? String) ?? ""
if let d = json["ItemPrice"] as? Double { itemPrice = d }
else if let i = json["ItemPrice"] as? Int { itemPrice = Double(i) }
else if let s = json["ItemPrice"] as? String, let d = Double(s) { itemPrice = d }
else { itemPrice = 0 }
quantity = WorkTask.parseInt(json["Quantity"]) ?? 1
remark = (json["Remark"] as? String) ?? ""
if let b = json["IsModifier"] as? Bool { isModifier = b }
else if let i = json["IsModifier"] as? Int { isModifier = i == 1 }
else { isModifier = false }
}
}

View file

@ -0,0 +1,36 @@
import Foundation
struct TableMember: Identifiable {
let userId: Int
let firstName: String
let lastName: String
let photoUrl: String
let isHost: Bool
let joinedAt: Date
var id: Int { userId }
init(json: [String: Any]) {
userId = WorkTask.parseInt(json["UserID"]) ?? 0
firstName = (json["UserFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
lastName = (json["UserLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
photoUrl = (json["UserPhotoUrl"] as? String) ?? ""
if let b = json["IsHost"] as? Bool { isHost = b }
else if let i = json["IsHost"] as? Int { isHost = i == 1 }
else { isHost = false }
if let s = json["JoinedAt"] as? String { joinedAt = APIService.parseDate(s) ?? Date() }
else { joinedAt = Date() }
}
var fullName: String {
let parts = [firstName, lastName].filter { !$0.isEmpty }
return parts.isEmpty ? "Guest" : parts.joined(separator: " ")
}
var initials: String {
let f = firstName.isEmpty ? "" : String(firstName.prefix(1)).uppercased()
let l = lastName.isEmpty ? "" : String(lastName.prefix(1)).uppercased()
let result = "\(f)\(l)"
return result.isEmpty ? "?" : result
}
}

View file

@ -0,0 +1,106 @@
import SwiftUI
struct WorkTask: Identifiable {
let taskId: Int
let businessId: Int
let categoryId: Int
let taskTypeId: Int // 1 = Service Request, 2 = Chat
let title: String
let details: String
let createdOn: Date
let statusId: Int
let sourceType: String
let sourceId: Int
let categoryName: String
let categoryColor: String
// Location (may be included in list responses)
let servicePointName: String
let deliveryAddress: String
var id: Int { taskId }
/// Decode directly from a [String: Any] dictionary (matches Flutter's fromJson)
init(json: [String: Any]) {
taskId = Self.parseInt(json["TaskID"]) ?? 0
businessId = Self.parseInt(json["BusinessID"] ?? json["TaskBusinessID"]) ?? 0
categoryId = Self.parseInt(json["TaskCategoryID"]) ?? 0
taskTypeId = Self.parseInt(json["TaskTypeID"]) ?? 1
title = (json["Title"] as? String) ?? (json["TaskTitle"] as? String) ?? ""
details = (json["Details"] as? String) ?? (json["TaskDetails"] as? String) ?? ""
createdOn = Self.parseDate(json["CreatedOn"] ?? json["TaskCreatedOn"]) ?? Date()
statusId = Self.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
sourceType = (json["SourceType"] as? String) ?? (json["TaskSourceType"] as? String) ?? ""
sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0
// Server key varies: CategoryName, Name, TaskCategoryName try all
categoryName = Self.nonEmpty(json["CategoryName"]) ?? Self.nonEmpty(json["Name"]) ?? Self.nonEmpty(json["TaskCategoryName"]) ?? Self.nonEmpty(json["TaskTypeName"]) ?? "Uncategorized"
// Server key varies: CategoryColor, Color, TaskCategoryColor try all, then TaskTypeColor fallback
categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? Self.nonEmpty(json["TaskTypeColor"]) ?? "#888888"
servicePointName = (json["ServicePointName"] as? String) ?? ""
deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
}
var locationDisplay: String {
if !deliveryAddress.isEmpty { return deliveryAddress }
if !servicePointName.isEmpty { return servicePointName }
return ""
}
var color: Color {
let hex = categoryColor.replacingOccurrences(of: "#", with: "")
guard hex.count == 6, let val = UInt64(hex, radix: 16) else {
return Color(red: 0.53, green: 0.53, blue: 0.53)
}
return Color(
red: Double((val >> 16) & 0xFF) / 255,
green: Double((val >> 8) & 0xFF) / 255,
blue: Double(val & 0xFF) / 255
)
}
var statusName: String {
switch statusId {
case 0: return "Pending"
case 1: return "Accepted"
case 2: return "In Progress"
case 3: return "Completed"
default: return "Unknown"
}
}
var timeAgo: String {
let diff = Date().timeIntervalSince(createdOn)
let minutes = Int(diff / 60)
if minutes < 1 { return "just now" }
if minutes < 60 { return "\(minutes)m ago" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h ago" }
return "\(hours / 24)d ago"
}
var isChat: Bool { taskTypeId == 2 }
// MARK: - Flexible parsing helpers
/// Returns the string value if it's non-nil and non-empty, otherwise nil
static func nonEmpty(_ value: Any?) -> String? {
guard let s = value as? String, !s.trimmingCharacters(in: .whitespaces).isEmpty else { return nil }
return s
}
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 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
}
}

View file

@ -0,0 +1,136 @@
import SwiftUI
struct TaskDetails {
let taskId: Int
let businessId: Int
let categoryId: Int
let title: String
let createdOn: Date
let statusId: Int
let categoryName: String
let categoryColor: String
// Order info
let orderId: Int
let orderRemarks: String
let orderSubmittedOn: Date?
// Location info
let servicePointId: Int
let servicePointName: String
let servicePointTypeId: Int
let deliveryAddress: String
let deliveryLat: Double
let deliveryLng: Double
// Customer info
let customerUserId: Int
let customerFirstName: String
let customerLastName: String
let customerPhone: String
let customerPhotoUrl: String
// Beacon info
let beaconUUID: String
// Line items & table members
let lineItems: [OrderLineItem]
let tableMembers: [TableMember]
/// Decode directly from a [String: Any] dictionary (matches Flutter's fromJson)
init(json: [String: Any]) {
taskId = WorkTask.parseInt(json["TaskID"]) ?? 0
businessId = WorkTask.parseInt(json["BusinessID"] ?? json["TaskBusinessID"]) ?? 0
categoryId = WorkTask.parseInt(json["TaskCategoryID"] ?? json["CategoryID"]) ?? 0
title = (json["Title"] as? String) ?? (json["TaskTitle"] as? String) ?? ""
createdOn = WorkTask.parseDate(json["CreatedOn"] ?? json["TaskCreatedOn"]) ?? Date()
statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
categoryName = WorkTask.nonEmpty(json["CategoryName"]) ?? WorkTask.nonEmpty(json["Name"]) ?? WorkTask.nonEmpty(json["TaskCategoryName"]) ?? "General"
categoryColor = WorkTask.nonEmpty(json["CategoryColor"]) ?? WorkTask.nonEmpty(json["Color"]) ?? WorkTask.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
orderId = WorkTask.parseInt(json["OrderID"]) ?? 0
orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? ""
if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty {
orderSubmittedOn = APIService.parseDate(s)
} else {
orderSubmittedOn = nil
}
servicePointId = WorkTask.parseInt(json["ServicePointID"]) ?? 0
servicePointName = (json["ServicePointName"] as? String) ?? ""
servicePointTypeId = WorkTask.parseInt(json["ServicePointTypeID"]) ?? 0
deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
deliveryLat = Self.parseDouble(json["DeliveryLat"]) ?? 0
deliveryLng = Self.parseDouble(json["DeliveryLng"]) ?? 0
customerUserId = WorkTask.parseInt(json["CustomerUserID"] ?? json["CustomerID"]) ?? 0
customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? ""
let rawPhoto = (json["CustomerPhotoUrl"] as? String) ?? (json["PhotoUrl"] as? String) ?? ""
customerPhotoUrl = APIService.resolvePhotoUrl(rawPhoto)
beaconUUID = (json["BeaconUUID"] as? String) ?? ""
// Try multiple key variants for arrays
if let arr = (json["LineItems"] ?? json["LINE_ITEMS"] ?? json["Items"]) as? [[String: Any]] {
lineItems = arr.map { OrderLineItem(json: $0) }
} else {
lineItems = []
}
if let arr = (json["TableMembers"] ?? json["TABLE_MEMBERS"] ?? json["Members"]) as? [[String: Any]] {
tableMembers = arr.map { TableMember(json: $0) }
} else {
tableMembers = []
}
}
private static func parseDouble(_ value: Any?) -> Double? {
guard let value = value else { return nil }
if let d = value as? Double { return d }
if let i = value as? Int { return Double(i) }
if let s = value as? String, let d = Double(s) { return d }
if let n = value as? NSNumber { return n.doubleValue }
return nil
}
var color: Color {
let hex = categoryColor.replacingOccurrences(of: "#", with: "")
guard hex.count == 6, let val = UInt64(hex, radix: 16) else {
return Color(red: 0.53, green: 0.53, blue: 0.53)
}
return Color(
red: Double((val >> 16) & 0xFF) / 255,
green: Double((val >> 8) & 0xFF) / 255,
blue: Double(val & 0xFF) / 255
)
}
var customerFullName: String {
let parts = [customerFirstName, customerLastName].filter { !$0.isEmpty }
return parts.isEmpty ? "Guest" : parts.joined(separator: " ")
}
var locationDisplay: String {
if !deliveryAddress.isEmpty { return deliveryAddress }
if !servicePointName.isEmpty { return servicePointName }
return "No location specified"
}
var isDelivery: Bool { !deliveryAddress.isEmpty }
var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty }
var timeAgo: String {
let diff = Date().timeIntervalSince(createdOn)
let minutes = Int(diff / 60)
if minutes < 1 { return "just now" }
if minutes < 60 { return "\(minutes)m ago" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h ago" }
return "\(hours / 24)d ago"
}
var mainItems: [OrderLineItem] {
lineItems.filter { !$0.isModifier && $0.parentLineItemId == 0 }
}
func modifiers(for parentLineItemId: Int) -> [OrderLineItem] {
lineItems.filter { $0.parentLineItemId == parentLineItemId }
}
}

View file

@ -0,0 +1,197 @@
import Foundation
// MARK: - Tier Status (from tierStatus.cfm)
struct TierStatus: Codable {
var tier: Int
var stripe: StripeInfo
var activation: ActivationInfo
enum CodingKeys: String, CodingKey {
case tier = "TIER"
case stripe = "STRIPE"
case activation = "ACTIVATION"
}
init(tier: Int = 0, stripe: StripeInfo = StripeInfo(), activation: ActivationInfo = ActivationInfo()) {
self.tier = tier
self.stripe = stripe
self.activation = activation
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
tier = (try? c.decode(Int.self, forKey: .tier)) ?? 0
stripe = (try? c.decode(StripeInfo.self, forKey: .stripe)) ?? StripeInfo()
activation = (try? c.decode(ActivationInfo.self, forKey: .activation)) ?? ActivationInfo()
}
struct StripeInfo: Codable {
var hasAccount: Bool
var payoutsEnabled: Bool
var setupIncomplete: Bool
enum CodingKeys: String, CodingKey {
case hasAccount = "HasAccount"
case payoutsEnabled = "PayoutsEnabled"
case setupIncomplete = "SetupIncomplete"
}
init(hasAccount: Bool = false, payoutsEnabled: Bool = false, setupIncomplete: Bool = false) {
self.hasAccount = hasAccount
self.payoutsEnabled = payoutsEnabled
self.setupIncomplete = setupIncomplete
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
hasAccount = (try? c.decode(Bool.self, forKey: .hasAccount)) ?? false
payoutsEnabled = (try? c.decode(Bool.self, forKey: .payoutsEnabled)) ?? false
setupIncomplete = (try? c.decode(Bool.self, forKey: .setupIncomplete)) ?? false
}
}
struct ActivationInfo: Codable {
var balanceCents: Int
var capCents: Int
var remainingCents: Int
var isComplete: Bool
var progressPercent: Int
enum CodingKeys: String, CodingKey {
case balanceCents = "BalanceCents"
case capCents = "CapCents"
case remainingCents = "RemainingCents"
case isComplete = "IsComplete"
case progressPercent = "ProgressPercent"
}
init(balanceCents: Int = 0, capCents: Int = 2500, remainingCents: Int = 2500, isComplete: Bool = false, progressPercent: Int = 0) {
self.balanceCents = balanceCents
self.capCents = capCents
self.remainingCents = remainingCents
self.isComplete = isComplete
self.progressPercent = progressPercent
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
balanceCents = (try? c.decode(Int.self, forKey: .balanceCents)) ?? 0
capCents = (try? c.decode(Int.self, forKey: .capCents)) ?? 2500
remainingCents = (try? c.decode(Int.self, forKey: .remainingCents)) ?? 2500
isComplete = (try? c.decode(Bool.self, forKey: .isComplete)) ?? false
progressPercent = (try? c.decode(Int.self, forKey: .progressPercent)) ?? 0
}
var balanceDollars: String {
String(format: "$%.2f", Double(balanceCents) / 100.0)
}
var capDollars: String {
String(format: "$%.2f", Double(capCents) / 100.0)
}
var progress: Double {
guard capCents > 0 else { return 0 }
return Double(balanceCents) / Double(capCents)
}
}
}
// MARK: - Ledger Response (from ledger.cfm)
struct LedgerResponse: Codable {
var entries: [LedgerEntry]
var totals: LedgerTotals
enum CodingKeys: String, CodingKey {
case entries = "ENTRIES"
case totals = "TOTALS"
}
init(entries: [LedgerEntry] = [], totals: LedgerTotals = LedgerTotals()) {
self.entries = entries
self.totals = totals
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
entries = (try? c.decode([LedgerEntry].self, forKey: .entries)) ?? []
totals = (try? c.decode(LedgerTotals.self, forKey: .totals)) ?? LedgerTotals()
}
}
struct LedgerEntry: Codable, Identifiable {
let id: Int
let taskId: Int
let grossEarningsCents: Int
let activationWithheldCents: Int
let netTransferCents: Int
let status: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id = "ID"
case taskId = "TaskID"
case grossEarningsCents = "GrossEarningsCents"
case activationWithheldCents = "ActivationWithheldCents"
case netTransferCents = "NetTransferCents"
case status = "Status"
case createdAt = "CreatedAt"
}
var grossDollars: String {
String(format: "$%.2f", Double(grossEarningsCents) / 100.0)
}
var netDollars: String {
String(format: "$%.2f", Double(netTransferCents) / 100.0)
}
var withheldDollars: String {
String(format: "$%.2f", Double(activationWithheldCents) / 100.0)
}
var statusDisplay: String {
switch status {
case "pending_charge": return "Pending"
case "charged": return "Charged"
case "transferred": return "Transferred"
case "reversed": return "Reversed"
default: return status.capitalized
}
}
}
struct LedgerTotals: Codable {
var totalGrossCents: Int
var totalWithheldCents: Int
var totalNetCents: Int
enum CodingKeys: String, CodingKey {
case totalGrossCents = "TotalGrossCents"
case totalWithheldCents = "TotalWithheldCents"
case totalNetCents = "TotalNetCents"
}
init(totalGrossCents: Int = 0, totalWithheldCents: Int = 0, totalNetCents: Int = 0) {
self.totalGrossCents = totalGrossCents
self.totalWithheldCents = totalWithheldCents
self.totalNetCents = totalNetCents
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
totalGrossCents = (try? c.decode(Int.self, forKey: .totalGrossCents)) ?? 0
totalWithheldCents = (try? c.decode(Int.self, forKey: .totalWithheldCents)) ?? 0
totalNetCents = (try? c.decode(Int.self, forKey: .totalNetCents)) ?? 0
}
var totalGrossDollars: String {
String(format: "$%.2f", Double(totalGrossCents) / 100.0)
}
var totalNetDollars: String {
String(format: "$%.2f", Double(totalNetCents) / 100.0)
}
}

View file

@ -0,0 +1,17 @@
import SwiftUI
@main
struct PayfritWorksApp: 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)
}

View file

@ -0,0 +1,593 @@
import Foundation
// MARK: - API Errors
enum APIError: LocalizedError {
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: - Chat Messages Result
struct ChatMessagesResult {
let messages: [ChatMessage]
let chatClosed: Bool
}
// 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)")
}
}
// Try to parse JSON, handling CFML prefix junk
if let json = tryDecodeJSON(data) {
return json
}
throw APIError.decodingError("Non-JSON response")
}
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
// Try direct parse
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return json
}
// Try extracting JSON from mixed response
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 {
if let b = json["OK"] as? Bool { return b }
if let b = json["ok"] as? Bool { return b }
if let i = json["OK"] as? Int { return i == 1 }
if let s = json["OK"] as? String { return s == "true" || s == "1" || s == "YES" }
return false
}
private func err(_ json: [String: Any]) -> String {
(json["ERROR"] as? String) ?? (json["error"] as? String)
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
}
/// All expected PascalCase keys our models use.
nonisolated private static let expectedKeys: [String] = [
"TaskID", "TaskBusinessID", "TaskCategoryID", "TaskTypeID", "TaskTitle",
"TaskDetails", "TaskCreatedOn", "TaskStatusID", "TaskSourceType", "TaskSourceID",
"TaskCategoryName", "TaskCategoryColor",
"EmployeeID", "BusinessID", "BusinessName", "BusinessAddress", "BusinessCity",
"EmployeeStatusID", "PendingTaskCount",
"OrderID", "OrderRemarks", "OrderSubmittedOn",
"ServicePointID", "ServicePointName", "ServicePointTypeID",
"DeliveryAddress", "DeliveryLat", "DeliveryLng",
"CustomerUserID", "CustomerFirstName", "CustomerLastName",
"CustomerPhone", "CustomerPhotoUrl", "BeaconUUID",
"LineItems", "TableMembers",
"LineItemID", "ItemName", "Quantity", "PriceCents", "Remark",
"IsModifier", "ParentLineItemID",
"UserID", "FirstName", "LastName", "IsHost",
"MessageID", "SenderUserID", "SenderType", "SenderName", "Text", "MessageText",
"CreatedOn", "IsRead",
"OK", "ERROR", "TASKS", "BUSINESSES", "MESSAGES", "TASK",
"UserFirstName", "Token", "PhotoUrl", "UserPhotoUrl",
"TIER", "STRIPE", "ACTIVATION",
"HasAccount", "PayoutsEnabled", "SetupIncomplete",
"BalanceCents", "CapCents", "RemainingCents", "IsComplete", "ProgressPercent",
"ENTRIES", "TOTALS", "ID",
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
"AccountID", "ACCOUNT_ID", "URL", "url",
"CHAT_CLOSED", "chat_closed", "PayCents"
]
/// Build a lookup: stripped key (no underscores, lowercased) expected PascalCase key.
/// This handles ALL CAPS, underscore_separated, camelCase, PascalCase any format.
nonisolated private static let keyLookup: [String: String] = {
var map: [String: String] = [:]
for k in expectedKeys {
// Strip underscores and lowercase for fuzzy matching
let stripped = k.replacingOccurrences(of: "_", with: "").lowercased()
map[stripped] = k
// Also add exact uppercased (for direct ALL CAPS match)
map[k.uppercased()] = k
}
return map
}()
/// Normalize dictionary keys: maps ANY casing/format to expected PascalCase.
/// Strips underscores and lowercases for fuzzy matching against known keys.
nonisolated static func normalizeKeys(_ dict: [String: Any]) -> [String: Any] {
var result: [String: Any] = [:]
for (key, value) in dict {
// Try exact uppercased match first, then stripped fuzzy match
let stripped = key.replacingOccurrences(of: "_", with: "").lowercased()
let normalizedKey = keyLookup[key.uppercased()] ?? keyLookup[stripped] ?? key
if let arr = value as? [[String: Any]] {
result[normalizedKey] = arr.map { normalizeKeys($0) }
} else if let sub = value as? [String: Any] {
result[normalizedKey] = normalizeKeys(sub)
} else {
result[normalizedKey] = value
}
}
return result
}
/// Find an array value in the JSON by trying multiple key variants
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 }
}
// Fallback: search all values for the first array of dicts
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)
?? ""
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: - Tasks
func listPendingTasks(categoryId: Int? = nil) async throws -> [WorkTask] {
var payload: [String: Any] = ["BusinessID": businessId]
if let cid = categoryId, cid > 0 {
payload["CategoryID"] = cid
}
let json = try await postJSON("/tasks/listPending.cfm", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load tasks: \(err(json))")
}
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
return arr.map { WorkTask(json: $0) }
}
func acceptTask(taskId: Int) async throws {
// Flutter only sends TaskID + BusinessID (no UserID)
let json = try await postJSON("/tasks/accept.cfm", payload: [
"TaskID": taskId,
"BusinessID": businessId
])
guard ok(json) else {
let e = err(json)
if e == "already_accepted" {
throw APIError.serverError("This task has already been claimed by someone else.")
}
throw APIError.serverError("Failed to accept task: \(e)")
}
}
func listMyTasks(filterType: String = "active") async throws -> [WorkTask] {
var payload: [String: Any] = [
"UserID": userId ?? 0,
"FilterType": filterType
]
if businessId > 0 {
payload["BusinessID"] = businessId
}
let json = try await postJSON("/tasks/listMine.cfm", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load my tasks: \(err(json))")
}
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
return arr.map { WorkTask(json: $0) }
}
func completeTask(taskId: Int) async throws {
let json = try await postJSON("/tasks/complete.cfm", payload: [
"TaskID": taskId,
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to complete task: \(err(json))")
}
}
func closeChat(taskId: Int) async throws {
let json = try await postJSON("/tasks/completeChat.cfm", payload: [
"TaskID": taskId
])
guard ok(json) else {
throw APIError.serverError("Failed to close chat: \(err(json))")
}
}
func getTaskDetails(taskId: Int) async throws -> TaskDetails {
let json = try await postJSON("/tasks/getDetails.cfm", payload: [
"TaskID": taskId
])
guard ok(json) else {
throw APIError.serverError("Failed to load task details: \(err(json))")
}
// Find the TASK object try known keys, then fallback to first dict value
var taskJson: [String: Any]?
for key in ["TASK", "Task", "task"] {
if let d = json[key] as? [String: Any] { taskJson = d; break }
}
if taskJson == nil {
// Fallback: look for first nested dict that looks like a task
for (_, value) in json {
if let d = value as? [String: Any], d.count > 3 { taskJson = d; break }
}
}
guard let taskJson = taskJson else {
throw APIError.serverError("Invalid task details response")
}
let details = TaskDetails(json: taskJson)
return details
}
// MARK: - Chat
func getChatMessages(taskId: Int, afterMessageId: Int? = nil) async throws -> ChatMessagesResult {
var payload: [String: Any] = ["TaskID": taskId]
if let after = afterMessageId, after > 0 {
payload["AfterMessageID"] = after
}
let json = try await postJSON("/chat/getMessages.cfm", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load chat messages")
}
let arr = Self.findArray(json, ["MESSAGES", "Messages", "messages"]) ?? []
let messages: [ChatMessage] = arr.map { ChatMessage(json: $0) }
let chatClosed = (json["CHAT_CLOSED"] as? Bool) == true || (json["chat_closed"] as? Bool) == true
return ChatMessagesResult(messages: messages, chatClosed: chatClosed)
}
func sendChatMessage(taskId: Int, message: String, userId: Int? = nil, senderType: String? = nil) async throws -> Int {
var payload: [String: Any] = [
"TaskID": taskId,
"Message": message
]
if let uid = userId { payload["UserID"] = uid }
if let st = senderType { payload["SenderType"] = st }
let json = try await postJSON("/chat/sendMessage.cfm", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to send message")
}
return (json["MessageID"] as? Int) ?? (json["MESSAGE_ID"] as? Int) ?? 0
}
func markChatMessagesRead(taskId: Int, readerType: String) async throws {
let json = try await postJSON("/chat/markRead.cfm", payload: [
"TaskID": taskId,
"ReaderType": readerType
])
guard ok(json) else {
throw APIError.serverError("Failed to mark messages as read")
}
}
// MARK: - Payout / Tier Endpoints
func getTierStatus() async throws -> TierStatus {
let json = try await postJSON("/workers/tierStatus.cfm", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to load tier status: \(err(json))")
}
let data = try JSONSerialization.data(withJSONObject: json)
return (try? JSONDecoder().decode(TierStatus.self, from: data)) ?? TierStatus()
}
func createStripeAccount() async throws -> String {
let json = try await postJSON("/workers/createAccount.cfm", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to create Stripe account: \(err(json))")
}
return (json["AccountID"] as? String) ?? (json["ACCOUNT_ID"] as? String) ?? ""
}
func getOnboardingLink() async throws -> String {
let json = try await postJSON("/workers/onboardingLink.cfm", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to get onboarding link: \(err(json))")
}
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
}
func getEarlyUnlockUrl() async throws -> String {
let json = try await postJSON("/workers/earlyUnlock.cfm", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to get unlock URL: \(err(json))")
}
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
}
func getLedger() async throws -> LedgerResponse {
let json = try await postJSON("/workers/ledger.cfm", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to load ledger: \(err(json))")
}
let data = try JSONSerialization.data(withJSONObject: json)
return try JSONDecoder().decode(LedgerResponse.self, from: data)
}
// MARK: - Debug
/// Returns raw JSON string for a given endpoint (for debugging key issues)
func debugRawJSON(_ path: String, payload: [String: Any]) async -> String {
let urlString = environment.baseURL + (path.hasPrefix("/") ? path : "/\(path)")
guard let url = URL(string: urlString) else { return "Invalid URL" }
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)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return "Network error"
}
let raw = String(data: data, encoding: .utf8) ?? "Non-UTF8"
// Return first 2000 chars
return String(raw.prefix(2000))
}
// MARK: - URL Helpers
/// Resolve a photo URL if it starts with "/" prepend the base domain
nonisolated static func resolvePhotoUrl(_ rawUrl: String) -> String {
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "" }
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
// Relative URL prepend base domain
let baseDomain = "https://dev.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 }
// Try epoch (milliseconds or seconds)
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
}
}

View file

@ -0,0 +1,90 @@
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_works_user_id"
private let userNameKey = "payfrit_works_user_name"
private let userPhotoKey = "payfrit_works_user_photo"
private let serviceName = "com.payfrit.works"
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)
if let name = userName {
UserDefaults.standard.set(name, forKey: userNameKey)
}
if let photo = photoUrl, !photo.isEmpty {
UserDefaults.standard.set(photo, 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)
}
}

View file

@ -0,0 +1,152 @@
import UIKit
import CoreBluetooth
import CoreLocation
/// Beacon scanner for task auto-completion.
/// Scans for a specific UUID and triggers callback after dwell time is met.
final class BeaconScanner: NSObject, ObservableObject {
private let targetUUID: String
private let onBeaconDetected: (Double) -> Void
private let onBluetoothOff: (() -> Void)?
private let onPermissionDenied: (() -> Void)?
@Published var isScanning = false
private var locationManager: CLLocationManager?
private var activeConstraint: CLBeaconIdentityConstraint?
private var checkTimer: Timer?
// RSSI samples for dwell time enforcement
private var rssiSamples: [Int] = []
private let minSamplesToConfirm = 5 // ~5 seconds
private let rssiThreshold = -75
init(targetUUID: String,
onBeaconDetected: @escaping (Double) -> Void,
onBluetoothOff: (() -> Void)? = nil,
onPermissionDenied: (() -> Void)? = nil) {
self.targetUUID = targetUUID
self.onBeaconDetected = onBeaconDetected
self.onBluetoothOff = onBluetoothOff
self.onPermissionDenied = onPermissionDenied
super.init()
}
// MARK: - UUID formatting
private 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 { return }
locationManager = CLLocationManager()
locationManager?.delegate = self
let status = locationManager!.authorizationStatus
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
onPermissionDenied?()
return
}
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
activeConstraint = constraint
locationManager?.startRangingBeacons(satisfying: constraint)
isScanning = true
rssiSamples.removeAll()
// Idle timer disabled so screen stays on during scanning
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}
// Periodic Bluetooth check
checkTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
if CBCentralManager.authorization == .denied ||
CBCentralManager.authorization == .restricted {
self?.onBluetoothOff?()
}
}
}
func stopScanning() {
isScanning = false
if let constraint = activeConstraint {
locationManager?.stopRangingBeacons(satisfying: constraint)
}
activeConstraint = nil
checkTimer?.invalidate()
checkTimer = nil
rssiSamples.removeAll()
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = false
}
}
func resetSamples() {
rssiSamples.removeAll()
}
func dispose() {
stopScanning()
locationManager = nil
}
}
// MARK: - CLLocationManagerDelegate
extension BeaconScanner: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
satisfying constraint: CLBeaconIdentityConstraint) {
let normalizedTarget = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
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 == normalizedTarget else { continue }
foundThisCycle = true
if rssi >= rssiThreshold {
rssiSamples.append(rssi)
if rssiSamples.count >= minSamplesToConfirm {
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
DispatchQueue.main.async { [weak self] in
self?.onBeaconDetected(avg)
}
}
} else {
// Signal too weak, reset
if !rssiSamples.isEmpty { rssiSamples.removeAll() }
}
}
// Beacon lost this cycle
if !foundThisCycle && !rssiSamples.isEmpty {
rssiSamples.removeAll()
}
}
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
// Ranging failed - could be Bluetooth off
}
}

View file

@ -0,0 +1,201 @@
import Foundation
// MARK: - Chat Event Types
enum ChatEventType {
case joined, userJoined, userLeft, chatEnded, disconnected, error
}
struct ChatEvent {
let type: ChatEventType
let message: String?
let data: [String: Any]?
init(type: ChatEventType, message: String? = nil, data: [String: Any]? = nil) {
self.type = type
self.message = message
self.data = data
}
}
struct TypingEvent {
let userType: String
let userName: String
let isTyping: Bool
}
// MARK: - Chat Service (WebSocket)
@MainActor
final class ChatService: ObservableObject {
private static let wsBaseURL = "wss://app.payfrit.com:3001"
@Published var isConnected = false
@Published var chatClosed = false
private var webSocketTask: URLSessionWebSocketTask?
private var currentTaskId: Int?
private var userType: String?
private var pingTimer: Timer?
// Callbacks
var onMessage: ((ChatMessage) -> Void)?
var onTyping: ((TypingEvent) -> Void)?
var onEvent: ((ChatEvent) -> Void)?
// MARK: - Connect
func connect(taskId: Int, userToken: String, userType: String) async -> Bool {
if webSocketTask != nil { disconnect() }
currentTaskId = taskId
self.userType = userType
guard let url = URL(string: Self.wsBaseURL) else { return false }
let session = URLSession(configuration: .default)
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
// Send join event
let joinPayload: [String: Any] = [
"event": "join-chat",
"task_id": taskId,
"user_token": userToken,
"user_type": userType
]
guard let joinData = try? JSONSerialization.data(withJSONObject: joinPayload),
let joinString = String(data: joinData, encoding: .utf8) else { return false }
do {
try await webSocketTask?.send(.string(joinString))
isConnected = true
startListening()
startPing()
return true
} catch {
isConnected = false
return false
}
}
// MARK: - Listen
private func startListening() {
webSocketTask?.receive { [weak self] result in
Task { @MainActor in
guard let self = self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
self.handleMessage(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
self.handleMessage(text)
}
@unknown default:
break
}
self.startListening()
case .failure:
self.isConnected = false
self.onEvent?(ChatEvent(type: .disconnected))
}
}
}
}
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let event = json["event"] as? String else { return }
switch event {
case "joined":
onEvent?(ChatEvent(type: .joined, data: json))
case "new-message":
if let msgData = json["data"] as? [String: Any] {
let msg = ChatMessage(json: msgData)
onMessage?(msg)
}
case "user-typing":
let data = json["data"] as? [String: Any] ?? json
onTyping?(TypingEvent(
userType: data["userType"] as? String ?? "",
userName: data["userName"] as? String ?? "",
isTyping: data["isTyping"] as? Bool ?? false
))
case "user-joined":
onEvent?(ChatEvent(type: .userJoined, data: json["data"] as? [String: Any]))
case "user-left":
onEvent?(ChatEvent(type: .userLeft, data: json["data"] as? [String: Any]))
case "chat-ended", "chat-closed":
chatClosed = true
onEvent?(ChatEvent(type: .chatEnded, message: (json["data"] as? [String: Any])?["message"] as? String ?? "Chat has ended"))
case "error":
onEvent?(ChatEvent(type: .error, message: (json["data"] as? [String: Any])?["message"] as? String ?? "Unknown error"))
default:
break
}
}
// MARK: - Send
func sendMessage(_ text: String) {
guard let taskId = currentTaskId else { return }
let payload: [String: Any] = [
"event": "send-message",
"task_id": taskId,
"message": text
]
sendJSON(payload)
}
func setTyping(_ isTyping: Bool) {
guard let taskId = currentTaskId else { return }
let payload: [String: Any] = [
"event": "typing",
"task_id": taskId,
"is_typing": isTyping
]
sendJSON(payload)
}
func closeChatWS() {
guard let taskId = currentTaskId else { return }
let payload: [String: Any] = [
"event": "chat-closed",
"task_id": taskId
]
sendJSON(payload)
}
private func sendJSON(_ payload: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let string = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(string)) { _ in }
}
// MARK: - Ping
private func startPing() {
pingTimer?.invalidate()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.webSocketTask?.sendPing { _ in }
}
}
// MARK: - Disconnect
func disconnect() {
pingTimer?.invalidate()
pingTimer = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
currentTaskId = nil
userType = nil
}
}

View file

@ -0,0 +1,41 @@
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 isAuthenticated = false
var isLoggedIn: Bool { userId != nil && userToken != nil }
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 setBusinessId(_ id: Int) {
self.businessId = id
}
func clearAuth() {
userId = nil
userToken = nil
userName = nil
userPhotoUrl = nil
isAuthenticated = false
businessId = 0
}
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)
}
}

View file

@ -0,0 +1,353 @@
import SwiftUI
struct AccountScreen: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var tierStatus: TierStatus?
@State private var ledger: LedgerResponse?
@State private var isLoading = true
@State private var error: String?
@State private var showingMyTasks = false
@State private var showActivationInfo = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
if isLoading {
ProgressView()
.padding(.top, 40)
} else if let error = error {
errorView(error)
} else {
if let tier = tierStatus {
tierCard(tier)
activationCard(tier)
}
if let ledger = ledger {
earningsCard(ledger)
}
logoutButton
}
}
.padding(16)
}
.navigationTitle("Account")
.overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task { await loadData() }
.refreshable { await loadData() }
.alert("What is Activation?", isPresented: $showActivationInfo) {
Button("Got it", role: .cancel) { }
} message: {
Text("Activation tracks your early earnings to help establish your account. Once you reach the activation cap, your account is fully activated and you can receive regular payouts. You can also complete activation early if you prefer.")
}
}
// MARK: - Tier Card
@ViewBuilder
private func tierCard(_ tier: TierStatus) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: tier.tier >= 1 ? "checkmark.shield.fill" : "shield")
.foregroundColor(tier.tier >= 1 ? .green : .secondary)
.font(.title2)
Text("Payout Status")
.font(.headline)
Spacer()
}
if tier.tier >= 1 {
// Tier 1 unlocked
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Tier 1 unlocked — Payouts enabled")
.foregroundColor(.green)
.font(.subheadline.weight(.medium))
}
} else if !tier.stripe.hasAccount {
// No account yet
Text("Tier 1 is locked")
.font(.subheadline)
.foregroundColor(.secondary)
Button { startStripeOnboarding() } label: {
Label("Complete payout setup", systemImage: "arrow.right.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
} else if tier.stripe.setupIncomplete {
// Account exists but incomplete
Text("Stripe needs more info to enable payouts.")
.font(.subheadline)
.foregroundColor(.secondary)
Button { continueStripeOnboarding() } label: {
Label("Continue payout setup", systemImage: "arrow.right.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Activation Card
@ViewBuilder
private func activationCard(_ tier: TierStatus) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: tier.activation.isComplete ? "checkmark.circle.fill" : "star.circle")
.foregroundColor(tier.activation.isComplete ? .green : .payfritGreen)
.font(.title2)
Text("Activation")
.font(.headline)
Spacer()
}
if tier.activation.isComplete {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Activation complete")
.foregroundColor(.green)
.font(.subheadline.weight(.medium))
}
} else {
// Progress bar
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: tier.activation.progress)
.tint(.payfritGreen)
Text("\(tier.activation.balanceDollars) of \(tier.activation.capDollars) completed")
.font(.subheadline)
.foregroundColor(.secondary)
}
Button { earlyUnlock() } label: {
Label("Complete activation now (optional)", systemImage: "bolt.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button { showActivationInfo = true } label: {
Text("What is activation?")
.font(.caption)
.foregroundColor(.payfritGreen)
}
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Earnings Card
@ViewBuilder
private func earningsCard(_ ledger: LedgerResponse) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "dollarsign.circle")
.foregroundColor(.green)
.font(.title2)
Text("Earnings")
.font(.headline)
Spacer()
VStack(alignment: .trailing) {
Text(ledger.totals.totalNetDollars)
.font(.title3.bold())
.foregroundColor(.green)
Text("total earned")
.font(.caption)
.foregroundColor(.secondary)
}
}
if !ledger.entries.isEmpty {
Divider()
ForEach(ledger.entries.prefix(20)) { entry in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Task #\(entry.taskId)")
.font(.subheadline.weight(.medium))
Text(entry.createdAt)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(entry.netDollars)
.font(.subheadline.weight(.medium))
.foregroundColor(.green)
Text(entry.statusDisplay)
.font(.caption2)
.foregroundColor(entry.status == "transferred" ? .green : .secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
entry.status == "transferred"
? Color.green.opacity(0.1)
: Color(.systemGray5)
)
.cornerRadius(4)
}
}
.padding(.vertical, 2)
}
} else {
Text("No earnings yet. Complete tasks to start earning!")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Logout
private var logoutButton: some View {
Button(role: .destructive) {
Task {
await APIService.shared.logout()
await AuthStorage.shared.clearAuth()
appState.clearAuth()
}
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
.padding(.top, 8)
}
// MARK: - Error
private func errorView(_ msg: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(msg)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadData() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions
private func loadData() async {
isLoading = true
error = nil
// Load tier status (required)
do {
tierStatus = try await APIService.shared.getTierStatus()
} catch {
self.error = error.localizedDescription
isLoading = false
return
}
// Load ledger (optional don't block screen if it fails)
do {
ledger = try await APIService.shared.getLedger()
} catch {
ledger = LedgerResponse()
}
isLoading = false
}
private func startStripeOnboarding() {
Task {
do {
_ = try await APIService.shared.createStripeAccount()
let urlStr = try await APIService.shared.getOnboardingLink()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
// Refresh on return
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
private func continueStripeOnboarding() {
Task {
do {
let urlStr = try await APIService.shared.getOnboardingLink()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
private func earlyUnlock() {
Task {
do {
let urlStr = try await APIService.shared.getEarlyUnlockUrl()
if let url = URL(string: urlStr) {
await MainActor.run {
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData()
} catch {
self.error = error.localizedDescription
}
}
}
}

View file

@ -0,0 +1,352 @@
import SwiftUI
struct BusinessSelectionScreen: View {
@EnvironmentObject var appState: AppState
@State private var businesses: [Employment] = []
@State private var isLoading = true
@State private var error: String?
@State private var showingTaskList = false
@State private var showingMyTasks = false
@State private var showingAccount = false
@State private var showingDebug = false
@State private var selectedBusiness: Employment?
@State private var debugText = ""
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView()
} else if let error = error {
errorView(error)
} else if businesses.isEmpty {
emptyView
} else {
businessList
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
myTasksFAB
}
.navigationTitle("Select Business")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Label(appState.userName ?? "Worker", systemImage: "person.circle.fill")
Divider()
Button { showingMyTasks = true } label: {
Label("My Tasks", systemImage: "checkmark.circle")
}
Button { showingAccount = true } label: {
Label("Account", systemImage: "person.crop.circle")
}
Button { loadDebugInfo() } label: {
Label("Debug API", systemImage: "ladybug")
}
Button(role: .destructive) { logout() } label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { loadBusinesses() } label: {
Image(systemName: "arrow.clockwise")
}
}
}
.navigationDestination(isPresented: $showingTaskList) {
if let biz = selectedBusiness {
TaskListScreen(businessName: biz.businessName)
.onAppear {
Task { await APIService.shared.setBusinessId(biz.businessId) }
appState.setBusinessId(biz.businessId)
}
}
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.navigationDestination(isPresented: $showingAccount) {
AccountScreen()
}
}
.sheet(isPresented: $showingDebug) {
NavigationStack {
ScrollView {
Text(debugText)
.font(.system(.caption2, design: .monospaced))
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle("API Debug")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { showingDebug = false }
}
}
}
}
.task { loadBusinesses() }
.onReceive(refreshTimer) { _ in loadBusinesses(silent: true) }
}
// MARK: - Business List
private var businessList: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(businesses) { emp in
businessCard(emp)
.onTapGesture { selectBusiness(emp) }
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 80) // space for FAB
}
.refreshable { loadBusinesses() }
}
private func businessCard(_ emp: Employment) -> some View {
VStack(spacing: 0) {
// Header image with brand color background
BusinessHeaderImage(businessId: emp.businessId)
// Info bar
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(emp.businessName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
Text(emp.statusName)
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(statusColor(emp.employeeStatusId))
}
Spacer()
// Task count badge
if emp.pendingTaskCount > 0 {
Text("\(emp.pendingTaskCount)")
.font(.caption.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.payfritGreen)
.clipShape(Capsule())
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.body)
}
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)
.contentShape(Rectangle())
}
private var myTasksFAB: some View {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
// MARK: - Empty / Error
private var emptyView: some View {
VStack(spacing: 16) {
Image(systemName: "briefcase")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No businesses found")
.font(.title3)
.foregroundColor(.secondary)
Text("You are not currently employed at any business")
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { loadBusinesses() }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions
private func loadBusinesses(silent: Bool = false) {
if !silent {
isLoading = true
error = nil
}
Task {
do {
let result = try await APIService.shared.getMyBusinesses()
businesses = result
isLoading = false
} catch {
if !silent {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
private func selectBusiness(_ emp: Employment) {
selectedBusiness = emp
showingTaskList = true
}
private func logout() {
Task {
await APIService.shared.logout()
await AuthStorage.shared.clearAuth()
appState.clearAuth()
}
}
private func loadDebugInfo() {
debugText = "Loading..."
showingDebug = true
Task {
let uid = await APIService.shared.getUserId() ?? 0
let bid = await APIService.shared.getBusinessId()
var text = "=== RAW API RESPONSES ===\n\n"
text += "UserID: \(uid), BusinessID: \(bid)\n\n"
text += "--- /workers/myBusinesses.cfm ---\n"
let bizRaw = await APIService.shared.debugRawJSON(
"/workers/myBusinesses.cfm", payload: ["UserID": uid])
text += bizRaw + "\n\n"
if bid > 0 {
text += "--- /tasks/listPending.cfm ---\n"
let taskRaw = await APIService.shared.debugRawJSON(
"/tasks/listPending.cfm", payload: ["BusinessID": bid])
text += taskRaw + "\n\n"
}
debugText = text
}
}
private func statusColor(_ id: Int) -> Color {
switch id {
case 0: return .payfritGreen
case 1: return .green
case 2: return .gray
default: return .gray
}
}
}
// 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 {
// No image fallback
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
}
}
}

View file

@ -0,0 +1,389 @@
import SwiftUI
struct ChatScreen: View {
let taskId: Int
let userType: String // "customer" or "worker"
var otherPartyName: String?
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@StateObject private var chatService = ChatService()
@State private var messages: [ChatMessage] = []
@State private var messageText = ""
@State private var isLoading = true
@State private var isSending = false
@State private var error: String?
@State private var otherUserTyping = false
@State private var otherUserName: String?
@State private var chatEnded = false
@State private var showCloseChatAlert = false
@State private var showingMyTasks = false
// Polling timer
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 0) {
// Chat ended banner
if chatEnded {
Text("This chat has ended")
.font(.callout.bold())
.frame(maxWidth: .infinity)
.padding(12)
.background(Color.payfritGreen.opacity(0.2))
}
// Error banner
if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.padding(8)
.background(Color.red.opacity(0.1))
.onTapGesture { self.error = nil }
}
// Messages
messageListView
// Typing indicator
if otherUserTyping {
HStack {
Text("\(otherUserName ?? "Other user") is typing...")
.font(.caption)
.foregroundColor(.secondary)
.italic()
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
}
// Input
if !chatEnded {
inputArea
}
}
.navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if chatService.isConnected {
Image(systemName: "wifi")
.foregroundColor(.green)
.font(.caption)
} else {
Image(systemName: "wifi.slash")
.foregroundColor(.payfritGreen)
.font(.caption)
}
}
if userType == "worker" && !chatEnded {
ToolbarItem(placement: .navigationBarTrailing) {
Button { showCloseChatAlert = true } label: {
Image(systemName: "xmark.circle")
}
}
}
}
.alert("Close Chat", isPresented: $showCloseChatAlert) {
Button("Cancel", role: .cancel) { }
Button("Close", role: .destructive) { closeChatAction() }
} message: {
Text("Are you sure you want to close this chat?")
}
.overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, chatEnded ? 16 : 60)
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task {
otherUserName = otherPartyName
await initializeChat()
}
.onReceive(pollTimer) { _ in
if !chatEnded && !chatService.isConnected {
pollNewMessages()
}
}
.onDisappear {
chatService.disconnect()
}
}
// MARK: - Message List
@ViewBuilder
private var messageListView: some View {
if isLoading {
Spacer()
ProgressView()
Spacer()
} else if messages.isEmpty {
Spacer()
VStack(spacing: 16) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No messages yet")
.foregroundColor(.secondary)
Text("Start the conversation!")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(messages) { msg in
messageBubble(msg)
.id(msg.messageId)
}
}
.padding(16)
}
.onChange(of: messages.count) { _ in
if let last = messages.last {
withAnimation {
proxy.scrollTo(last.messageId, anchor: .bottom)
}
}
}
}
}
}
private func messageBubble(_ msg: ChatMessage) -> some View {
let isMe = msg.senderType == userType
let time = msg.createdOn.formatted(date: .omitted, time: .shortened)
return HStack(alignment: .bottom, spacing: 8) {
if isMe { Spacer(minLength: 60) }
if !isMe {
Circle()
.fill(Color(.systemGray4))
.frame(width: 32, height: 32)
.overlay(
Text(msg.senderName.isEmpty
? (msg.senderType == "worker" ? "S" : "C")
: String(msg.senderName.prefix(1)).uppercased())
.font(.caption.bold())
)
}
VStack(alignment: isMe ? .trailing : .leading, spacing: 4) {
if !isMe && !msg.senderName.isEmpty {
Text(msg.senderName)
.font(.caption2.bold())
.foregroundColor(.secondary)
}
Text(msg.text)
.foregroundColor(isMe ? .white : .primary)
Text(time)
.font(.caption2)
.foregroundColor(isMe ? .white.opacity(0.7) : .secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isMe ? Color.accentColor : Color(.systemGray5))
.cornerRadius(16, corners: isMe
? [.topLeft, .topRight, .bottomLeft]
: [.topLeft, .topRight, .bottomRight])
if isMe {
let initials = {
let parts = (appState.userName ?? "").split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
} else if let first = parts.first {
return String(first.prefix(1)).uppercased()
}
return "Me"
}()
Circle()
.fill(Color.accentColor.opacity(0.7))
.frame(width: 32, height: 32)
.overlay(
Text(initials)
.font(.caption2)
.foregroundColor(.white)
)
}
if !isMe { Spacer(minLength: 60) }
}
}
// MARK: - Input
private var inputArea: some View {
HStack(spacing: 8) {
TextField("Type a message...", text: $messageText)
.textFieldStyle(.roundedBorder)
.onSubmit { sendMessage() }
Button(action: sendMessage) {
if isSending {
ProgressView()
.frame(width: 36, height: 36)
} else {
Image(systemName: "paperplane.fill")
.frame(width: 36, height: 36)
}
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
.disabled(isSending || messageText.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
}
// MARK: - Actions
private func initializeChat() async {
// Load messages first
await loadMessages()
// Try WebSocket
let token = await APIService.shared.getToken()
guard let token = token, !token.isEmpty else { return }
chatService.onMessage = { msg in
if !messages.contains(where: { $0.messageId == msg.messageId }) {
messages.append(msg)
Task {
if msg.senderType != userType {
try? await APIService.shared.markChatMessagesRead(taskId: taskId, readerType: userType)
}
}
}
}
chatService.onTyping = { event in
if event.userType != userType {
otherUserTyping = event.isTyping
if !event.userName.isEmpty { otherUserName = event.userName }
}
}
chatService.onEvent = { event in
switch event.type {
case .chatEnded:
chatEnded = true
case .userJoined:
if let name = event.data?["userName"] as? String {
otherUserName = name
}
default: break
}
}
let _ = await chatService.connect(taskId: taskId, userToken: token, userType: userType)
}
private func loadMessages() async {
isLoading = true
error = nil
do {
let result = try await APIService.shared.getChatMessages(taskId: taskId)
messages = result.messages
if result.chatClosed { chatEnded = true }
isLoading = false
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func sendMessage() {
let text = messageText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty, !isSending, !chatEnded else { return }
isSending = true
chatService.setTyping(false)
if chatService.isConnected {
chatService.sendMessage(text)
messageText = ""
isSending = false
} else {
Task {
do {
let uid = await APIService.shared.getUserId()
_ = try await APIService.shared.sendChatMessage(
taskId: taskId, message: text, userId: uid, senderType: userType
)
messageText = ""
await loadMessages()
} catch {
self.error = error.localizedDescription
}
isSending = false
}
}
}
private func pollNewMessages() {
Task {
let lastId = messages.last?.messageId ?? 0
if let result = try? await APIService.shared.getChatMessages(taskId: taskId, afterMessageId: lastId) {
if result.chatClosed && !chatEnded { chatEnded = true }
for msg in result.messages {
if !messages.contains(where: { $0.messageId == msg.messageId }) {
messages.append(msg)
}
}
}
}
}
private func closeChatAction() {
guard userType == "worker" else { return }
Task {
do {
try await APIService.shared.closeChat(taskId: taskId)
chatService.closeChatWS()
chatEnded = true
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
}
// MARK: - Corner Radius Extension
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCornerShape(radius: radius, corners: corners))
}
}
struct RoundedCornerShape: Shape {
var radius: CGFloat
var corners: UIRectCorner
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}

View file

@ -0,0 +1,138 @@
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("PayfritLogoLight")
.resizable()
.scaledToFit()
.frame(width: 220)
.padding(.horizontal, 16)
Text("Payfrit Works")
.font(.system(size: 28, weight: .bold))
Text("Sign in to view and claim tasks")
.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)
// Password field with visibility toggle
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 = APIService.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
}
}
}
}

View file

@ -0,0 +1,281 @@
import SwiftUI
struct MyTasksScreen: View {
@Environment(\.dismiss) var dismiss
@State private var selectedFilter = "active"
@State private var tasksByFilter: [String: [WorkTask]] = [
"active": [], "today": [], "week": [], "completed": []
]
@State private var loadingByFilter: [String: Bool] = [
"active": true, "today": true, "week": true, "completed": true
]
@State private var errorByFilter: [String: String?] = [
"active": nil, "today": nil, "week": nil, "completed": nil
]
private let filters = [
FilterTab(value: "active", label: "Active", icon: "play.fill"),
FilterTab(value: "today", label: "Today", icon: "calendar"),
FilterTab(value: "week", label: "This Week", icon: "calendar.badge.clock"),
FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"),
]
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
var body: some View {
VStack(spacing: 0) {
// Tab bar
HStack(spacing: 0) {
ForEach(filters, id: \.value) { filter in
Button {
selectedFilter = filter.value
loadTasks(filter.value)
} label: {
VStack(spacing: 4) {
Image(systemName: filter.icon)
.font(.caption)
Text(filter.label)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundColor(selectedFilter == filter.value ? .white : .white.opacity(0.7))
}
}
}
.background(Color.payfritGreen)
// Indicator
GeometryReader { geo in
let idx = filters.firstIndex(where: { $0.value == selectedFilter }) ?? 0
let width = geo.size.width / CGFloat(filters.count)
Rectangle()
.fill(Color.white)
.frame(width: width, height: 3)
.offset(x: width * CGFloat(idx))
.animation(.easeInOut(duration: 0.2), value: selectedFilter)
}
.frame(height: 3)
.background(Color.payfritGreen)
// Content
TabView(selection: $selectedFilter) {
ForEach(filters, id: \.value) { filter in
taskListView(filter.value)
.tag(filter.value)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.navigationTitle("My Tasks")
.toolbarBackground(Color.payfritGreen, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { loadTasks(selectedFilter) } label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(.white)
}
}
}
.task { loadTasks("active") }
.onReceive(refreshTimer) { _ in loadTasks(selectedFilter, silent: true) }
}
// MARK: - Task List per Filter
@ViewBuilder
private func taskListView(_ filterType: String) -> some View {
let isLoading = loadingByFilter[filterType] ?? true
let error = errorByFilter[filterType] ?? nil
let tasks = tasksByFilter[filterType] ?? []
if isLoading {
ProgressView()
} else if let error = error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(error)
.multilineTextAlignment(.center)
Button("Retry") { loadTasks(filterType) }
.buttonStyle(.borderedProminent)
}
.padding()
} else if tasks.isEmpty {
emptyView(filterType)
} else {
List(tasks) { task in
let isCompleted = filterType == "completed"
NavigationLink {
TaskDetailScreen(task: task, showCompleteButton: !isCompleted)
} label: {
taskCard(task, isCompleted: isCompleted)
}
}
.listStyle(.plain)
.refreshable { loadTasks(filterType) }
}
}
private func taskCard(_ task: WorkTask, isCompleted: Bool) -> some View {
HStack {
Rectangle()
.fill(isCompleted ? Color.green : task.color)
.frame(width: 4)
.cornerRadius(2)
if isCompleted {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.callout.weight(.semibold))
.strikethrough(isCompleted)
.foregroundColor(isCompleted ? .secondary : .primary)
if !task.locationDisplay.isEmpty {
HStack(spacing: 4) {
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
.font(.caption2)
.foregroundColor(.payfritGreen)
Text(task.locationDisplay)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
HStack(spacing: 8) {
Text(task.categoryName)
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(task.color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(task.color.opacity(0.2))
.cornerRadius(8)
Text(task.timeAgo)
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
if task.isChat {
Image(systemName: "bubble.left.fill")
.foregroundColor(.payfritGreen)
.font(.footnote)
}
if !isCompleted {
Text(task.statusName)
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(statusColor(task.statusId))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(statusColor(task.statusId).opacity(0.1))
.cornerRadius(8)
}
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
// MARK: - Empty
@ViewBuilder
private func emptyView(_ filterType: String) -> some View {
VStack(spacing: 16) {
Image(systemName: emptyIcon(filterType))
.font(.system(size: 64))
.foregroundColor(.secondary)
Text(emptyMessage(filterType))
.font(.title3)
.foregroundColor(.secondary)
Text(emptySubMessage(filterType))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
private func emptyIcon(_ f: String) -> String {
switch f {
case "active": return "doc.text"
case "today": return "calendar"
case "week": return "calendar.badge.clock"
case "completed": return "checkmark.circle"
default: return "tray"
}
}
private func emptyMessage(_ f: String) -> String {
switch f {
case "active": return "No active tasks"
case "today": return "No tasks today"
case "week": return "No tasks this week"
case "completed": return "No completed tasks"
default: return "No tasks"
}
}
private func emptySubMessage(_ f: String) -> String {
switch f {
case "active": return "Claim some tasks to get started!"
case "today": return "You haven't worked on any tasks today"
case "week": return "You haven't worked on any tasks this week"
case "completed": return "Complete tasks to see them here"
default: return ""
}
}
// MARK: - Actions
private func loadTasks(_ filterType: String, silent: Bool = false) {
if !silent {
loadingByFilter[filterType] = true
errorByFilter[filterType] = nil
}
Task {
do {
let tasks = try await APIService.shared.listMyTasks(filterType: filterType)
tasksByFilter[filterType] = tasks
loadingByFilter[filterType] = false
} catch {
if !silent {
errorByFilter[filterType] = error.localizedDescription
loadingByFilter[filterType] = false
}
}
}
}
private func statusColor(_ id: Int) -> Color {
switch id {
case 0: return .payfritGreen
case 1: return .payfritGreen
case 2: return .purple
case 3: return .green
default: return .gray
}
}
}
private struct FilterTab {
let value: String
let label: String
let icon: String
}

View file

@ -0,0 +1,86 @@
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("PayfritLogoLight")
.resizable()
.scaledToFit()
.frame(width: 200)
ProgressView()
.tint(.payfritGreen)
}
}
}
private func checkAuthWithBiometrics() async {
let creds = await AuthStorage.shared.loadAuth()
guard creds != nil else { return }
let context = LAContext()
context.localizedCancelTitle = "Use Password"
var error: NSError?
let canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
#if targetEnvironment(simulator)
// Skip biometrics on simulator test on real device
await appState.loadSavedAuth()
return
#endif
guard canUseBiometrics else {
await appState.loadSavedAuth()
return
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Sign in to Payfrit Works"
)
if success {
await appState.loadSavedAuth()
}
} catch {
// User tapped "Use Password" or biometrics failed show login screen
NSLog("PAYFRIT [biometrics] cancelled/failed: \(error.localizedDescription)")
}
}
}

View file

@ -0,0 +1,625 @@
import SwiftUI
struct TaskDetailScreen: View {
let task: WorkTask
var showCompleteButton = false
var showAcceptButton = false
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var details: TaskDetails?
@State private var isLoading = true
@State private var error: String?
// Beacon
@State private var beaconDetected = false
@State private var bluetoothOff = false
@State private var autoCompleting = false
@State private var showAutoCompleteDialog = false
@State private var showAcceptAlert = false
@State private var showCompleteAlert = false
@State private var beaconScanner: BeaconScanner?
@State private var showingMyTasks = false
@State private var showingChat = false
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView()
} else if let error = error {
errorView(error)
} else if let details = details {
contentView(details)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 80)
}
.navigationTitle(task.title)
.toolbarBackground(task.color, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.alert("Accept Task?", isPresented: $showAcceptAlert) {
Button("Cancel", role: .cancel) { }
Button("Accept") { acceptTask() }
} message: {
Text("Claim this task and add it to your tasks?")
}
.alert("Complete Task?", isPresented: $showCompleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Complete") { completeTask() }
} message: {
Text("Mark this task as completed?")
}
.sheet(isPresented: $showAutoCompleteDialog) {
AutoCompleteCountdownView(taskId: task.taskId) { result in
showAutoCompleteDialog = false
if result == "success" {
dismiss()
} else if result == "cancelled" || result == "error" {
autoCompleting = false
beaconDetected = false
beaconScanner?.resetSamples()
beaconScanner?.startScanning()
}
}
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.navigationDestination(isPresented: $showingChat) {
ChatScreen(taskId: task.taskId, userType: "worker",
otherPartyName: details?.customerFullName)
}
.task { await loadDetails() }
.onDisappear { beaconScanner?.dispose() }
}
// MARK: - Content
@ViewBuilder
private func contentView(_ details: TaskDetails) -> some View {
ScrollView {
VStack(spacing: 16) {
customerSection(details)
if task.isChat {
chatButton(details)
}
locationSection(details)
if !details.tableMembers.isEmpty {
tableMembersSection(details)
}
if !details.mainItems.isEmpty {
orderItemsSection(details)
}
if !details.orderRemarks.isEmpty {
remarksSection(details)
}
Spacer().frame(height: 80)
}
.padding(16)
}
.safeAreaInset(edge: .bottom) {
if showAcceptButton || showCompleteButton {
bottomBar
}
}
}
// MARK: - Customer
private func customerSection(_ d: TaskDetails) -> some View {
HStack(spacing: 16) {
// Avatar
ZStack {
Circle()
.fill(task.color.opacity(0.2))
.frame(width: 64, height: 64)
if !d.customerPhotoUrl.isEmpty, let url = URL(string: d.customerPhotoUrl) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
initialsView(d)
}
.frame(width: 64, height: 64)
.clipShape(Circle())
} else {
initialsView(d)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(d.customerFullName)
.font(.title3.bold())
if !d.customerPhone.isEmpty {
Text(d.customerPhone)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
if !d.customerPhone.isEmpty {
Button { callCustomer(d.customerPhone) } label: {
Image(systemName: "phone.fill")
.foregroundColor(.green)
.font(.title2)
}
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
private func initialsView(_ d: TaskDetails) -> some View {
let f = d.customerFirstName.isEmpty ? "" : String(d.customerFirstName.prefix(1)).uppercased()
let l = d.customerLastName.isEmpty ? "" : String(d.customerLastName.prefix(1)).uppercased()
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
return Text(text)
.font(.title2.bold())
.foregroundColor(task.color)
}
// MARK: - Chat Button
@ViewBuilder
private func chatButton(_ d: TaskDetails) -> some View {
Button { showingChat = true } label: {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.payfritGreen.opacity(0.1))
.frame(width: 48, height: 48)
Image(systemName: "bubble.left.and.bubble.right.fill")
.foregroundColor(.payfritGreen)
.font(.title2)
}
VStack(alignment: .leading, spacing: 2) {
Text("Chat with Customer")
.font(.callout.weight(.medium))
.foregroundColor(.primary)
Text(d.customerFullName)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
}
// MARK: - Location
private func locationSection(_ d: TaskDetails) -> some View {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.payfritGreen.opacity(0.1))
.frame(width: 48, height: 48)
Image(systemName: d.isDelivery ? "bicycle" : "table.furniture")
.foregroundColor(.payfritGreen)
.font(.title2)
}
VStack(alignment: .leading, spacing: 4) {
Text(d.isDelivery ? "Delivery" : "Table Service")
.font(.caption)
.foregroundColor(.secondary)
Text(d.locationDisplay)
.font(.callout.weight(.medium))
}
Spacer()
if d.isDelivery && d.deliveryLat != 0 {
Button { openMaps(d) } label: {
Image(systemName: "arrow.triangle.turn.up.right.diamond.fill")
.foregroundColor(.payfritGreen)
}
}
if !d.isDelivery && !d.beaconUUID.isEmpty && showCompleteButton {
beaconIndicator
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
@ViewBuilder
private var beaconIndicator: some View {
if autoCompleting {
ProgressView()
} else if bluetoothOff {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.foregroundColor(.payfritGreen)
} else if beaconDetected {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.green)
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.payfritGreen)
.opacity(0.7)
}
}
// MARK: - Table Members
private func tableMembersSection(_ d: TaskDetails) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "person.3.fill")
.foregroundColor(.secondary)
Text("Table Members (\(d.tableMembers.count))")
.font(.subheadline.weight(.semibold))
}
FlowLayout(spacing: 8) {
ForEach(d.tableMembers) { member in
HStack(spacing: 6) {
Circle()
.fill(member.isHost ? Color.yellow.opacity(0.3) : Color(.systemGray4))
.frame(width: 28, height: 28)
.overlay(
Text(member.initials)
.font(.caption2)
)
Text(member.fullName)
.font(.subheadline)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(member.isHost ? Color.yellow.opacity(0.1) : Color(.systemGray6))
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(member.isHost ? Color.yellow : Color.clear, lineWidth: 1)
)
}
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Order Items
private func orderItemsSection(_ d: TaskDetails) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "list.bullet.rectangle.portrait")
.foregroundColor(.secondary)
Text("Order Items (\(d.mainItems.count))")
.font(.subheadline.weight(.semibold))
}
ForEach(d.mainItems) { item in
orderItemRow(item, details: d)
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
private func orderItemRow(_ item: OrderLineItem, details d: TaskDetails) -> some View {
let modifiers = d.modifiers(for: item.lineItemId)
return HStack(alignment: .top, spacing: 12) {
// Item image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 56, height: 56)
.overlay(
Image(systemName: "fork.knife")
.foregroundColor(.secondary)
)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("\(item.quantity)x")
.font(.caption.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(task.color)
.cornerRadius(4)
Text(item.itemName)
.font(.callout.weight(.semibold))
}
ForEach(modifiers) { mod in
let children = d.lineItems.filter { $0.parentLineItemId == mod.lineItemId }
if !children.isEmpty {
ForEach(children) { child in
Text("- \(mod.itemName): \(child.itemName)")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
Text("- \(mod.itemName)")
.font(.caption)
.foregroundColor(.secondary)
}
}
if !item.remark.isEmpty {
Text("\"\(item.remark)\"")
.font(.caption)
.foregroundColor(.payfritGreen)
.italic()
}
}
}
.padding(.bottom, 8)
}
// MARK: - Remarks
private func remarksSection(_ d: TaskDetails) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "note.text")
.foregroundColor(.yellow)
VStack(alignment: .leading, spacing: 4) {
Text("Order Notes")
.font(.caption.weight(.semibold))
.foregroundColor(.yellow)
Text(d.orderRemarks)
.font(.callout)
}
Spacer()
}
.padding(16)
.background(Color.yellow.opacity(0.1))
.cornerRadius(12)
}
// MARK: - Bottom Bar
private var bottomBar: some View {
HStack {
if showAcceptButton {
Button { showAcceptAlert = true } label: {
Label("Accept Task", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
if showCompleteButton {
Button { showCompleteAlert = true } label: {
Label("Complete Task", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
}
// MARK: - Error
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadDetails() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions
private func loadDetails() async {
isLoading = true
error = nil
do {
let d = try await APIService.shared.getTaskDetails(taskId: task.taskId)
details = d
isLoading = false
if showCompleteButton && !d.beaconUUID.isEmpty {
startBeaconScanning(d.beaconUUID)
}
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func startBeaconScanning(_ uuid: String) {
let scanner = BeaconScanner(
targetUUID: uuid,
onBeaconDetected: { [self] _ in
if !beaconDetected && !autoCompleting {
beaconDetected = true
autoCompleting = true
beaconScanner?.stopScanning()
showAutoCompleteDialog = true
}
},
onBluetoothOff: { bluetoothOff = true },
onPermissionDenied: { self.error = "Bluetooth permission is required for auto-complete. Please enable it in Settings." }
)
beaconScanner = scanner
scanner.startScanning()
}
private func acceptTask() {
Task {
do {
try await APIService.shared.acceptTask(taskId: task.taskId)
if task.isChat {
showingChat = true
} else {
dismiss()
}
} catch {
self.error = error.localizedDescription
}
}
}
private func completeTask() {
Task {
do {
try await APIService.shared.completeTask(taskId: task.taskId)
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
private func callCustomer(_ phone: String) {
guard let url = URL(string: "tel:\(phone)") else { return }
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
private func openMaps(_ d: TaskDetails) {
guard d.deliveryLat != 0, d.deliveryLng != 0 else { return }
let urlStr = "https://www.google.com/maps/dir/?api=1&destination=\(d.deliveryLat),\(d.deliveryLng)"
guard let url = URL(string: urlStr) else { return }
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
// MARK: - Auto-Complete Countdown
struct AutoCompleteCountdownView: View {
let taskId: Int
let onResult: (String) -> Void
@State private var countdown = 3
@State private var completing = false
@State private var message = "Auto-completing in"
var body: some View {
VStack(spacing: 24) {
Image(systemName: completing ? "checkmark.circle.fill" : "timer")
.font(.system(size: 48))
.foregroundColor(completing ? .green : .payfritGreen)
Text(completing ? "Task Completed!" : "Auto-Complete")
.font(.title2.bold())
Text(completing ? message : "\(message) \(countdown)...")
.font(.body)
if !completing {
Button("Cancel") { onResult("cancelled") }
.buttonStyle(.bordered)
}
}
.padding(32)
.presentationDetents([.height(280)])
.task { await startCountdown() }
}
private func startCountdown() async {
for i in stride(from: 3, through: 1, by: -1) {
countdown = i
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
completing = true
message = "Completing task..."
do {
try await APIService.shared.completeTask(taskId: taskId)
message = "Closing this window now"
try? await Task.sleep(nanoseconds: 1_000_000_000)
onResult("success")
} catch {
onResult("error")
}
}
}
// MARK: - Flow Layout (for table members)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
proposal: .unspecified)
}
}
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var totalWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
totalWidth = max(totalWidth, currentX)
}
return (positions, CGSize(width: totalWidth, height: currentY + lineHeight))
}
}

View file

@ -0,0 +1,219 @@
import SwiftUI
struct TaskListScreen: View {
var businessName: String = ""
@EnvironmentObject var appState: AppState
@State private var tasks: [WorkTask] = []
@State private var isLoading = true
@State private var error: String?
@State private var lastRefresh = Date()
@State private var selectedTask: WorkTask?
@State private var showingMyTasks = false
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView()
} else if let error = error {
errorView(error)
} else if tasks.isEmpty {
emptyView
} else {
taskListView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 16)
}
.navigationTitle(businessName.isEmpty ? "Available Tasks" : businessName)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { loadTasks() } label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Label(appState.userName ?? "Worker", systemImage: "person.circle")
.disabled(true)
Divider()
Button { showingMyTasks = true } label: {
Label("My Tasks", systemImage: "checkmark.circle")
}
Button(role: .destructive) { logout() } label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task { loadTasks() }
.onReceive(refreshTimer) { _ in loadTasks(silent: true) }
}
// MARK: - List
private var taskListView: some View {
List(tasks) { task in
NavigationLink {
TaskDetailScreen(task: task, showAcceptButton: true)
} label: {
taskRow(task)
}
}
.listStyle(.plain)
.refreshable { loadTasks() }
}
private func taskRow(_ task: WorkTask) -> some View {
HStack(spacing: 12) {
// Left color bar
Rectangle()
.fill(task.color)
.frame(width: 4)
.cornerRadius(2)
// Chat icon or category icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(task.isChat ? Color.payfritGreen.opacity(0.15) : task.color.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill")
.foregroundColor(task.isChat ? .payfritGreen : task.color)
.font(.subheadline)
}
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.callout.weight(.semibold))
if !task.locationDisplay.isEmpty {
HStack(spacing: 4) {
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
.font(.caption2)
.foregroundColor(.payfritGreen)
Text(task.locationDisplay)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
} else if !task.details.isEmpty {
Text(task.details)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
HStack(spacing: 8) {
Text(task.categoryName)
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(task.color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(task.color.opacity(0.2))
.cornerRadius(8)
if task.isChat {
Text("Chat")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.payfritGreen)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.payfritGreen.opacity(0.15))
.cornerRadius(8)
}
Text(task.timeAgo)
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
// MARK: - Empty / Error
private var emptyView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle")
.font(.system(size: 64))
.foregroundColor(.secondary)
Text("No pending tasks")
.font(.title3)
.foregroundColor(.secondary)
Text("Check back soon!")
.foregroundColor(.secondary)
}
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { loadTasks() }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions
private func loadTasks(silent: Bool = false) {
if !silent {
isLoading = true
error = nil
}
Task {
do {
let result = try await APIService.shared.listPendingTasks()
tasks = result
isLoading = false
lastRefresh = Date()
} catch {
if !silent {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
private func logout() {
Task {
await APIService.shared.logout()
await AuthStorage.shared.clearAuth()
appState.clearAuth()
}
}
}

Some files were not shown because too many files have changed in this diff Show more