Initial commit: Payfrit Works iOS native app
This commit is contained in:
commit
3d057b481d
2599 changed files with 13880 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcworkspacedata
|
||||||
|
xcuserdata/
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
*.hmap
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
484
PayfritWorks.xcodeproj/project.pbxproj
Normal file
484
PayfritWorks.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.294",
|
||||||
|
"green" : "0.698",
|
||||||
|
"red" : "0.133"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "appicon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
PayfritWorks/Assets.xcassets/AppIcon.appiconset/appicon.png
Normal file
BIN
PayfritWorks/Assets.xcassets/AppIcon.appiconset/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
6
PayfritWorks/Assets.xcassets/Contents.json
Normal file
6
PayfritWorks/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
16
PayfritWorks/Assets.xcassets/PayfritLogo.imageset/Contents.json
vendored
Normal file
16
PayfritWorks/Assets.xcassets/PayfritLogo.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
PayfritWorks/Assets.xcassets/PayfritLogo.imageset/payfrit-logo-white.svg
vendored
Normal file
26
PayfritWorks/Assets.xcassets/PayfritLogo.imageset/payfrit-logo-white.svg
vendored
Normal 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 |
16
PayfritWorks/Assets.xcassets/PayfritLogoLight.imageset/Contents.json
vendored
Normal file
16
PayfritWorks/Assets.xcassets/PayfritLogoLight.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
PayfritWorks/Assets.xcassets/PayfritLogoLight.imageset/payfrit-logo-light.svg
vendored
Normal file
28
PayfritWorks/Assets.xcassets/PayfritLogoLight.imageset/payfrit-logo-light.svg
vendored
Normal 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
35
PayfritWorks/Info.plist
Normal 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>
|
||||||
53
PayfritWorks/Models/ChatMessage.swift
Normal file
53
PayfritWorks/Models/ChatMessage.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
PayfritWorks/Models/Employment.swift
Normal file
35
PayfritWorks/Models/Employment.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
PayfritWorks/Models/OrderLineItem.swift
Normal file
30
PayfritWorks/Models/OrderLineItem.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
PayfritWorks/Models/TableMember.swift
Normal file
36
PayfritWorks/Models/TableMember.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
106
PayfritWorks/Models/Task.swift
Normal file
106
PayfritWorks/Models/Task.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
136
PayfritWorks/Models/TaskDetails.swift
Normal file
136
PayfritWorks/Models/TaskDetails.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
197
PayfritWorks/Models/TierStatus.swift
Normal file
197
PayfritWorks/Models/TierStatus.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
PayfritWorks/PayfritWorksApp.swift
Normal file
17
PayfritWorks/PayfritWorksApp.swift
Normal 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)
|
||||||
|
}
|
||||||
593
PayfritWorks/Services/APIService.swift
Normal file
593
PayfritWorks/Services/APIService.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
90
PayfritWorks/Services/AuthStorage.swift
Normal file
90
PayfritWorks/Services/AuthStorage.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
PayfritWorks/Services/BeaconScanner.swift
Normal file
152
PayfritWorks/Services/BeaconScanner.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
201
PayfritWorks/Services/ChatService.swift
Normal file
201
PayfritWorks/Services/ChatService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
PayfritWorks/ViewModels/AppState.swift
Normal file
41
PayfritWorks/ViewModels/AppState.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
353
PayfritWorks/Views/AccountScreen.swift
Normal file
353
PayfritWorks/Views/AccountScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
352
PayfritWorks/Views/BusinessSelectionScreen.swift
Normal file
352
PayfritWorks/Views/BusinessSelectionScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
PayfritWorks/Views/ChatScreen.swift
Normal file
389
PayfritWorks/Views/ChatScreen.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
138
PayfritWorks/Views/LoginScreen.swift
Normal file
138
PayfritWorks/Views/LoginScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
PayfritWorks/Views/MyTasksScreen.swift
Normal file
281
PayfritWorks/Views/MyTasksScreen.swift
Normal 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
|
||||||
|
}
|
||||||
86
PayfritWorks/Views/RootView.swift
Normal file
86
PayfritWorks/Views/RootView.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
625
PayfritWorks/Views/TaskDetailScreen.swift
Normal file
625
PayfritWorks/Views/TaskDetailScreen.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
219
PayfritWorks/Views/TaskListScreen.swift
Normal file
219
PayfritWorks/Views/TaskListScreen.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue