Add ios-marketing idiom for App Store icon display
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
99464d88ce
commit
c71b9f7dea
18 changed files with 1853 additions and 262 deletions
14
ExportOptions.plist
Normal file
14
ExportOptions.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?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>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>destination</key>
|
||||||
|
<string>upload</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
B01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000031; };
|
B01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000031; };
|
||||||
B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; };
|
B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; };
|
||||||
B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; };
|
B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; };
|
||||||
|
B01000000034 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000034; };
|
||||||
|
|
||||||
/* Widgets */
|
/* Widgets */
|
||||||
B01000000050 /* DevRibbon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000050; };
|
B01000000050 /* DevRibbon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000050; };
|
||||||
|
|
@ -40,6 +41,8 @@
|
||||||
B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; };
|
B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; };
|
||||||
B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; };
|
B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; };
|
||||||
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
|
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
|
||||||
|
B01000000048 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000048; };
|
||||||
|
B01000000049 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000049; };
|
||||||
|
|
||||||
/* Resources */
|
/* Resources */
|
||||||
B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; };
|
B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; };
|
||||||
|
|
@ -71,6 +74,7 @@
|
||||||
B02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.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>"; };
|
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>"; };
|
B02000000033 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = "<group>"; };
|
||||||
|
B02000000034 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
|
||||||
|
|
||||||
/* Widgets */
|
/* Widgets */
|
||||||
B02000000050 /* DevRibbon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevRibbon.swift; sourceTree = "<group>"; };
|
B02000000050 /* DevRibbon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevRibbon.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -84,6 +88,8 @@
|
||||||
B02000000045 /* MyTasksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksScreen.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>"; };
|
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>"; };
|
B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; };
|
||||||
|
B02000000048 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = "<group>"; };
|
||||||
|
B02000000049 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = "<group>"; };
|
||||||
|
|
||||||
/* Resources */
|
/* Resources */
|
||||||
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
|
@ -154,6 +160,7 @@
|
||||||
B02000000031 /* AuthStorage.swift */,
|
B02000000031 /* AuthStorage.swift */,
|
||||||
B02000000032 /* BeaconScanner.swift */,
|
B02000000032 /* BeaconScanner.swift */,
|
||||||
B02000000033 /* ChatService.swift */,
|
B02000000033 /* ChatService.swift */,
|
||||||
|
B02000000034 /* BeaconShardPool.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -177,6 +184,8 @@
|
||||||
B02000000045 /* MyTasksScreen.swift */,
|
B02000000045 /* MyTasksScreen.swift */,
|
||||||
B02000000046 /* ChatScreen.swift */,
|
B02000000046 /* ChatScreen.swift */,
|
||||||
B02000000047 /* AccountScreen.swift */,
|
B02000000047 /* AccountScreen.swift */,
|
||||||
|
B02000000048 /* AboutScreen.swift */,
|
||||||
|
B02000000049 /* ProfileScreen.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -291,6 +300,7 @@
|
||||||
B01000000031 /* AuthStorage.swift in Sources */,
|
B01000000031 /* AuthStorage.swift in Sources */,
|
||||||
B01000000032 /* BeaconScanner.swift in Sources */,
|
B01000000032 /* BeaconScanner.swift in Sources */,
|
||||||
B01000000033 /* ChatService.swift in Sources */,
|
B01000000033 /* ChatService.swift in Sources */,
|
||||||
|
B01000000034 /* BeaconShardPool.swift in Sources */,
|
||||||
B01000000050 /* DevRibbon.swift in Sources */,
|
B01000000050 /* DevRibbon.swift in Sources */,
|
||||||
B01000000040 /* RootView.swift in Sources */,
|
B01000000040 /* RootView.swift in Sources */,
|
||||||
B01000000041 /* LoginScreen.swift in Sources */,
|
B01000000041 /* LoginScreen.swift in Sources */,
|
||||||
|
|
@ -300,6 +310,8 @@
|
||||||
B01000000045 /* MyTasksScreen.swift in Sources */,
|
B01000000045 /* MyTasksScreen.swift in Sources */,
|
||||||
B01000000046 /* ChatScreen.swift in Sources */,
|
B01000000046 /* ChatScreen.swift in Sources */,
|
||||||
B01000000047 /* AccountScreen.swift in Sources */,
|
B01000000047 /* AccountScreen.swift in Sources */,
|
||||||
|
B01000000048 /* AboutScreen.swift in Sources */,
|
||||||
|
B01000000049 /* ProfileScreen.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -429,7 +441,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = U83YL8VRF3;
|
DEVELOPMENT_TEAM = U83YL8VRF3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
||||||
|
|
@ -461,7 +473,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = U83YL8VRF3;
|
DEVELOPMENT_TEAM = U83YL8VRF3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "appicon.png",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,19 @@
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ struct WorkTask: Identifiable {
|
||||||
let sourceId: Int
|
let sourceId: Int
|
||||||
let categoryName: String
|
let categoryName: String
|
||||||
let categoryColor: String
|
let categoryColor: String
|
||||||
|
let taskTypeName: String
|
||||||
|
let taskTypeColor: String
|
||||||
|
let orderTotal: Double
|
||||||
|
|
||||||
// Location (may be included in list responses)
|
// Location (may be included in list responses)
|
||||||
let servicePointName: String
|
let servicePointName: String
|
||||||
|
|
@ -34,8 +37,12 @@ struct WorkTask: Identifiable {
|
||||||
sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0
|
sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0
|
||||||
// Server key varies: CategoryName, Name, TaskCategoryName — try all
|
// 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"
|
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
|
// Category color as fallback
|
||||||
categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? Self.nonEmpty(json["TaskTypeColor"]) ?? "#888888"
|
categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
|
||||||
|
taskTypeName = (json["TaskTypeName"] as? String) ?? ""
|
||||||
|
// TaskType color is the primary color source
|
||||||
|
taskTypeColor = Self.nonEmpty(json["TaskTypeColor"]) ?? ""
|
||||||
|
orderTotal = Self.parseDouble(json["OrderTotal"]) ?? 0
|
||||||
servicePointName = (json["ServicePointName"] as? String) ?? ""
|
servicePointName = (json["ServicePointName"] as? String) ?? ""
|
||||||
deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
|
deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
|
||||||
}
|
}
|
||||||
|
|
@ -46,8 +53,10 @@ struct WorkTask: Identifiable {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Primary color from TaskType, falls back to category color
|
||||||
var color: Color {
|
var color: Color {
|
||||||
let hex = categoryColor.replacingOccurrences(of: "#", with: "")
|
let hexSource = !taskTypeColor.isEmpty ? taskTypeColor : categoryColor
|
||||||
|
let hex = hexSource.replacingOccurrences(of: "#", with: "")
|
||||||
guard hex.count == 6, let val = UInt64(hex, radix: 16) else {
|
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: 0.53, green: 0.53, blue: 0.53)
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +87,9 @@ struct WorkTask: Identifiable {
|
||||||
return "\(hours / 24)d ago"
|
return "\(hours / 24)d ago"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isChat: Bool { taskTypeId == 2 }
|
/// Chat or Call Team Member tasks - both involve customer interaction
|
||||||
|
var isChat: Bool { taskTypeId == 2 || taskTypeId == 6 }
|
||||||
|
var isCash: Bool { taskTypeName.lowercased().contains("cash") }
|
||||||
|
|
||||||
// MARK: - Flexible parsing helpers
|
// MARK: - Flexible parsing helpers
|
||||||
|
|
||||||
|
|
@ -88,6 +99,15 @@ struct WorkTask: Identifiable {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
static func parseInt(_ value: Any?) -> Int? {
|
static func parseInt(_ value: Any?) -> Int? {
|
||||||
guard let value = value else { return nil }
|
guard let value = value else { return nil }
|
||||||
if let v = value as? Int { return v }
|
if let v = value as? Int { return v }
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ struct TaskDetails {
|
||||||
let statusId: Int
|
let statusId: Int
|
||||||
let categoryName: String
|
let categoryName: String
|
||||||
let categoryColor: String
|
let categoryColor: String
|
||||||
|
let taskTypeName: String
|
||||||
|
let orderTotal: Double
|
||||||
|
|
||||||
// Order info
|
// Order info
|
||||||
let orderId: Int
|
let orderId: Int
|
||||||
|
|
@ -47,6 +49,8 @@ struct TaskDetails {
|
||||||
statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
|
statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
|
||||||
categoryName = WorkTask.nonEmpty(json["CategoryName"]) ?? WorkTask.nonEmpty(json["Name"]) ?? WorkTask.nonEmpty(json["TaskCategoryName"]) ?? "General"
|
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"
|
categoryColor = WorkTask.nonEmpty(json["CategoryColor"]) ?? WorkTask.nonEmpty(json["Color"]) ?? WorkTask.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
|
||||||
|
taskTypeName = (json["TaskTypeName"] as? String) ?? ""
|
||||||
|
orderTotal = Self.parseDouble(json["OrderTotal"]) ?? 0
|
||||||
orderId = WorkTask.parseInt(json["OrderID"]) ?? 0
|
orderId = WorkTask.parseInt(json["OrderID"]) ?? 0
|
||||||
orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? ""
|
orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? ""
|
||||||
if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty {
|
if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty {
|
||||||
|
|
@ -64,7 +68,25 @@ struct TaskDetails {
|
||||||
customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
|
customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
|
||||||
customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
|
customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
|
||||||
customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? ""
|
customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? ""
|
||||||
let rawPhoto = (json["CustomerPhotoUrl"] as? String) ?? (json["PhotoUrl"] as? String) ?? ""
|
|
||||||
|
// Check multiple key variations for customer photo URL
|
||||||
|
let photoKeys = ["CustomerPhotoUrl", "CustomerPhotoURL", "CUSTOMERPHOTOURL",
|
||||||
|
"PhotoUrl", "PhotoURL", "PHOTOURL", "Photo_Url", "PHOTO_URL",
|
||||||
|
"AvatarUrl", "AvatarURL", "AVATARURL", "Avatar_Url", "AVATAR_URL",
|
||||||
|
"CustomerAvatar", "CUSTOMERAVATAR", "UserPhotoUrl", "USERPHOTOURL"]
|
||||||
|
var rawPhoto = ""
|
||||||
|
for key in photoKeys {
|
||||||
|
if let url = json[key] as? String, !url.isEmpty {
|
||||||
|
rawPhoto = url
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no photo URL but we have customerUserId, construct direct avatar URL
|
||||||
|
if rawPhoto.isEmpty && customerUserId > 0 {
|
||||||
|
rawPhoto = "/uploads/avatars/\(customerUserId).jpg"
|
||||||
|
}
|
||||||
|
|
||||||
customerPhotoUrl = APIService.resolvePhotoUrl(rawPhoto)
|
customerPhotoUrl = APIService.resolvePhotoUrl(rawPhoto)
|
||||||
beaconUUID = (json["BeaconUUID"] as? String) ?? ""
|
beaconUUID = (json["BeaconUUID"] as? String) ?? ""
|
||||||
|
|
||||||
|
|
@ -113,6 +135,7 @@ struct TaskDetails {
|
||||||
return "No location specified"
|
return "No location specified"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isCash: Bool { taskTypeName.lowercased().contains("cash") }
|
||||||
var isDelivery: Bool { !deliveryAddress.isEmpty }
|
var isDelivery: Bool { !deliveryAddress.isEmpty }
|
||||||
var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty }
|
var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.payfrit.works", category: "API")
|
||||||
|
|
||||||
// MARK: - Dev Flag
|
// MARK: - Dev Flag
|
||||||
|
|
||||||
/// Master flag: flip to `false` for production builds.
|
/// Master flag: flip to `false` for production builds.
|
||||||
/// Controls API endpoint, dev banner, and magic OTPs.
|
/// Controls API endpoint, dev banner, and magic OTPs.
|
||||||
let IS_DEV = true
|
let IS_DEV = false
|
||||||
|
|
||||||
// MARK: - API Errors
|
// MARK: - API Errors
|
||||||
|
|
||||||
|
|
@ -37,6 +40,13 @@ struct LoginResponse {
|
||||||
let photoUrl: String
|
let photoUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OTP Response
|
||||||
|
|
||||||
|
struct SendOtpResponse {
|
||||||
|
let uuid: String
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Chat Messages Result
|
// MARK: - Chat Messages Result
|
||||||
|
|
||||||
struct ChatMessagesResult {
|
struct ChatMessagesResult {
|
||||||
|
|
@ -108,6 +118,35 @@ actor APIService {
|
||||||
throw APIError.decodingError("Non-JSON response")
|
throw APIError.decodingError("Non-JSON response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getJSON(_ path: String) async throws -> [String: Any] {
|
||||||
|
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
|
||||||
|
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
if let token = userToken, !token.isEmpty {
|
||||||
|
request.setValue(token, forHTTPHeaderField: "X-User-Token")
|
||||||
|
}
|
||||||
|
if businessId > 0 {
|
||||||
|
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let json = tryDecodeJSON(data) {
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
throw APIError.decodingError("Non-JSON response")
|
||||||
|
}
|
||||||
|
|
||||||
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
|
||||||
// Try direct parse
|
// Try direct parse
|
||||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
|
@ -162,7 +201,8 @@ actor APIService {
|
||||||
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
|
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
|
||||||
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
|
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
|
||||||
"AccountID", "ACCOUNT_ID", "URL", "url",
|
"AccountID", "ACCOUNT_ID", "URL", "url",
|
||||||
"CHAT_CLOSED", "chat_closed", "PayCents"
|
"CHAT_CLOSED", "chat_closed", "PayCents",
|
||||||
|
"TaskTypeName", "OrderTotal", "CashReceivedCents"
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Build a lookup: stripped key (no underscores, lowercased) → expected PascalCase key.
|
/// Build a lookup: stripped key (no underscores, lowercased) → expected PascalCase key.
|
||||||
|
|
@ -250,6 +290,57 @@ actor APIService {
|
||||||
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OTP Login
|
||||||
|
|
||||||
|
func sendLoginOtp(phone: String) async throws -> SendOtpResponse {
|
||||||
|
let json = try await postJSON("/auth/loginOTP.cfm", payload: [
|
||||||
|
"phone": phone
|
||||||
|
])
|
||||||
|
|
||||||
|
guard ok(json) else {
|
||||||
|
throw APIError.serverError("Failed to send code: \(err(json))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
let uuid = (data["UUID"] as? String) ?? (data["uuid"] as? String) ?? ""
|
||||||
|
let message = (data["MESSAGE"] as? String) ?? (data["message"] as? String) ?? "OTP sent"
|
||||||
|
|
||||||
|
return SendOtpResponse(uuid: uuid, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
|
||||||
|
let json = try await postJSON("/auth/verifyLoginOTP.cfm", payload: [
|
||||||
|
"uuid": uuid,
|
||||||
|
"otp": otp
|
||||||
|
])
|
||||||
|
|
||||||
|
guard ok(json) else {
|
||||||
|
throw APIError.serverError("Invalid code")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
let uid = (data["UserID"] as? Int)
|
||||||
|
?? Int(data["UserID"] as? String ?? "")
|
||||||
|
?? (data["USERID"] as? Int)
|
||||||
|
?? 0
|
||||||
|
let token = (data["Token"] as? String)
|
||||||
|
?? (data["TOKEN"] as? String)
|
||||||
|
?? (data["token"] as? String)
|
||||||
|
?? ""
|
||||||
|
let firstName = (data["FirstName"] as? String)
|
||||||
|
?? (data["FIRSTNAME"] as? String)
|
||||||
|
?? (data["USERFIRSTNAME"] as? String)
|
||||||
|
?? ""
|
||||||
|
let photoUrl = (data["PhotoUrl"] as? String)
|
||||||
|
?? (data["PHOTOURL"] as? String)
|
||||||
|
?? ""
|
||||||
|
|
||||||
|
self.userToken = token
|
||||||
|
self.userId = uid
|
||||||
|
|
||||||
|
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
|
||||||
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
userToken = nil
|
userToken = nil
|
||||||
userId = nil
|
userId = nil
|
||||||
|
|
@ -328,22 +419,27 @@ actor APIService {
|
||||||
return arr.map { WorkTask(json: $0) }
|
return arr.map { WorkTask(json: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeTask(taskId: Int) async throws {
|
func completeTask(taskId: Int, cashReceivedCents: Int? = nil) async throws {
|
||||||
let json = try await postJSON("/tasks/complete.cfm", payload: [
|
var payload: [String: Any] = [
|
||||||
"TaskID": taskId,
|
"TaskID": taskId,
|
||||||
"UserID": userId ?? 0
|
"UserID": userId ?? 0
|
||||||
])
|
]
|
||||||
|
if let cents = cashReceivedCents {
|
||||||
|
payload["CashReceivedCents"] = cents
|
||||||
|
}
|
||||||
|
let json = try await postJSON("/tasks/complete.cfm", payload: payload)
|
||||||
guard ok(json) else {
|
guard ok(json) else {
|
||||||
throw APIError.serverError("Failed to complete task: \(err(json))")
|
throw APIError.serverError("Failed to complete task: \(err(json))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeChat(taskId: Int) async throws {
|
func closeChat(taskId: Int) async throws {
|
||||||
let json = try await postJSON("/tasks/completeChat.cfm", payload: [
|
let payload: [String: Any] = [
|
||||||
"TaskID": taskId
|
"TaskID": taskId
|
||||||
])
|
]
|
||||||
|
let json = try await postJSON("/tasks/completeChat.cfm", payload: payload)
|
||||||
guard ok(json) else {
|
guard ok(json) else {
|
||||||
throw APIError.serverError("Failed to close chat: \(err(json))")
|
throw APIError.serverError("Close chat failed: \(err(json))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,6 +466,25 @@ actor APIService {
|
||||||
throw APIError.serverError("Invalid task details response")
|
throw APIError.serverError("Invalid task details response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug: log ALL fields to find customer data
|
||||||
|
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||||
|
let debugFile = docs.appendingPathComponent("task_debug.txt")
|
||||||
|
var lines: [String] = ["=== ALL KEYS ==="]
|
||||||
|
for key in taskJson.keys.sorted() {
|
||||||
|
let val = taskJson[key]
|
||||||
|
if let str = val as? String {
|
||||||
|
lines.append("\(key): \(str)")
|
||||||
|
} else if let num = val as? Int {
|
||||||
|
lines.append("\(key): \(num)")
|
||||||
|
} else if val is [[String: Any]] {
|
||||||
|
lines.append("\(key): [array]")
|
||||||
|
} else {
|
||||||
|
lines.append("\(key): \(val ?? "nil")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try? lines.joined(separator: "\n").write(to: debugFile, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
let details = TaskDetails(json: taskJson)
|
let details = TaskDetails(json: taskJson)
|
||||||
return details
|
return details
|
||||||
}
|
}
|
||||||
|
|
@ -474,6 +589,108 @@ actor APIService {
|
||||||
return try JSONDecoder().decode(LedgerResponse.self, from: data)
|
return try JSONDecoder().decode(LedgerResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar
|
||||||
|
|
||||||
|
func getAvatarUrl() async throws -> String? {
|
||||||
|
let json = try await getJSON("/auth/avatar.cfm")
|
||||||
|
print("[Avatar] Response: \(json)")
|
||||||
|
guard ok(json) else {
|
||||||
|
print("[Avatar] Response not OK")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
print("[Avatar] Data: \(data)")
|
||||||
|
|
||||||
|
// Try all possible key variations for avatar URL
|
||||||
|
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
||||||
|
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
|
||||||
|
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
||||||
|
for key in keys {
|
||||||
|
if let url = data[key] as? String, !url.isEmpty {
|
||||||
|
let resolved = Self.resolvePhotoUrl(url)
|
||||||
|
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)")
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if let url = json[key] as? String, !url.isEmpty {
|
||||||
|
let resolved = Self.resolvePhotoUrl(url)
|
||||||
|
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)")
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("[Avatar] No avatar URL found in response")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get avatar URL for any user by their userId
|
||||||
|
func getUserAvatarUrl(userId: Int) async throws -> String? {
|
||||||
|
// Try the avatar endpoint with UserID
|
||||||
|
let json = try await postJSON("/auth/avatar.cfm", payload: ["UserID": userId])
|
||||||
|
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)")
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
|
||||||
|
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
|
||||||
|
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
|
||||||
|
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
|
||||||
|
for key in keys {
|
||||||
|
if let url = data[key] as? String, !url.isEmpty {
|
||||||
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
||||||
|
return Self.resolvePhotoUrl(url)
|
||||||
|
}
|
||||||
|
if let url = json[key] as? String, !url.isEmpty {
|
||||||
|
print("[Avatar] Found avatar for userId \(userId): \(url)")
|
||||||
|
return Self.resolvePhotoUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct direct avatar URL for a user (fallback pattern)
|
||||||
|
nonisolated static func directAvatarUrl(userId: Int) -> String {
|
||||||
|
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
|
||||||
|
return "\(baseDomain)/uploads/avatars/\(userId).jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Beacon Sharding
|
||||||
|
|
||||||
|
func resolveServicePoint(uuid: String, major: Int, minor: Int) async throws -> Int {
|
||||||
|
let json = try await postJSON("/beacon-sharding/resolve_servicepoint.cfm", payload: [
|
||||||
|
"UUID": uuid,
|
||||||
|
"Major": major,
|
||||||
|
"Minor": minor
|
||||||
|
])
|
||||||
|
guard ok(json) else {
|
||||||
|
throw APIError.serverError("Failed to resolve service point: \(err(json))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
if let spId = data["ServicePointID"] as? Int { return spId }
|
||||||
|
if let spId = data["SERVICEPOINTID"] as? Int { return spId }
|
||||||
|
if let spId = data["servicePointId"] as? Int { return spId }
|
||||||
|
if let spId = json["ServicePointID"] as? Int { return spId }
|
||||||
|
if let spId = json["SERVICEPOINTID"] as? Int { return spId }
|
||||||
|
|
||||||
|
throw APIError.decodingError("ServicePointID not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBusiness(uuid: String, major: Int) async throws -> (businessId: Int, businessName: String) {
|
||||||
|
let json = try await postJSON("/beacon-sharding/resolve_business.cfm", payload: [
|
||||||
|
"UUID": uuid,
|
||||||
|
"Major": major
|
||||||
|
])
|
||||||
|
guard ok(json) else {
|
||||||
|
throw APIError.serverError("Failed to resolve business: \(err(json))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = json["DATA"] as? [String: Any] ?? json
|
||||||
|
let bizId = (data["BusinessID"] as? Int) ?? (data["BUSINESSID"] as? Int) ?? (json["BusinessID"] as? Int) ?? 0
|
||||||
|
let bizName = (data["BusinessName"] as? String) ?? (data["BUSINESSNAME"] as? String) ?? (json["BusinessName"] as? String) ?? ""
|
||||||
|
|
||||||
|
return (businessId: bizId, businessName: bizName)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
/// Returns raw JSON string for a given endpoint (for debugging key issues)
|
/// Returns raw JSON string for a given endpoint (for debugging key issues)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import UIKit
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
/// Beacon scanner for task auto-completion.
|
/// Beacon scanner for task auto-completion using the Payfrit shard system.
|
||||||
/// Scans for a specific UUID and triggers callback after dwell time is met.
|
/// Scans for ALL shard UUIDs and resolves via API to find the target ServicePoint.
|
||||||
final class BeaconScanner: NSObject, ObservableObject {
|
final class BeaconScanner: NSObject, ObservableObject {
|
||||||
private let targetUUID: String
|
private let targetServicePointId: Int
|
||||||
private let onBeaconDetected: (Double) -> Void
|
private let onBeaconDetected: (Double) -> Void
|
||||||
private let onBluetoothOff: (() -> Void)?
|
private let onBluetoothOff: (() -> Void)?
|
||||||
private let onPermissionDenied: (() -> Void)?
|
private let onPermissionDenied: (() -> Void)?
|
||||||
|
|
@ -13,37 +13,39 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
@Published var isScanning = false
|
@Published var isScanning = false
|
||||||
|
|
||||||
private var locationManager: CLLocationManager?
|
private var locationManager: CLLocationManager?
|
||||||
private var activeConstraint: CLBeaconIdentityConstraint?
|
private var activeConstraints: [CLBeaconIdentityConstraint] = []
|
||||||
private var checkTimer: Timer?
|
private var checkTimer: Timer?
|
||||||
|
|
||||||
// RSSI samples for dwell time enforcement
|
// RSSI samples for dwell time enforcement
|
||||||
private var rssiSamples: [Int] = []
|
private var rssiSamples: [Int] = []
|
||||||
private let minSamplesToConfirm = 5 // ~5 seconds
|
private let minSamplesToConfirm = 5 // ~5 seconds at 1Hz ranging
|
||||||
private let rssiThreshold = -75
|
private let rssiThreshold = -75
|
||||||
|
|
||||||
init(targetUUID: String,
|
// Track resolved beacons to avoid repeated API calls
|
||||||
|
private var resolvedBeacons: [String: Int] = [:] // "uuid-major-minor" -> servicePointId
|
||||||
|
private var pendingResolutions: Set<String> = []
|
||||||
|
|
||||||
|
init(targetServicePointId: Int,
|
||||||
onBeaconDetected: @escaping (Double) -> Void,
|
onBeaconDetected: @escaping (Double) -> Void,
|
||||||
onBluetoothOff: (() -> Void)? = nil,
|
onBluetoothOff: (() -> Void)? = nil,
|
||||||
onPermissionDenied: (() -> Void)? = nil) {
|
onPermissionDenied: (() -> Void)? = nil) {
|
||||||
self.targetUUID = targetUUID
|
self.targetServicePointId = targetServicePointId
|
||||||
self.onBeaconDetected = onBeaconDetected
|
self.onBeaconDetected = onBeaconDetected
|
||||||
self.onBluetoothOff = onBluetoothOff
|
self.onBluetoothOff = onBluetoothOff
|
||||||
self.onPermissionDenied = onPermissionDenied
|
self.onPermissionDenied = onPermissionDenied
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UUID formatting
|
// Legacy init for backwards compatibility with UUID-based scanning
|
||||||
|
convenience init(targetUUID: String,
|
||||||
private func formatUUID(_ uuid: String) -> String {
|
onBeaconDetected: @escaping (Double) -> Void,
|
||||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
onBluetoothOff: (() -> Void)? = nil,
|
||||||
guard clean.count == 32 else { return uuid }
|
onPermissionDenied: (() -> Void)? = nil) {
|
||||||
let i = clean.startIndex
|
// Use 0 as a placeholder - this path won't use ServicePoint matching
|
||||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
self.init(targetServicePointId: 0,
|
||||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
onBeaconDetected: onBeaconDetected,
|
||||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
onBluetoothOff: onBluetoothOff,
|
||||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
onPermissionDenied: onPermissionDenied)
|
||||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
|
||||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Start/Stop
|
// MARK: - Start/Stop
|
||||||
|
|
@ -51,26 +53,30 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
func startScanning() {
|
func startScanning() {
|
||||||
guard !isScanning else { return }
|
guard !isScanning else { return }
|
||||||
|
|
||||||
let formatted = formatUUID(targetUUID)
|
|
||||||
guard let uuid = UUID(uuidString: formatted) else { return }
|
|
||||||
|
|
||||||
locationManager = CLLocationManager()
|
locationManager = CLLocationManager()
|
||||||
locationManager?.delegate = self
|
locationManager?.delegate = self
|
||||||
|
|
||||||
let status = locationManager!.authorizationStatus
|
let status = locationManager!.authorizationStatus
|
||||||
|
if status == .notDetermined {
|
||||||
|
locationManager?.requestWhenInUseAuthorization()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||||
onPermissionDenied?()
|
onPermissionDenied?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start ranging for ALL shard UUIDs
|
||||||
|
for uuid in BeaconShardPool.uuids {
|
||||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||||
activeConstraint = constraint
|
activeConstraints.append(constraint)
|
||||||
locationManager?.startRangingBeacons(satisfying: constraint)
|
locationManager?.startRangingBeacons(satisfying: constraint)
|
||||||
|
}
|
||||||
|
|
||||||
isScanning = true
|
isScanning = true
|
||||||
rssiSamples.removeAll()
|
rssiSamples.removeAll()
|
||||||
|
|
||||||
// Idle timer disabled so screen stays on during scanning
|
// Keep screen on during scanning
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
}
|
}
|
||||||
|
|
@ -82,14 +88,16 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
self?.onBluetoothOff?()
|
self?.onBluetoothOff?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopScanning() {
|
func stopScanning() {
|
||||||
isScanning = false
|
isScanning = false
|
||||||
if let constraint = activeConstraint {
|
for constraint in activeConstraints {
|
||||||
locationManager?.stopRangingBeacons(satisfying: constraint)
|
locationManager?.stopRangingBeacons(satisfying: constraint)
|
||||||
}
|
}
|
||||||
activeConstraint = nil
|
activeConstraints.removeAll()
|
||||||
checkTimer?.invalidate()
|
checkTimer?.invalidate()
|
||||||
checkTimer = nil
|
checkTimer = nil
|
||||||
rssiSamples.removeAll()
|
rssiSamples.removeAll()
|
||||||
|
|
@ -106,6 +114,39 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
locationManager = nil
|
locationManager = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Beacon Resolution
|
||||||
|
|
||||||
|
private func beaconKey(_ beacon: CLBeacon) -> String {
|
||||||
|
return "\(beacon.uuid.uuidString)-\(beacon.major)-\(beacon.minor)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBeacon(_ beacon: CLBeacon) {
|
||||||
|
let key = beaconKey(beacon)
|
||||||
|
guard resolvedBeacons[key] == nil && !pendingResolutions.contains(key) else { return }
|
||||||
|
|
||||||
|
pendingResolutions.insert(key)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let servicePointId = try await APIService.shared.resolveServicePoint(
|
||||||
|
uuid: beacon.uuid.uuidString,
|
||||||
|
major: beacon.major.intValue,
|
||||||
|
minor: beacon.minor.intValue
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
self.resolvedBeacons[key] = servicePointId
|
||||||
|
self.pendingResolutions.remove(key)
|
||||||
|
print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.pendingResolutions.remove(key)
|
||||||
|
print("[BeaconScanner] Failed to resolve \(key): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
@ -113,23 +154,34 @@ final class BeaconScanner: NSObject, ObservableObject {
|
||||||
extension BeaconScanner: CLLocationManagerDelegate {
|
extension BeaconScanner: CLLocationManagerDelegate {
|
||||||
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
||||||
satisfying constraint: CLBeaconIdentityConstraint) {
|
satisfying constraint: CLBeaconIdentityConstraint) {
|
||||||
let normalizedTarget = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
|
var foundTarget = false
|
||||||
var foundThisCycle = false
|
|
||||||
|
|
||||||
for beacon in beacons {
|
for beacon in beacons {
|
||||||
let rssi = beacon.rssi
|
let rssi = beacon.rssi
|
||||||
guard rssi != 0 else { continue }
|
guard rssi != 0 else { continue }
|
||||||
|
|
||||||
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
// Check if this is a Payfrit beacon
|
||||||
guard detectedUUID == normalizedTarget else { continue }
|
guard BeaconShardPool.isPayfrit(beacon.uuid) else { continue }
|
||||||
|
|
||||||
foundThisCycle = true
|
let key = beaconKey(beacon)
|
||||||
|
|
||||||
|
// Try to resolve if not already resolved
|
||||||
|
if resolvedBeacons[key] == nil {
|
||||||
|
resolveBeacon(beacon)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is our target service point
|
||||||
|
guard resolvedBeacons[key] == targetServicePointId else { continue }
|
||||||
|
|
||||||
|
foundTarget = true
|
||||||
|
|
||||||
if rssi >= rssiThreshold {
|
if rssi >= rssiThreshold {
|
||||||
rssiSamples.append(rssi)
|
rssiSamples.append(rssi)
|
||||||
|
|
||||||
if rssiSamples.count >= minSamplesToConfirm {
|
if rssiSamples.count >= minSamplesToConfirm {
|
||||||
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
||||||
|
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)")
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.onBeaconDetected(avg)
|
self?.onBeaconDetected(avg)
|
||||||
}
|
}
|
||||||
|
|
@ -140,13 +192,24 @@ extension BeaconScanner: CLLocationManagerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beacon lost this cycle
|
// Target beacon lost this cycle
|
||||||
if !foundThisCycle && !rssiSamples.isEmpty {
|
if !foundTarget && !rssiSamples.isEmpty {
|
||||||
rssiSamples.removeAll()
|
rssiSamples.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
if status == .authorizedWhenInUse || status == .authorizedAlways {
|
||||||
|
if !isScanning {
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
} else if status == .denied || status == .restricted {
|
||||||
|
onPermissionDenied?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
||||||
// Ranging failed - could be Bluetooth off
|
print("[BeaconScanner] Ranging failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@ final class AppState: ObservableObject {
|
||||||
@Published var userToken: String?
|
@Published var userToken: String?
|
||||||
@Published var businessId: Int = 0
|
@Published var businessId: Int = 0
|
||||||
@Published var isAuthenticated = false
|
@Published var isAuthenticated = false
|
||||||
|
@Published var shouldPopToRoot = false
|
||||||
|
@Published var shouldPopToTaskList = false
|
||||||
|
@Published var needsRefresh = false
|
||||||
|
|
||||||
|
/// Navigate back to root (business selection) and trigger a data refresh
|
||||||
|
func popToRoot() {
|
||||||
|
needsRefresh = true
|
||||||
|
shouldPopToRoot = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate back to task list (not all the way to business selection)
|
||||||
|
func popToTaskList() {
|
||||||
|
needsRefresh = true
|
||||||
|
shouldPopToTaskList = true
|
||||||
|
}
|
||||||
|
|
||||||
var isLoggedIn: Bool { userId != nil && userToken != nil }
|
var isLoggedIn: Bool { userId != nil && userToken != nil }
|
||||||
|
|
||||||
|
|
|
||||||
184
PayfritWorks/Views/AboutScreen.swift
Normal file
184
PayfritWorks/Views/AboutScreen.swift
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AboutScreen: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var appearAnimation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Logo + version
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image("PayfritLogoLight")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Text("Payfrit Works")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Version 1.0.0")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
|
// Intro
|
||||||
|
Text("Payfrit Works helps you manage tasks, accept cash payments, and earn money. Get notified of new tasks, complete them efficiently, and track your earnings.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
.offset(y: appearAnimation ? 0 : 15)
|
||||||
|
|
||||||
|
// Feature cards
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
featureCard(
|
||||||
|
icon: "checkmark.circle",
|
||||||
|
title: "Claim Tasks",
|
||||||
|
description: "View available tasks from businesses you work for and claim them with one tap."
|
||||||
|
)
|
||||||
|
.staggeredAppear(index: 0, appeared: appearAnimation)
|
||||||
|
|
||||||
|
featureCard(
|
||||||
|
icon: "dollarsign.circle",
|
||||||
|
title: "Accept Cash",
|
||||||
|
description: "Collect cash payments from customers with automatic change calculation."
|
||||||
|
)
|
||||||
|
.staggeredAppear(index: 1, appeared: appearAnimation)
|
||||||
|
|
||||||
|
featureCard(
|
||||||
|
icon: "antenna.radiowaves.left.and.right",
|
||||||
|
title: "Beacon Auto-Complete",
|
||||||
|
description: "Tasks complete automatically when you're near the customer's table beacon."
|
||||||
|
)
|
||||||
|
.staggeredAppear(index: 2, appeared: appearAnimation)
|
||||||
|
|
||||||
|
featureCard(
|
||||||
|
icon: "creditcard",
|
||||||
|
title: "Earn & Get Paid",
|
||||||
|
description: "Track your earnings in real-time and receive payouts to your bank account."
|
||||||
|
)
|
||||||
|
.staggeredAppear(index: 3, appeared: appearAnimation)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Links
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Link(destination: URL(string: "https://www.payfrit.com")!) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Color.payfritGreen.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Website")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("www.payfrit.com")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.staggeredAppear(index: 4, appeared: appearAnimation)
|
||||||
|
|
||||||
|
// Copyright
|
||||||
|
Text("\u{00A9} 2026 Payfrit. All rights reserved.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
|
.navigationTitle("About Payfrit")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
|
appearAnimation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func featureCard(icon: String, title: String, description: String) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Color.payfritGreen.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(description)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staggered Appear Modifier
|
||||||
|
|
||||||
|
private struct StaggeredAppearModifier: ViewModifier {
|
||||||
|
let index: Int
|
||||||
|
let appeared: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 20)
|
||||||
|
.animation(
|
||||||
|
.spring(response: 0.4, dampingFraction: 0.8)
|
||||||
|
.delay(Double(index) * 0.06),
|
||||||
|
value: appeared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func staggeredAppear(index: Int, appeared: Bool) -> some View {
|
||||||
|
modifier(StaggeredAppearModifier(index: index, appeared: appeared))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
AboutScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,30 +10,113 @@ struct AccountScreen: View {
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var showingMyTasks = false
|
@State private var showingMyTasks = false
|
||||||
@State private var showActivationInfo = false
|
@State private var showActivationInfo = false
|
||||||
|
@State private var appearAnimation = false
|
||||||
|
@State private var avatarURLLoaded: URL?
|
||||||
|
|
||||||
|
private var displayName: String {
|
||||||
|
appState.userName ?? "Worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var avatarURL: URL? {
|
||||||
|
// Prefer loaded URL from API, fall back to appState
|
||||||
|
if let loaded = avatarURLLoaded { return loaded }
|
||||||
|
guard let urlString = appState.userPhotoUrl, !urlString.isEmpty else { return nil }
|
||||||
|
return URL(string: urlString)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
// Avatar section
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let url = avatarURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
case .failure:
|
||||||
|
placeholderAvatar
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.payfritGreen.opacity(0.3), lineWidth: 2))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
||||||
|
} else {
|
||||||
|
placeholderAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(displayName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
|
// Navigation cards
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
NavigationLink {
|
||||||
|
ProfileScreen()
|
||||||
|
.environmentObject(appState)
|
||||||
|
} label: {
|
||||||
|
accountCard(
|
||||||
|
icon: "person",
|
||||||
|
title: "Edit Profile",
|
||||||
|
subtitle: "View your account info"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.staggeredAppear(index: 0, appeared: appearAnimation)
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
AboutScreen()
|
||||||
|
} label: {
|
||||||
|
accountCard(
|
||||||
|
icon: "info.circle",
|
||||||
|
title: "About Payfrit",
|
||||||
|
subtitle: "App info and features"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.staggeredAppear(index: 1, appeared: appearAnimation)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Payout content
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding(.top, 40)
|
.padding(.top, 20)
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
errorView(error)
|
errorView(error)
|
||||||
} else {
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
if let tier = tierStatus {
|
if let tier = tierStatus {
|
||||||
tierCard(tier)
|
tierCard(tier)
|
||||||
|
.staggeredAppear(index: 2, appeared: appearAnimation)
|
||||||
activationCard(tier)
|
activationCard(tier)
|
||||||
|
.staggeredAppear(index: 3, appeared: appearAnimation)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ledger = ledger {
|
if let ledger = ledger {
|
||||||
earningsCard(ledger)
|
earningsCard(ledger)
|
||||||
|
.staggeredAppear(index: 4, appeared: appearAnimation)
|
||||||
}
|
}
|
||||||
|
|
||||||
logoutButton
|
logoutButton
|
||||||
|
.staggeredAppear(index: 5, appeared: appearAnimation)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("Account")
|
.navigationTitle("Account")
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
Button { showingMyTasks = true } label: {
|
Button { showingMyTasks = true } label: {
|
||||||
|
|
@ -51,8 +134,16 @@ struct AccountScreen: View {
|
||||||
.navigationDestination(isPresented: $showingMyTasks) {
|
.navigationDestination(isPresented: $showingMyTasks) {
|
||||||
MyTasksScreen()
|
MyTasksScreen()
|
||||||
}
|
}
|
||||||
.task { await loadData() }
|
.task {
|
||||||
|
await loadAvatar()
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
.refreshable { await loadData() }
|
.refreshable { await loadData() }
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
|
appearAnimation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
.alert("What is Activation?", isPresented: $showActivationInfo) {
|
.alert("What is Activation?", isPresented: $showActivationInfo) {
|
||||||
Button("Got it", role: .cancel) { }
|
Button("Got it", role: .cancel) { }
|
||||||
} message: {
|
} message: {
|
||||||
|
|
@ -60,6 +151,53 @@ struct AccountScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar
|
||||||
|
|
||||||
|
private var placeholderAvatar: some View {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.foregroundStyle(.linearGradient(
|
||||||
|
colors: [Color.payfritGreen.opacity(0.6), Color.payfritGreen.opacity(0.3)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
))
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account Card
|
||||||
|
|
||||||
|
private func accountCard(icon: String, title: String, subtitle: String) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Color.payfritGreen.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Tier Card
|
// MARK: - Tier Card
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -75,7 +213,6 @@ struct AccountScreen: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if tier.tier >= 1 {
|
if tier.tier >= 1 {
|
||||||
// Tier 1 unlocked
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
|
|
@ -84,7 +221,6 @@ struct AccountScreen: View {
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
}
|
}
|
||||||
} else if !tier.stripe.hasAccount {
|
} else if !tier.stripe.hasAccount {
|
||||||
// No account yet
|
|
||||||
Text("Tier 1 is locked")
|
Text("Tier 1 is locked")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -96,7 +232,6 @@ struct AccountScreen: View {
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.payfritGreen)
|
.tint(.payfritGreen)
|
||||||
} else if tier.stripe.setupIncomplete {
|
} else if tier.stripe.setupIncomplete {
|
||||||
// Account exists but incomplete
|
|
||||||
Text("Stripe needs more info to enable payouts.")
|
Text("Stripe needs more info to enable payouts.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -137,7 +272,6 @@ struct AccountScreen: View {
|
||||||
.font(.subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Progress bar
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ProgressView(value: tier.activation.progress)
|
ProgressView(value: tier.activation.progress)
|
||||||
.tint(.payfritGreen)
|
.tint(.payfritGreen)
|
||||||
|
|
@ -269,11 +403,21 @@ struct AccountScreen: View {
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func loadAvatar() async {
|
||||||
|
do {
|
||||||
|
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
avatarURLLoaded = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avatar load failed, will use placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadData() async {
|
private func loadData() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
|
|
||||||
// Load tier status (required)
|
|
||||||
do {
|
do {
|
||||||
tierStatus = try await APIService.shared.getTierStatus()
|
tierStatus = try await APIService.shared.getTierStatus()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -282,7 +426,6 @@ struct AccountScreen: View {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load ledger (optional — don't block screen if it fails)
|
|
||||||
do {
|
do {
|
||||||
ledger = try await APIService.shared.getLedger()
|
ledger = try await APIService.shared.getLedger()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -304,7 +447,6 @@ struct AccountScreen: View {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Refresh on return
|
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -351,3 +493,34 @@ struct AccountScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Staggered Appear Modifier
|
||||||
|
|
||||||
|
private struct StaggeredAppearModifier: ViewModifier {
|
||||||
|
let index: Int
|
||||||
|
let appeared: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 20)
|
||||||
|
.animation(
|
||||||
|
.spring(response: 0.4, dampingFraction: 0.8)
|
||||||
|
.delay(Double(index) * 0.06),
|
||||||
|
value: appeared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func staggeredAppear(index: Int, appeared: Bool) -> some View {
|
||||||
|
modifier(StaggeredAppearModifier(index: index, appeared: appeared))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
AccountScreen()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import SwiftUI
|
||||||
struct BusinessSelectionScreen: View {
|
struct BusinessSelectionScreen: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@State private var businesses: [Employment] = []
|
@State private var businesses: [Employment] = []
|
||||||
|
@State private var myTaskBusinessIds: Set<Int> = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var showingTaskList = false
|
@State private var showingTaskList = false
|
||||||
|
|
@ -81,6 +82,20 @@ struct BusinessSelectionScreen: View {
|
||||||
.navigationDestination(isPresented: $showingAccount) {
|
.navigationDestination(isPresented: $showingAccount) {
|
||||||
AccountScreen()
|
AccountScreen()
|
||||||
}
|
}
|
||||||
|
.onChange(of: appState.shouldPopToRoot) { shouldPop in
|
||||||
|
if shouldPop {
|
||||||
|
showingTaskList = false
|
||||||
|
showingMyTasks = false
|
||||||
|
showingAccount = false
|
||||||
|
appState.shouldPopToRoot = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: appState.needsRefresh) { needsRefresh in
|
||||||
|
if needsRefresh {
|
||||||
|
loadBusinesses()
|
||||||
|
appState.needsRefresh = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingDebug) {
|
.sheet(isPresented: $showingDebug) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|
@ -108,7 +123,8 @@ struct BusinessSelectionScreen: View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(businesses) { emp in
|
ForEach(businesses) { emp in
|
||||||
if emp.pendingTaskCount > 0 {
|
let hasActivity = emp.pendingTaskCount > 0 || myTaskBusinessIds.contains(emp.businessId)
|
||||||
|
if hasActivity {
|
||||||
Button { selectBusiness(emp) } label: {
|
Button { selectBusiness(emp) } label: {
|
||||||
businessCard(emp)
|
businessCard(emp)
|
||||||
}
|
}
|
||||||
|
|
@ -152,12 +168,26 @@ struct BusinessSelectionScreen: View {
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Task count or clear checkmark
|
// Task indicators
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Red indicator if user has active tasks with this business
|
||||||
|
if myTaskBusinessIds.contains(emp.businessId) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text("active")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending task count or clear checkmark
|
||||||
if emp.pendingTaskCount > 0 {
|
if emp.pendingTaskCount > 0 {
|
||||||
Text("\(emp.pendingTaskCount)")
|
Text("\(emp.pendingTaskCount)")
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.foregroundColor(.payfritGreen)
|
.foregroundColor(.payfritGreen)
|
||||||
} else {
|
} else if !myTaskBusinessIds.contains(emp.businessId) {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
|
@ -167,6 +197,7 @@ struct BusinessSelectionScreen: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
|
|
@ -239,6 +270,13 @@ struct BusinessSelectionScreen: View {
|
||||||
do {
|
do {
|
||||||
let result = try await APIService.shared.getMyBusinesses()
|
let result = try await APIService.shared.getMyBusinesses()
|
||||||
businesses = result
|
businesses = result
|
||||||
|
|
||||||
|
// Fetch user's active tasks to show indicator
|
||||||
|
let myTasks = try? await APIService.shared.listMyTasks(filterType: "active")
|
||||||
|
if let tasks = myTasks {
|
||||||
|
myTaskBusinessIds = Set(tasks.map { $0.businessId })
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
if !silent {
|
if !silent {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ struct ChatScreen: View {
|
||||||
let taskId: Int
|
let taskId: Int
|
||||||
let userType: String // "customer" or "worker"
|
let userType: String // "customer" or "worker"
|
||||||
var otherPartyName: String?
|
var otherPartyName: String?
|
||||||
|
var otherPartyPhotoUrl: String?
|
||||||
|
var servicePointName: String?
|
||||||
|
var taskColor: Color = .blue
|
||||||
|
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
@ -18,13 +21,18 @@ struct ChatScreen: View {
|
||||||
@State private var otherUserName: String?
|
@State private var otherUserName: String?
|
||||||
@State private var chatEnded = false
|
@State private var chatEnded = false
|
||||||
@State private var showCloseChatAlert = false
|
@State private var showCloseChatAlert = false
|
||||||
@State private var showingMyTasks = false
|
@State private var workerClosedChat = false // Track if this worker closed the chat
|
||||||
|
|
||||||
// Polling timer
|
// Polling timer
|
||||||
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// Customer header (for workers)
|
||||||
|
if userType == "worker" {
|
||||||
|
customerHeader
|
||||||
|
}
|
||||||
|
|
||||||
// Chat ended banner
|
// Chat ended banner
|
||||||
if chatEnded {
|
if chatEnded {
|
||||||
Text("This chat has ended")
|
Text("This chat has ended")
|
||||||
|
|
@ -67,16 +75,23 @@ struct ChatScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer")
|
.navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(taskColor, for: .navigationBar)
|
||||||
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.tint(.white)
|
||||||
|
.navigationBarBackButtonHidden(chatEnded)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
if chatEnded {
|
||||||
if chatService.isConnected {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Image(systemName: "wifi")
|
Button {
|
||||||
.foregroundColor(.green)
|
appState.popToTaskList()
|
||||||
.font(.caption)
|
} label: {
|
||||||
} else {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "wifi.slash")
|
Image(systemName: "chevron.left")
|
||||||
.foregroundColor(.payfritGreen)
|
Text("Tasks")
|
||||||
.font(.caption)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if userType == "worker" && !chatEnded {
|
if userType == "worker" && !chatEnded {
|
||||||
|
|
@ -88,27 +103,11 @@ struct ChatScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Close Chat", isPresented: $showCloseChatAlert) {
|
.alert("Close Chat", isPresented: $showCloseChatAlert) {
|
||||||
|
Button("Close Chat") { closeChatAction() }
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
Button("Close", role: .destructive) { closeChatAction() }
|
|
||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to close this chat?")
|
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 {
|
.task {
|
||||||
otherUserName = otherPartyName
|
otherUserName = otherPartyName
|
||||||
await initializeChat()
|
await initializeChat()
|
||||||
|
|
@ -233,29 +232,100 @@ struct ChatScreen: View {
|
||||||
// MARK: - Input
|
// MARK: - Input
|
||||||
|
|
||||||
private var inputArea: some View {
|
private var inputArea: some View {
|
||||||
HStack(spacing: 8) {
|
let canSend = !isSending && !messageText.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
|
||||||
|
return HStack(spacing: 8) {
|
||||||
TextField("Type a message...", text: $messageText)
|
TextField("Type a message...", text: $messageText)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onSubmit { sendMessage() }
|
.onSubmit { sendMessage() }
|
||||||
|
|
||||||
Button(action: sendMessage) {
|
Button(action: sendMessage) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(canSend ? Color.accentColor : Color.accentColor.opacity(0.4))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
if isSending {
|
if isSending {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(width: 36, height: 36)
|
.tint(.white)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "paperplane.fill")
|
Image(systemName: "paperplane.fill")
|
||||||
.frame(width: 36, height: 36)
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
}
|
||||||
.clipShape(Circle())
|
.disabled(!canSend)
|
||||||
.disabled(isSending || messageText.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Customer Header
|
||||||
|
|
||||||
|
private var customerHeader: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Customer avatar
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(taskColor.opacity(0.2))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
if let photoUrl = otherPartyPhotoUrl, !photoUrl.isEmpty, let url = URL(string: photoUrl) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
default:
|
||||||
|
customerInitials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
customerInitials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(otherPartyName ?? "Customer")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let sp = servicePointName, !sp.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(sp)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customerInitials: some View {
|
||||||
|
let name = otherPartyName ?? ""
|
||||||
|
let parts = name.split(separator: " ")
|
||||||
|
let initials: String
|
||||||
|
if parts.count >= 2 {
|
||||||
|
initials = "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
|
||||||
|
} else if let first = parts.first {
|
||||||
|
initials = String(first.prefix(1)).uppercased()
|
||||||
|
} else {
|
||||||
|
initials = "C"
|
||||||
|
}
|
||||||
|
return Text(initials)
|
||||||
|
.font(.headline.bold())
|
||||||
|
.foregroundColor(taskColor)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func initializeChat() async {
|
private func initializeChat() async {
|
||||||
|
|
@ -357,12 +427,16 @@ struct ChatScreen: View {
|
||||||
|
|
||||||
private func closeChatAction() {
|
private func closeChatAction() {
|
||||||
guard userType == "worker" else { return }
|
guard userType == "worker" else { return }
|
||||||
|
workerClosedChat = true // Mark that worker is closing, not customer
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
// Close chat and complete task
|
||||||
try await APIService.shared.closeChat(taskId: taskId)
|
try await APIService.shared.closeChat(taskId: taskId)
|
||||||
|
try await APIService.shared.completeTask(taskId: taskId)
|
||||||
chatService.closeChatWS()
|
chatService.closeChatWS()
|
||||||
chatEnded = true
|
chatEnded = true
|
||||||
dismiss()
|
// Go back to task list
|
||||||
|
appState.popToTaskList()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,68 +2,58 @@ import SwiftUI
|
||||||
|
|
||||||
struct LoginScreen: View {
|
struct LoginScreen: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
||||||
|
// Login mode
|
||||||
|
@State private var usePasswordLogin = false
|
||||||
|
|
||||||
|
// OTP flow
|
||||||
|
@State private var step: OtpStep = .phone
|
||||||
|
@State private var phoneNumber = ""
|
||||||
|
@State private var otpCode = ""
|
||||||
|
@State private var otpUuid = ""
|
||||||
|
|
||||||
|
// Password flow
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var showPassword = false
|
@State private var showPassword = false
|
||||||
|
|
||||||
|
// Shared state
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
|
private enum OtpStep {
|
||||||
|
case phone
|
||||||
|
case otp
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 16) {
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
Image("PayfritLogoLight")
|
Image("PayfritLogoLight")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 220)
|
.frame(width: 180)
|
||||||
.padding(.horizontal, 16)
|
|
||||||
|
|
||||||
Text("Payfrit Works")
|
Text("Payfrit Works")
|
||||||
.font(.system(size: 28, weight: .bold))
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
|
||||||
Text("Sign in to view and claim tasks")
|
Text("Sign in to view and claim tasks")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
if IS_DEV {
|
if IS_DEV {
|
||||||
Text("DEV MODE — password: 123456")
|
Text("DEV MODE — OTP: 123456")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
Spacer().frame(height: 8)
|
||||||
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)
|
|
||||||
|
|
||||||
|
// Error message
|
||||||
if let error = error {
|
if let error = error {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
|
@ -78,7 +68,192 @@ struct LoginScreen: View {
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: login) {
|
// Main form
|
||||||
|
if usePasswordLogin {
|
||||||
|
passwordLoginView
|
||||||
|
} else {
|
||||||
|
switch step {
|
||||||
|
case .phone: phoneEntryView
|
||||||
|
case .otp: otpEntryView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: 16)
|
||||||
|
|
||||||
|
// Toggle login mode
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
usePasswordLogin.toggle()
|
||||||
|
error = nil
|
||||||
|
// Reset states
|
||||||
|
step = .phone
|
||||||
|
otpCode = ""
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(usePasswordLogin ? "Use phone number instead" : "Use email & password instead")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.frame(minHeight: geo.size.height)
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Phone Entry View
|
||||||
|
|
||||||
|
private var phoneEntryView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Phone Number")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("+1")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(height: 20)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
|
||||||
|
TextField("(555) 555-1234", text: $phoneNumber)
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
.textContentType(.telephoneNumber)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
}
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
let canSend = phoneNumber.filter { $0.isNumber }.count >= 10 && !isLoading
|
||||||
|
|
||||||
|
Button(action: sendOtp) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
} else {
|
||||||
|
Text("Send Login Code")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(canSend ? .payfritGreen : .payfritGreen.opacity(0.5))
|
||||||
|
.disabled(!canSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OTP Entry View
|
||||||
|
|
||||||
|
private var otpEntryView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("We sent a code to")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(formattedPhone)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("000000", text: $otpCode)
|
||||||
|
.font(.system(size: 28, weight: .semibold, design: .monospaced))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
.tracking(8)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(Color(.systemGray4), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.onChange(of: otpCode) { newValue in
|
||||||
|
otpCode = String(newValue.prefix(6))
|
||||||
|
}
|
||||||
|
|
||||||
|
let canVerify = otpCode.count == 6 && !isLoading
|
||||||
|
|
||||||
|
Button(action: verifyOtp) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
} else {
|
||||||
|
Text("Sign In")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(canVerify ? .payfritGreen : .payfritGreen.opacity(0.5))
|
||||||
|
.disabled(!canVerify)
|
||||||
|
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
Button("Resend Code") {
|
||||||
|
sendOtp()
|
||||||
|
}
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
|
||||||
|
Button("Change Number") {
|
||||||
|
step = .phone
|
||||||
|
otpCode = ""
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Password Login View
|
||||||
|
|
||||||
|
private var passwordLoginView: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
TextField("Email or Phone", text: $username)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
|
Group {
|
||||||
|
if showPassword {
|
||||||
|
TextField("Password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
} else {
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit { loginWithPassword() }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showPassword.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: loginWithPassword) {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
|
@ -93,14 +268,71 @@ struct LoginScreen: View {
|
||||||
.tint(.payfritGreen)
|
.tint(.payfritGreen)
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.frame(minHeight: geo.size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func login() {
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var formattedPhone: String {
|
||||||
|
let digits = phoneNumber.filter { $0.isNumber }
|
||||||
|
guard digits.count >= 10 else { return "+1 \(phoneNumber)" }
|
||||||
|
let area = String(digits.prefix(3))
|
||||||
|
let mid = String(digits.dropFirst(3).prefix(3))
|
||||||
|
let last = String(digits.dropFirst(6).prefix(4))
|
||||||
|
return "+1 (\(area)) \(mid)-\(last)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API Calls
|
||||||
|
|
||||||
|
private func sendOtp() {
|
||||||
|
let cleanPhone = phoneNumber.filter { $0.isNumber }
|
||||||
|
guard cleanPhone.count >= 10 else {
|
||||||
|
error = "Please enter a valid phone number"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await APIService.shared.sendLoginOtp(phone: "+1\(cleanPhone)")
|
||||||
|
otpUuid = response.uuid
|
||||||
|
step = .otp
|
||||||
|
} catch {
|
||||||
|
self.error = "Failed to send code. Please try again."
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyOtp() {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let response = try await APIService.shared.verifyLoginOtp(uuid: otpUuid, otp: otpCode)
|
||||||
|
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 = "Invalid code. Please try again."
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginWithPassword() {
|
||||||
let user = username.trimmingCharacters(in: .whitespaces)
|
let user = username.trimmingCharacters(in: .whitespaces)
|
||||||
let pass = password
|
let pass = password
|
||||||
guard !user.isEmpty, !pass.isEmpty else {
|
guard !user.isEmpty, !pass.isEmpty else {
|
||||||
|
|
@ -129,8 +361,13 @@ struct LoginScreen: View {
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoginScreen()
|
||||||
|
.environmentObject(AppState())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ struct MyTasksScreen: View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.payfritGreen)
|
.foregroundColor(task.color)
|
||||||
Text(task.locationDisplay)
|
Text(task.locationDisplay)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -172,7 +172,7 @@ struct MyTasksScreen: View {
|
||||||
|
|
||||||
if task.isChat {
|
if task.isChat {
|
||||||
Image(systemName: "bubble.left.fill")
|
Image(systemName: "bubble.left.fill")
|
||||||
.foregroundColor(.payfritGreen)
|
.foregroundColor(task.color)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,10 +187,16 @@ struct MyTasksScreen: View {
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
|
if task.isChat {
|
||||||
|
Image(systemName: "bubble.right.fill")
|
||||||
|
.foregroundColor(task.color)
|
||||||
|
.font(.body)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
153
PayfritWorks/Views/ProfileScreen.swift
Normal file
153
PayfritWorks/Views/ProfileScreen.swift
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfileScreen: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var appearAnimation = false
|
||||||
|
@State private var avatarURLLoaded: URL?
|
||||||
|
|
||||||
|
private var displayName: String {
|
||||||
|
appState.userName ?? "Worker"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var avatarURL: URL? {
|
||||||
|
if let loaded = avatarURLLoaded { return loaded }
|
||||||
|
guard let urlString = appState.userPhotoUrl, !urlString.isEmpty else { return nil }
|
||||||
|
return URL(string: urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Avatar section
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let url = avatarURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
case .failure:
|
||||||
|
placeholderAvatar
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.payfritGreen.opacity(0.3), lineWidth: 2))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
||||||
|
} else {
|
||||||
|
placeholderAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(displayName)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text("Worker")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
|
// Info section
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Account Information")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
infoRow(icon: "person.fill", label: "Name", value: displayName)
|
||||||
|
Divider().padding(.leading, 54)
|
||||||
|
infoRow(icon: "briefcase.fill", label: "Role", value: "Worker")
|
||||||
|
}
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
.offset(y: appearAnimation ? 0 : 15)
|
||||||
|
|
||||||
|
// Note
|
||||||
|
Text("Profile information is managed through your employer. Contact your manager if you need to update your details.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
|
.navigationTitle("Profile")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
do {
|
||||||
|
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
avatarURLLoaded = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avatar load failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||||
|
appearAnimation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderAvatar: some View {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.foregroundStyle(.linearGradient(
|
||||||
|
colors: [Color.payfritGreen.opacity(0.6), Color.payfritGreen.opacity(0.3)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoRow(icon: String, label: String, value: String) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ProfileScreen()
|
||||||
|
.environmentObject(AppState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,15 @@ struct TaskDetailScreen: View {
|
||||||
@State private var beaconScanner: BeaconScanner?
|
@State private var beaconScanner: BeaconScanner?
|
||||||
@State private var showingMyTasks = false
|
@State private var showingMyTasks = false
|
||||||
@State private var showingChat = false
|
@State private var showingChat = false
|
||||||
|
@State private var showCashDialog = false
|
||||||
|
@State private var cashReceived = ""
|
||||||
|
@State private var cashError: String?
|
||||||
|
@State private var taskAccepted = false // Track if task was just accepted
|
||||||
|
@State private var customerAvatarUrl: String? // Fetched separately if not in task details
|
||||||
|
|
||||||
|
// Computed properties for effective button visibility after accepting
|
||||||
|
private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted }
|
||||||
|
private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
|
@ -35,6 +44,8 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Button { showingMyTasks = true } label: {
|
Button { showingMyTasks = true } label: {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
|
@ -52,6 +63,7 @@ struct TaskDetailScreen: View {
|
||||||
.toolbarBackground(task.color, for: .navigationBar)
|
.toolbarBackground(task.color, for: .navigationBar)
|
||||||
.toolbarBackground(.visible, for: .navigationBar)
|
.toolbarBackground(.visible, for: .navigationBar)
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.tint(.white)
|
||||||
.alert("Accept Task?", isPresented: $showAcceptAlert) {
|
.alert("Accept Task?", isPresented: $showAcceptAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
Button("Accept") { acceptTask() }
|
Button("Accept") { acceptTask() }
|
||||||
|
|
@ -68,7 +80,7 @@ struct TaskDetailScreen: View {
|
||||||
AutoCompleteCountdownView(taskId: task.taskId) { result in
|
AutoCompleteCountdownView(taskId: task.taskId) { result in
|
||||||
showAutoCompleteDialog = false
|
showAutoCompleteDialog = false
|
||||||
if result == "success" {
|
if result == "success" {
|
||||||
dismiss()
|
appState.popToRoot()
|
||||||
} else if result == "cancelled" || result == "error" {
|
} else if result == "cancelled" || result == "error" {
|
||||||
autoCompleting = false
|
autoCompleting = false
|
||||||
beaconDetected = false
|
beaconDetected = false
|
||||||
|
|
@ -82,10 +94,26 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $showingChat) {
|
.navigationDestination(isPresented: $showingChat) {
|
||||||
ChatScreen(taskId: task.taskId, userType: "worker",
|
ChatScreen(taskId: task.taskId, userType: "worker",
|
||||||
otherPartyName: details?.customerFullName)
|
otherPartyName: details?.customerFullName,
|
||||||
|
otherPartyPhotoUrl: details?.customerPhotoUrl,
|
||||||
|
servicePointName: details?.servicePointName,
|
||||||
|
taskColor: task.color)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCashDialog) {
|
||||||
|
CashCollectionSheet(
|
||||||
|
orderTotalCents: orderTotalCents,
|
||||||
|
taskId: task.taskId,
|
||||||
|
onComplete: { appState.popToRoot() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.task { await loadDetails() }
|
.task { await loadDetails() }
|
||||||
.onDisappear { beaconScanner?.dispose() }
|
.onDisappear { beaconScanner?.dispose() }
|
||||||
|
.onChange(of: appState.shouldPopToTaskList) { shouldPop in
|
||||||
|
if shouldPop {
|
||||||
|
appState.shouldPopToTaskList = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
|
@ -93,11 +121,24 @@ struct TaskDetailScreen: View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func contentView(_ details: TaskDetails) -> some View {
|
private func contentView(_ details: TaskDetails) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Colored header section
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
if isCashTask && orderTotalCents > 0 {
|
||||||
|
cashAmountBanner
|
||||||
|
}
|
||||||
customerSection(details)
|
customerSection(details)
|
||||||
if task.isChat {
|
if task.isChat {
|
||||||
chatButton(details)
|
chatButton(details)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(task.color)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
// White content section
|
||||||
|
VStack(spacing: 16) {
|
||||||
locationSection(details)
|
locationSection(details)
|
||||||
|
|
||||||
if !details.tableMembers.isEmpty {
|
if !details.tableMembers.isEmpty {
|
||||||
|
|
@ -116,33 +157,93 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
if showAcceptButton || showCompleteButton {
|
if effectiveShowAcceptButton || effectiveShowCompleteButton {
|
||||||
bottomBar
|
bottomBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cash Helpers
|
||||||
|
|
||||||
|
private var isCashTask: Bool {
|
||||||
|
task.isCash || (details?.isCash ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var orderTotalCents: Int {
|
||||||
|
let fromDetails = details?.orderTotal ?? 0
|
||||||
|
let fromTask = task.orderTotal
|
||||||
|
let cents = fromDetails > 0 ? fromDetails : fromTask
|
||||||
|
return Int(cents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cashAmountBanner: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "dollarsign.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Cash Payment Due")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
Text(String(format: "$%.2f", Double(orderTotalCents) / 100))
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Customer
|
// MARK: - Customer
|
||||||
|
|
||||||
private func customerSection(_ d: TaskDetails) -> some View {
|
private func customerSection(_ d: TaskDetails) -> some View {
|
||||||
HStack(spacing: 16) {
|
// For chat/call server tasks, only show avatar after accepted
|
||||||
// Avatar
|
let showAvatar = !task.isChat || effectiveShowCompleteButton
|
||||||
|
|
||||||
|
return HStack(spacing: 16) {
|
||||||
|
// Avatar - only show for chat tasks after accepted
|
||||||
|
if showAvatar {
|
||||||
|
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(task.color.opacity(0.2))
|
.fill(Color.white.opacity(0.2))
|
||||||
.frame(width: 64, height: 64)
|
.frame(width: 64, height: 64)
|
||||||
|
|
||||||
if !d.customerPhotoUrl.isEmpty, let url = URL(string: d.customerPhotoUrl) {
|
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
|
||||||
AsyncImage(url: url) { image in
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
image.resizable().scaledToFill()
|
image.resizable().scaledToFill()
|
||||||
} placeholder: {
|
case .failure(let error):
|
||||||
|
// Show initials on error
|
||||||
initialsView(d)
|
initialsView(d)
|
||||||
|
.onAppear {
|
||||||
|
NSLog("[Avatar] Failed to load image from %@: %@", photoUrl, error.localizedDescription)
|
||||||
|
}
|
||||||
|
case .empty:
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
@unknown default:
|
||||||
|
initialsView(d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 64, height: 64)
|
.frame(width: 64, height: 64)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
|
.onAppear {
|
||||||
|
NSLog("[Avatar] Loading customer avatar from: %@", photoUrl)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
initialsView(d)
|
initialsView(d)
|
||||||
|
.onAppear {
|
||||||
|
NSLog("[Avatar] No photo URL available, customerUserId=%d", d.customerUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +253,7 @@ struct TaskDetailScreen: View {
|
||||||
if !d.customerPhone.isEmpty {
|
if !d.customerPhone.isEmpty {
|
||||||
Text(d.customerPhone)
|
Text(d.customerPhone)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.white.opacity(0.8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,14 +262,14 @@ struct TaskDetailScreen: View {
|
||||||
if !d.customerPhone.isEmpty {
|
if !d.customerPhone.isEmpty {
|
||||||
Button { callCustomer(d.customerPhone) } label: {
|
Button { callCustomer(d.customerPhone) } label: {
|
||||||
Image(systemName: "phone.fill")
|
Image(systemName: "phone.fill")
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.white)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.white.opacity(0.2))
|
||||||
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
|
||||||
.background(Color(.secondarySystemGroupedBackground))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initialsView(_ d: TaskDetails) -> some View {
|
private func initialsView(_ d: TaskDetails) -> some View {
|
||||||
|
|
@ -177,7 +278,7 @@ struct TaskDetailScreen: View {
|
||||||
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
|
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
|
||||||
return Text(text)
|
return Text(text)
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
.foregroundColor(task.color)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chat Button
|
// MARK: - Chat Button
|
||||||
|
|
@ -186,36 +287,68 @@ struct TaskDetailScreen: View {
|
||||||
private func chatButton(_ d: TaskDetails) -> some View {
|
private func chatButton(_ d: TaskDetails) -> some View {
|
||||||
Button { showingChat = true } label: {
|
Button { showingChat = true } label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
// Customer avatar
|
||||||
|
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
Circle()
|
||||||
.fill(Color.payfritGreen.opacity(0.1))
|
.fill(task.color.opacity(0.3))
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: 56, height: 56)
|
||||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
|
||||||
.foregroundColor(.payfritGreen)
|
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
|
||||||
.font(.title2)
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image.resizable().scaledToFill()
|
||||||
|
default:
|
||||||
|
chatInitialsView(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
chatInitialsView(d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Chat with Customer")
|
|
||||||
.font(.callout.weight(.medium))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(d.customerFullName)
|
Text(d.customerFullName)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
|
||||||
|
// Show service point where chat originated
|
||||||
|
if !d.servicePointName.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(d.servicePointName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
.foregroundColor(.black.opacity(0.6))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Tap to open chat")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.payfritGreen)
|
||||||
.font(.caption)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(Color(.secondarySystemGroupedBackground))
|
.background(Color.white)
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func chatInitialsView(_ d: TaskDetails) -> some View {
|
||||||
|
Text(String(d.customerFirstName.prefix(1) + d.customerLastName.prefix(1)).uppercased())
|
||||||
|
.font(.headline.bold())
|
||||||
|
.foregroundColor(task.color)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Location
|
// MARK: - Location
|
||||||
|
|
||||||
private func locationSection(_ d: TaskDetails) -> some View {
|
private func locationSection(_ d: TaskDetails) -> some View {
|
||||||
|
|
@ -246,7 +379,7 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !d.isDelivery && !d.beaconUUID.isEmpty && showCompleteButton {
|
if !d.isDelivery && d.servicePointId > 0 && effectiveShowCompleteButton {
|
||||||
beaconIndicator
|
beaconIndicator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +542,7 @@ struct TaskDetailScreen: View {
|
||||||
|
|
||||||
private var bottomBar: some View {
|
private var bottomBar: some View {
|
||||||
HStack {
|
HStack {
|
||||||
if showAcceptButton {
|
if effectiveShowAcceptButton {
|
||||||
Button { showAcceptAlert = true } label: {
|
Button { showAcceptAlert = true } label: {
|
||||||
Label("Accept Task", systemImage: "plus.circle.fill")
|
Label("Accept Task", systemImage: "plus.circle.fill")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
@ -419,7 +552,16 @@ struct TaskDetailScreen: View {
|
||||||
.tint(.payfritGreen)
|
.tint(.payfritGreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
if showCompleteButton {
|
if effectiveShowCompleteButton {
|
||||||
|
if isCashTask && orderTotalCents > 0 {
|
||||||
|
Button { showCashDialog = true } label: {
|
||||||
|
Label("Collect Cash", systemImage: "dollarsign.circle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
|
} else {
|
||||||
Button { showCompleteAlert = true } label: {
|
Button { showCompleteAlert = true } label: {
|
||||||
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
@ -429,6 +571,7 @@ struct TaskDetailScreen: View {
|
||||||
.tint(.green)
|
.tint(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
|
|
@ -459,8 +602,30 @@ struct TaskDetailScreen: View {
|
||||||
details = d
|
details = d
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
if showCompleteButton && !d.beaconUUID.isEmpty {
|
// Fetch customer avatar separately if not included in task details
|
||||||
startBeaconScanning(d.beaconUUID)
|
if d.customerPhotoUrl.isEmpty && d.customerUserId > 0 {
|
||||||
|
Task {
|
||||||
|
// Try API first, then fallback to direct URL pattern
|
||||||
|
if let avatarUrl = try? await APIService.shared.getUserAvatarUrl(userId: d.customerUserId) {
|
||||||
|
await MainActor.run {
|
||||||
|
customerAvatarUrl = avatarUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try direct URL pattern as fallback
|
||||||
|
let directUrl = APIService.directAvatarUrl(userId: d.customerUserId)
|
||||||
|
await MainActor.run {
|
||||||
|
customerAvatarUrl = directUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
|
||||||
|
if showCompleteButton && d.servicePointId > 0 {
|
||||||
|
print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)")
|
||||||
|
startBeaconScanning(d.servicePointId)
|
||||||
|
} else {
|
||||||
|
print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|
@ -468,9 +633,9 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startBeaconScanning(_ uuid: String) {
|
private func startBeaconScanning(_ servicePointId: Int) {
|
||||||
let scanner = BeaconScanner(
|
let scanner = BeaconScanner(
|
||||||
targetUUID: uuid,
|
targetServicePointId: servicePointId,
|
||||||
onBeaconDetected: { [self] _ in
|
onBeaconDetected: { [self] _ in
|
||||||
if !beaconDetected && !autoCompleting {
|
if !beaconDetected && !autoCompleting {
|
||||||
beaconDetected = true
|
beaconDetected = true
|
||||||
|
|
@ -480,7 +645,7 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBluetoothOff: { bluetoothOff = true },
|
onBluetoothOff: { bluetoothOff = true },
|
||||||
onPermissionDenied: { self.error = "Bluetooth permission is required for auto-complete. Please enable it in Settings." }
|
onPermissionDenied: { self.error = "Location permission is required for beacon detection. Please enable it in Settings." }
|
||||||
)
|
)
|
||||||
beaconScanner = scanner
|
beaconScanner = scanner
|
||||||
scanner.startScanning()
|
scanner.startScanning()
|
||||||
|
|
@ -490,10 +655,16 @@ struct TaskDetailScreen: View {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await APIService.shared.acceptTask(taskId: task.taskId)
|
try await APIService.shared.acceptTask(taskId: task.taskId)
|
||||||
|
taskAccepted = true
|
||||||
|
|
||||||
if task.isChat {
|
if task.isChat {
|
||||||
|
// Go directly to chat for chat tasks
|
||||||
showingChat = true
|
showingChat = true
|
||||||
} else {
|
} else {
|
||||||
dismiss()
|
// Stay on detail screen, start beacon scanning if applicable
|
||||||
|
if let d = details, d.servicePointId > 0 {
|
||||||
|
startBeaconScanning(d.servicePointId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|
@ -505,7 +676,7 @@ struct TaskDetailScreen: View {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await APIService.shared.completeTask(taskId: task.taskId)
|
try await APIService.shared.completeTask(taskId: task.taskId)
|
||||||
dismiss()
|
appState.popToRoot()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|
@ -529,10 +700,160 @@ struct TaskDetailScreen: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cash Collection Sheet
|
||||||
|
|
||||||
|
struct CashCollectionSheet: View {
|
||||||
|
let orderTotalCents: Int
|
||||||
|
let taskId: Int
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var cashReceivedText = ""
|
||||||
|
@State private var isProcessing = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
private var orderTotalDollars: Double { Double(orderTotalCents) / 100 }
|
||||||
|
|
||||||
|
private var cashReceivedCents: Int? {
|
||||||
|
guard let dollars = Double(cashReceivedText) else { return nil }
|
||||||
|
return Int(round(dollars * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var changeDue: Double? {
|
||||||
|
guard let receivedCents = cashReceivedCents else { return nil }
|
||||||
|
let change = Double(receivedCents - orderTotalCents) / 100
|
||||||
|
return change >= 0 ? change : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isValid: Bool {
|
||||||
|
guard let receivedCents = cashReceivedCents else { return false }
|
||||||
|
return receivedCents >= orderTotalCents
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Order total
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Amount Due")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(String(format: "$%.2f", orderTotalDollars))
|
||||||
|
.font(.system(size: 42, weight: .bold))
|
||||||
|
.foregroundColor(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
// Cash received input
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Cash Received")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("$")
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
TextField("0.00", text: $cashReceivedText)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change display
|
||||||
|
if let change = changeDue {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "arrow.uturn.left.circle.fill")
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
Text("Change:")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "$%.2f", change))
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundColor(.payfritGreen)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.payfritGreen.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
} else if cashReceivedCents != nil {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text("Amount received must be at least \(String(format: "$%.2f", orderTotalDollars))")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.red.opacity(0.1))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let err = errorMessage {
|
||||||
|
Text(err)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Confirm button
|
||||||
|
Button {
|
||||||
|
confirmCashCollection()
|
||||||
|
} label: {
|
||||||
|
if isProcessing {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
} else {
|
||||||
|
Text("Confirm Cash Received")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
|
.disabled(!isValid || isProcessing)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.navigationTitle("Collect Cash")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmCashCollection() {
|
||||||
|
guard let cents = cashReceivedCents, cents >= orderTotalCents else { return }
|
||||||
|
isProcessing = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
|
||||||
|
dismiss()
|
||||||
|
onComplete()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Auto-Complete Countdown
|
// MARK: - Auto-Complete Countdown
|
||||||
|
|
||||||
struct AutoCompleteCountdownView: View {
|
struct AutoCompleteCountdownView: View {
|
||||||
let taskId: Int
|
let taskId: Int
|
||||||
|
var cashReceivedCents: Int? = nil
|
||||||
let onResult: (String) -> Void
|
let onResult: (String) -> Void
|
||||||
|
|
||||||
@State private var countdown = 3
|
@State private var countdown = 3
|
||||||
|
|
@ -571,7 +892,7 @@ struct AutoCompleteCountdownView: View {
|
||||||
message = "Completing task..."
|
message = "Completing task..."
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await APIService.shared.completeTask(taskId: taskId)
|
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cashReceivedCents)
|
||||||
message = "Closing this window now"
|
message = "Closing this window now"
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
onResult("success")
|
onResult("success")
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ struct TaskListScreen: View {
|
||||||
MyTasksScreen()
|
MyTasksScreen()
|
||||||
}
|
}
|
||||||
.task { loadTasks() }
|
.task { loadTasks() }
|
||||||
|
.onAppear { loadTasks(silent: true) }
|
||||||
.onReceive(refreshTimer) { _ in loadTasks(silent: true) }
|
.onReceive(refreshTimer) { _ in loadTasks(silent: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,13 +93,13 @@ struct TaskListScreen: View {
|
||||||
.frame(width: 4)
|
.frame(width: 4)
|
||||||
.cornerRadius(2)
|
.cornerRadius(2)
|
||||||
|
|
||||||
// Chat icon or category icon
|
// Task type icon - uses task type color
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(task.isChat ? Color.payfritGreen.opacity(0.15) : task.color.opacity(0.15))
|
.fill(task.color.opacity(0.15))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill")
|
Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill")
|
||||||
.foregroundColor(task.isChat ? .payfritGreen : task.color)
|
.foregroundColor(task.color)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +111,7 @@ struct TaskListScreen: View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.payfritGreen)
|
.foregroundColor(task.color)
|
||||||
Text(task.locationDisplay)
|
Text(task.locationDisplay)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -134,16 +135,30 @@ struct TaskListScreen: View {
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
if task.isChat {
|
if task.isChat {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "bubble.left.fill")
|
||||||
|
.font(.caption2)
|
||||||
Text("Chat")
|
Text("Chat")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.payfritGreen)
|
}
|
||||||
|
.foregroundColor(task.color)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.payfritGreen.opacity(0.15))
|
.background(task.color.opacity(0.15))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if task.isCash && task.orderTotal > 0 {
|
||||||
|
Text(String(format: "$%.2f", task.orderTotal / 100))
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
Text(task.timeAgo)
|
Text(task.timeAgo)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -152,10 +167,16 @@ struct TaskListScreen: View {
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
|
if task.isChat {
|
||||||
|
Image(systemName: "bubble.right.fill")
|
||||||
|
.foregroundColor(task.color)
|
||||||
|
.font(.body)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue