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; };
|
||||
B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; };
|
||||
B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; };
|
||||
B01000000034 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000034; };
|
||||
|
||||
/* Widgets */
|
||||
B01000000050 /* DevRibbon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000050; };
|
||||
|
|
@ -40,6 +41,8 @@
|
|||
B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; };
|
||||
B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; };
|
||||
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
|
||||
B01000000048 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000048; };
|
||||
B01000000049 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000049; };
|
||||
|
||||
/* Resources */
|
||||
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>"; };
|
||||
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>"; };
|
||||
B02000000034 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
|
||||
|
||||
/* Widgets */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
|
@ -154,6 +160,7 @@
|
|||
B02000000031 /* AuthStorage.swift */,
|
||||
B02000000032 /* BeaconScanner.swift */,
|
||||
B02000000033 /* ChatService.swift */,
|
||||
B02000000034 /* BeaconShardPool.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -177,6 +184,8 @@
|
|||
B02000000045 /* MyTasksScreen.swift */,
|
||||
B02000000046 /* ChatScreen.swift */,
|
||||
B02000000047 /* AccountScreen.swift */,
|
||||
B02000000048 /* AboutScreen.swift */,
|
||||
B02000000049 /* ProfileScreen.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -291,6 +300,7 @@
|
|||
B01000000031 /* AuthStorage.swift in Sources */,
|
||||
B01000000032 /* BeaconScanner.swift in Sources */,
|
||||
B01000000033 /* ChatService.swift in Sources */,
|
||||
B01000000034 /* BeaconShardPool.swift in Sources */,
|
||||
B01000000050 /* DevRibbon.swift in Sources */,
|
||||
B01000000040 /* RootView.swift in Sources */,
|
||||
B01000000041 /* LoginScreen.swift in Sources */,
|
||||
|
|
@ -300,6 +310,8 @@
|
|||
B01000000045 /* MyTasksScreen.swift in Sources */,
|
||||
B01000000046 /* ChatScreen.swift in Sources */,
|
||||
B01000000047 /* AccountScreen.swift in Sources */,
|
||||
B01000000048 /* AboutScreen.swift in Sources */,
|
||||
B01000000049 /* ProfileScreen.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -429,7 +441,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = U83YL8VRF3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
||||
|
|
@ -461,7 +473,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = U83YL8VRF3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = PayfritWorks/Info.plist;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@
|
|||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "appicon.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
|
|
|||
|
|
@ -38,5 +38,19 @@
|
|||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</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>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ struct WorkTask: Identifiable {
|
|||
let sourceId: Int
|
||||
let categoryName: String
|
||||
let categoryColor: String
|
||||
let taskTypeName: String
|
||||
let taskTypeColor: String
|
||||
let orderTotal: Double
|
||||
|
||||
// Location (may be included in list responses)
|
||||
let servicePointName: String
|
||||
|
|
@ -34,8 +37,12 @@ struct WorkTask: Identifiable {
|
|||
sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0
|
||||
// Server key varies: CategoryName, Name, TaskCategoryName — try all
|
||||
categoryName = Self.nonEmpty(json["CategoryName"]) ?? Self.nonEmpty(json["Name"]) ?? Self.nonEmpty(json["TaskCategoryName"]) ?? Self.nonEmpty(json["TaskTypeName"]) ?? "Uncategorized"
|
||||
// Server key varies: CategoryColor, Color, TaskCategoryColor — try all, then TaskTypeColor fallback
|
||||
categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? Self.nonEmpty(json["TaskTypeColor"]) ?? "#888888"
|
||||
// Category color as fallback
|
||||
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) ?? ""
|
||||
deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
|
||||
}
|
||||
|
|
@ -46,8 +53,10 @@ struct WorkTask: Identifiable {
|
|||
return ""
|
||||
}
|
||||
|
||||
/// Primary color from TaskType, falls back to category 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 {
|
||||
return Color(red: 0.53, green: 0.53, blue: 0.53)
|
||||
}
|
||||
|
|
@ -78,7 +87,9 @@ struct WorkTask: Identifiable {
|
|||
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
|
||||
|
||||
|
|
@ -88,6 +99,15 @@ struct WorkTask: Identifiable {
|
|||
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? {
|
||||
guard let value = value else { return nil }
|
||||
if let v = value as? Int { return v }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ struct TaskDetails {
|
|||
let statusId: Int
|
||||
let categoryName: String
|
||||
let categoryColor: String
|
||||
let taskTypeName: String
|
||||
let orderTotal: Double
|
||||
|
||||
// Order info
|
||||
let orderId: Int
|
||||
|
|
@ -47,6 +49,8 @@ struct TaskDetails {
|
|||
statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
|
||||
categoryName = WorkTask.nonEmpty(json["CategoryName"]) ?? WorkTask.nonEmpty(json["Name"]) ?? WorkTask.nonEmpty(json["TaskCategoryName"]) ?? "General"
|
||||
categoryColor = WorkTask.nonEmpty(json["CategoryColor"]) ?? WorkTask.nonEmpty(json["Color"]) ?? WorkTask.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
|
||||
taskTypeName = (json["TaskTypeName"] as? String) ?? ""
|
||||
orderTotal = Self.parseDouble(json["OrderTotal"]) ?? 0
|
||||
orderId = WorkTask.parseInt(json["OrderID"]) ?? 0
|
||||
orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? ""
|
||||
if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty {
|
||||
|
|
@ -64,7 +68,25 @@ struct TaskDetails {
|
|||
customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
|
||||
customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
|
||||
customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? ""
|
||||
let rawPhoto = (json["CustomerPhotoUrl"] as? String) ?? (json["PhotoUrl"] as? String) ?? ""
|
||||
|
||||
// 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)
|
||||
beaconUUID = (json["BeaconUUID"] as? String) ?? ""
|
||||
|
||||
|
|
@ -113,6 +135,7 @@ struct TaskDetails {
|
|||
return "No location specified"
|
||||
}
|
||||
|
||||
var isCash: Bool { taskTypeName.lowercased().contains("cash") }
|
||||
var isDelivery: Bool { !deliveryAddress.isEmpty }
|
||||
var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import Foundation
|
||||
import os.log
|
||||
|
||||
private let logger = Logger(subsystem: "com.payfrit.works", category: "API")
|
||||
|
||||
// MARK: - Dev Flag
|
||||
|
||||
/// Master flag: flip to `false` for production builds.
|
||||
/// Controls API endpoint, dev banner, and magic OTPs.
|
||||
let IS_DEV = true
|
||||
let IS_DEV = false
|
||||
|
||||
// MARK: - API Errors
|
||||
|
||||
|
|
@ -37,6 +40,13 @@ struct LoginResponse {
|
|||
let photoUrl: String
|
||||
}
|
||||
|
||||
// MARK: - OTP Response
|
||||
|
||||
struct SendOtpResponse {
|
||||
let uuid: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
// MARK: - Chat Messages Result
|
||||
|
||||
struct ChatMessagesResult {
|
||||
|
|
@ -108,6 +118,35 @@ actor APIService {
|
|||
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]? {
|
||||
// Try direct parse
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
|
|
@ -162,7 +201,8 @@ actor APIService {
|
|||
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
|
||||
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
|
||||
"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.
|
||||
|
|
@ -250,6 +290,57 @@ actor APIService {
|
|||
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() {
|
||||
userToken = nil
|
||||
userId = nil
|
||||
|
|
@ -328,22 +419,27 @@ actor APIService {
|
|||
return arr.map { WorkTask(json: $0) }
|
||||
}
|
||||
|
||||
func completeTask(taskId: Int) async throws {
|
||||
let json = try await postJSON("/tasks/complete.cfm", payload: [
|
||||
func completeTask(taskId: Int, cashReceivedCents: Int? = nil) async throws {
|
||||
var payload: [String: Any] = [
|
||||
"TaskID": taskId,
|
||||
"UserID": userId ?? 0
|
||||
])
|
||||
]
|
||||
if let cents = cashReceivedCents {
|
||||
payload["CashReceivedCents"] = cents
|
||||
}
|
||||
let json = try await postJSON("/tasks/complete.cfm", payload: payload)
|
||||
guard ok(json) else {
|
||||
throw APIError.serverError("Failed to complete task: \(err(json))")
|
||||
}
|
||||
}
|
||||
|
||||
func closeChat(taskId: Int) async throws {
|
||||
let json = try await postJSON("/tasks/completeChat.cfm", payload: [
|
||||
let payload: [String: Any] = [
|
||||
"TaskID": taskId
|
||||
])
|
||||
]
|
||||
let json = try await postJSON("/tasks/completeChat.cfm", payload: payload)
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
return details
|
||||
}
|
||||
|
|
@ -474,6 +589,108 @@ actor APIService {
|
|||
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
|
||||
|
||||
/// Returns raw JSON string for a given endpoint (for debugging key issues)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import UIKit
|
|||
import CoreBluetooth
|
||||
import CoreLocation
|
||||
|
||||
/// Beacon scanner for task auto-completion.
|
||||
/// Scans for a specific UUID and triggers callback after dwell time is met.
|
||||
/// Beacon scanner for task auto-completion using the Payfrit shard system.
|
||||
/// Scans for ALL shard UUIDs and resolves via API to find the target ServicePoint.
|
||||
final class BeaconScanner: NSObject, ObservableObject {
|
||||
private let targetUUID: String
|
||||
private let targetServicePointId: Int
|
||||
private let onBeaconDetected: (Double) -> Void
|
||||
private let onBluetoothOff: (() -> Void)?
|
||||
private let onPermissionDenied: (() -> Void)?
|
||||
|
|
@ -13,37 +13,39 @@ final class BeaconScanner: NSObject, ObservableObject {
|
|||
@Published var isScanning = false
|
||||
|
||||
private var locationManager: CLLocationManager?
|
||||
private var activeConstraint: CLBeaconIdentityConstraint?
|
||||
private var activeConstraints: [CLBeaconIdentityConstraint] = []
|
||||
private var checkTimer: Timer?
|
||||
|
||||
// RSSI samples for dwell time enforcement
|
||||
private var rssiSamples: [Int] = []
|
||||
private let minSamplesToConfirm = 5 // ~5 seconds
|
||||
private let minSamplesToConfirm = 5 // ~5 seconds at 1Hz ranging
|
||||
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,
|
||||
onBluetoothOff: (() -> Void)? = nil,
|
||||
onPermissionDenied: (() -> Void)? = nil) {
|
||||
self.targetUUID = targetUUID
|
||||
self.targetServicePointId = targetServicePointId
|
||||
self.onBeaconDetected = onBeaconDetected
|
||||
self.onBluetoothOff = onBluetoothOff
|
||||
self.onPermissionDenied = onPermissionDenied
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - UUID formatting
|
||||
|
||||
private func formatUUID(_ uuid: String) -> String {
|
||||
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard clean.count == 32 else { return uuid }
|
||||
let i = clean.startIndex
|
||||
let p1 = clean[i..<clean.index(i, offsetBy: 8)]
|
||||
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)]
|
||||
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)]
|
||||
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)]
|
||||
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
|
||||
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
|
||||
// Legacy init for backwards compatibility with UUID-based scanning
|
||||
convenience init(targetUUID: String,
|
||||
onBeaconDetected: @escaping (Double) -> Void,
|
||||
onBluetoothOff: (() -> Void)? = nil,
|
||||
onPermissionDenied: (() -> Void)? = nil) {
|
||||
// Use 0 as a placeholder - this path won't use ServicePoint matching
|
||||
self.init(targetServicePointId: 0,
|
||||
onBeaconDetected: onBeaconDetected,
|
||||
onBluetoothOff: onBluetoothOff,
|
||||
onPermissionDenied: onPermissionDenied)
|
||||
}
|
||||
|
||||
// MARK: - Start/Stop
|
||||
|
|
@ -51,26 +53,30 @@ final class BeaconScanner: NSObject, ObservableObject {
|
|||
func startScanning() {
|
||||
guard !isScanning else { return }
|
||||
|
||||
let formatted = formatUUID(targetUUID)
|
||||
guard let uuid = UUID(uuidString: formatted) else { return }
|
||||
|
||||
locationManager = CLLocationManager()
|
||||
locationManager?.delegate = self
|
||||
|
||||
let status = locationManager!.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
locationManager?.requestWhenInUseAuthorization()
|
||||
return
|
||||
}
|
||||
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||
onPermissionDenied?()
|
||||
return
|
||||
}
|
||||
|
||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||
activeConstraint = constraint
|
||||
locationManager?.startRangingBeacons(satisfying: constraint)
|
||||
// Start ranging for ALL shard UUIDs
|
||||
for uuid in BeaconShardPool.uuids {
|
||||
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
||||
activeConstraints.append(constraint)
|
||||
locationManager?.startRangingBeacons(satisfying: constraint)
|
||||
}
|
||||
|
||||
isScanning = true
|
||||
rssiSamples.removeAll()
|
||||
|
||||
// Idle timer disabled so screen stays on during scanning
|
||||
// Keep screen on during scanning
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
}
|
||||
|
|
@ -82,14 +88,16 @@ final class BeaconScanner: NSObject, ObservableObject {
|
|||
self?.onBluetoothOff?()
|
||||
}
|
||||
}
|
||||
|
||||
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)")
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
isScanning = false
|
||||
if let constraint = activeConstraint {
|
||||
for constraint in activeConstraints {
|
||||
locationManager?.stopRangingBeacons(satisfying: constraint)
|
||||
}
|
||||
activeConstraint = nil
|
||||
activeConstraints.removeAll()
|
||||
checkTimer?.invalidate()
|
||||
checkTimer = nil
|
||||
rssiSamples.removeAll()
|
||||
|
|
@ -106,6 +114,39 @@ final class BeaconScanner: NSObject, ObservableObject {
|
|||
stopScanning()
|
||||
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
|
||||
|
|
@ -113,23 +154,34 @@ final class BeaconScanner: NSObject, ObservableObject {
|
|||
extension BeaconScanner: CLLocationManagerDelegate {
|
||||
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
||||
satisfying constraint: CLBeaconIdentityConstraint) {
|
||||
let normalizedTarget = targetUUID.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
var foundThisCycle = false
|
||||
var foundTarget = false
|
||||
|
||||
for beacon in beacons {
|
||||
let rssi = beacon.rssi
|
||||
guard rssi != 0 else { continue }
|
||||
|
||||
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
||||
guard detectedUUID == normalizedTarget else { continue }
|
||||
// Check if this is a Payfrit beacon
|
||||
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 {
|
||||
rssiSamples.append(rssi)
|
||||
|
||||
if rssiSamples.count >= minSamplesToConfirm {
|
||||
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
|
||||
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onBeaconDetected(avg)
|
||||
}
|
||||
|
|
@ -140,13 +192,24 @@ extension BeaconScanner: CLLocationManagerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// Beacon lost this cycle
|
||||
if !foundThisCycle && !rssiSamples.isEmpty {
|
||||
// Target beacon lost this cycle
|
||||
if !foundTarget && !rssiSamples.isEmpty {
|
||||
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) {
|
||||
// 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 businessId: Int = 0
|
||||
@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 }
|
||||
|
||||
|
|
|
|||
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 showingMyTasks = 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 {
|
||||
ScrollView {
|
||||
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 {
|
||||
ProgressView()
|
||||
.padding(.top, 40)
|
||||
.padding(.top, 20)
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
} else {
|
||||
if let tier = tierStatus {
|
||||
tierCard(tier)
|
||||
activationCard(tier)
|
||||
}
|
||||
VStack(spacing: 16) {
|
||||
if let tier = tierStatus {
|
||||
tierCard(tier)
|
||||
.staggeredAppear(index: 2, appeared: appearAnimation)
|
||||
activationCard(tier)
|
||||
.staggeredAppear(index: 3, appeared: appearAnimation)
|
||||
}
|
||||
|
||||
if let ledger = ledger {
|
||||
earningsCard(ledger)
|
||||
}
|
||||
if let ledger = 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")
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Button { showingMyTasks = true } label: {
|
||||
|
|
@ -51,8 +134,16 @@ struct AccountScreen: View {
|
|||
.navigationDestination(isPresented: $showingMyTasks) {
|
||||
MyTasksScreen()
|
||||
}
|
||||
.task { await loadData() }
|
||||
.task {
|
||||
await loadAvatar()
|
||||
await loadData()
|
||||
}
|
||||
.refreshable { await loadData() }
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
appearAnimation = true
|
||||
}
|
||||
}
|
||||
.alert("What is Activation?", isPresented: $showActivationInfo) {
|
||||
Button("Got it", role: .cancel) { }
|
||||
} 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
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -75,7 +213,6 @@ struct AccountScreen: View {
|
|||
}
|
||||
|
||||
if tier.tier >= 1 {
|
||||
// Tier 1 unlocked
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
|
|
@ -84,7 +221,6 @@ struct AccountScreen: View {
|
|||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
} else if !tier.stripe.hasAccount {
|
||||
// No account yet
|
||||
Text("Tier 1 is locked")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -96,7 +232,6 @@ struct AccountScreen: View {
|
|||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
} else if tier.stripe.setupIncomplete {
|
||||
// Account exists but incomplete
|
||||
Text("Stripe needs more info to enable payouts.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -137,7 +272,6 @@ struct AccountScreen: View {
|
|||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
} else {
|
||||
// Progress bar
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: tier.activation.progress)
|
||||
.tint(.payfritGreen)
|
||||
|
|
@ -269,11 +403,21 @@ struct AccountScreen: View {
|
|||
|
||||
// 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 {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
// Load tier status (required)
|
||||
do {
|
||||
tierStatus = try await APIService.shared.getTierStatus()
|
||||
} catch {
|
||||
|
|
@ -282,7 +426,6 @@ struct AccountScreen: View {
|
|||
return
|
||||
}
|
||||
|
||||
// Load ledger (optional — don't block screen if it fails)
|
||||
do {
|
||||
ledger = try await APIService.shared.getLedger()
|
||||
} catch {
|
||||
|
|
@ -304,7 +447,6 @@ struct AccountScreen: View {
|
|||
#endif
|
||||
}
|
||||
}
|
||||
// Refresh on return
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
await loadData()
|
||||
} 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 {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var businesses: [Employment] = []
|
||||
@State private var myTaskBusinessIds: Set<Int> = []
|
||||
@State private var isLoading = true
|
||||
@State private var error: String?
|
||||
@State private var showingTaskList = false
|
||||
|
|
@ -81,6 +82,20 @@ struct BusinessSelectionScreen: View {
|
|||
.navigationDestination(isPresented: $showingAccount) {
|
||||
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) {
|
||||
NavigationStack {
|
||||
|
|
@ -108,7 +123,8 @@ struct BusinessSelectionScreen: View {
|
|||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(businesses) { emp in
|
||||
if emp.pendingTaskCount > 0 {
|
||||
let hasActivity = emp.pendingTaskCount > 0 || myTaskBusinessIds.contains(emp.businessId)
|
||||
if hasActivity {
|
||||
Button { selectBusiness(emp) } label: {
|
||||
businessCard(emp)
|
||||
}
|
||||
|
|
@ -152,19 +168,34 @@ struct BusinessSelectionScreen: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
// Task count or clear checkmark
|
||||
if emp.pendingTaskCount > 0 {
|
||||
Text("\(emp.pendingTaskCount)")
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.payfritGreen)
|
||||
} else {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title2)
|
||||
// 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 {
|
||||
Text("\(emp.pendingTaskCount)")
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.payfritGreen)
|
||||
Text("clear")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
} else if !myTaskBusinessIds.contains(emp.businessId) {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.payfritGreen)
|
||||
Text("clear")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +270,13 @@ struct BusinessSelectionScreen: View {
|
|||
do {
|
||||
let result = try await APIService.shared.getMyBusinesses()
|
||||
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
|
||||
} catch {
|
||||
if !silent {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ struct ChatScreen: View {
|
|||
let taskId: Int
|
||||
let userType: String // "customer" or "worker"
|
||||
var otherPartyName: String?
|
||||
var otherPartyPhotoUrl: String?
|
||||
var servicePointName: String?
|
||||
var taskColor: Color = .blue
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
|
@ -18,13 +21,18 @@ struct ChatScreen: View {
|
|||
@State private var otherUserName: String?
|
||||
@State private var chatEnded = 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
|
||||
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Customer header (for workers)
|
||||
if userType == "worker" {
|
||||
customerHeader
|
||||
}
|
||||
|
||||
// Chat ended banner
|
||||
if chatEnded {
|
||||
Text("This chat has ended")
|
||||
|
|
@ -67,16 +75,23 @@ struct ChatScreen: View {
|
|||
}
|
||||
}
|
||||
.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 {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if chatService.isConnected {
|
||||
Image(systemName: "wifi")
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.payfritGreen)
|
||||
.font(.caption)
|
||||
if chatEnded {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
appState.popToTaskList()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
Text("Tasks")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userType == "worker" && !chatEnded {
|
||||
|
|
@ -88,27 +103,11 @@ struct ChatScreen: View {
|
|||
}
|
||||
}
|
||||
.alert("Close Chat", isPresented: $showCloseChatAlert) {
|
||||
Button("Close Chat") { closeChatAction() }
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Close", role: .destructive) { closeChatAction() }
|
||||
} message: {
|
||||
Text("Are you sure you want to close this chat?")
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Button { showingMyTasks = true } label: {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
.background(Color.payfritGreen)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(Circle())
|
||||
.shadow(radius: 4)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, chatEnded ? 16 : 60)
|
||||
}
|
||||
.navigationDestination(isPresented: $showingMyTasks) {
|
||||
MyTasksScreen()
|
||||
}
|
||||
.task {
|
||||
otherUserName = otherPartyName
|
||||
await initializeChat()
|
||||
|
|
@ -233,29 +232,100 @@ struct ChatScreen: View {
|
|||
// MARK: - Input
|
||||
|
||||
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)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button(action: sendMessage) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.frame(width: 36, height: 36)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.frame(width: 36, height: 36)
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(canSend ? Color.accentColor : Color.accentColor.opacity(0.4))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.clipShape(Circle())
|
||||
.disabled(isSending || messageText.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!canSend)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.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
|
||||
|
||||
private func initializeChat() async {
|
||||
|
|
@ -357,12 +427,16 @@ struct ChatScreen: View {
|
|||
|
||||
private func closeChatAction() {
|
||||
guard userType == "worker" else { return }
|
||||
workerClosedChat = true // Mark that worker is closing, not customer
|
||||
Task {
|
||||
do {
|
||||
// Close chat and complete task
|
||||
try await APIService.shared.closeChat(taskId: taskId)
|
||||
try await APIService.shared.completeTask(taskId: taskId)
|
||||
chatService.closeChatWS()
|
||||
chatEnded = true
|
||||
dismiss()
|
||||
// Go back to task list
|
||||
appState.popToTaskList()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,68 +2,58 @@ import SwiftUI
|
|||
|
||||
struct LoginScreen: View {
|
||||
@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 password = ""
|
||||
@State private var showPassword = false
|
||||
|
||||
// Shared state
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
|
||||
private enum OtpStep {
|
||||
case phone
|
||||
case otp
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: 16) {
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
Image("PayfritLogoLight")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 220)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(width: 180)
|
||||
|
||||
Text("Payfrit Works")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
|
||||
Text("Sign in to view and claim tasks")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
|
||||
if IS_DEV {
|
||||
Text("DEV MODE — password: 123456")
|
||||
Text("DEV MODE — OTP: 123456")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
TextField("Email or Phone", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
|
||||
// Password field with visibility toggle
|
||||
ZStack(alignment: .trailing) {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
} else {
|
||||
SecureField("Password", text: $password)
|
||||
.textContentType(.password)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { login() }
|
||||
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Spacer().frame(height: 8)
|
||||
|
||||
// Error message
|
||||
if let error = error {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
|
|
@ -78,29 +68,271 @@ struct LoginScreen: View {
|
|||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Button(action: login) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
// Main form
|
||||
if usePasswordLogin {
|
||||
passwordLoginView
|
||||
} else {
|
||||
switch step {
|
||||
case .phone: phoneEntryView
|
||||
case .otp: otpEntryView
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
.disabled(isLoading)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
private func login() {
|
||||
// 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 {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
} else {
|
||||
Text("Sign In")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.payfritGreen)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 pass = password
|
||||
guard !user.isEmpty, !pass.isEmpty else {
|
||||
|
|
@ -129,8 +361,13 @@ struct LoginScreen: View {
|
|||
)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LoginScreen()
|
||||
.environmentObject(AppState())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ struct MyTasksScreen: View {
|
|||
HStack(spacing: 4) {
|
||||
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.payfritGreen)
|
||||
.foregroundColor(task.color)
|
||||
Text(task.locationDisplay)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -172,7 +172,7 @@ struct MyTasksScreen: View {
|
|||
|
||||
if task.isChat {
|
||||
Image(systemName: "bubble.left.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
.foregroundColor(task.color)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +187,15 @@ struct MyTasksScreen: View {
|
|||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
if task.isChat {
|
||||
Image(systemName: "bubble.right.fill")
|
||||
.foregroundColor(task.color)
|
||||
.font(.body)
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.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 showingMyTasks = 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 {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
|
|
@ -35,6 +44,8 @@ struct TaskDetailScreen: View {
|
|||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemBackground))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Button { showingMyTasks = true } label: {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
|
|
@ -52,6 +63,7 @@ struct TaskDetailScreen: View {
|
|||
.toolbarBackground(task.color, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.tint(.white)
|
||||
.alert("Accept Task?", isPresented: $showAcceptAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Accept") { acceptTask() }
|
||||
|
|
@ -68,7 +80,7 @@ struct TaskDetailScreen: View {
|
|||
AutoCompleteCountdownView(taskId: task.taskId) { result in
|
||||
showAutoCompleteDialog = false
|
||||
if result == "success" {
|
||||
dismiss()
|
||||
appState.popToRoot()
|
||||
} else if result == "cancelled" || result == "error" {
|
||||
autoCompleting = false
|
||||
beaconDetected = false
|
||||
|
|
@ -82,10 +94,26 @@ struct TaskDetailScreen: View {
|
|||
}
|
||||
.navigationDestination(isPresented: $showingChat) {
|
||||
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() }
|
||||
.onDisappear { beaconScanner?.dispose() }
|
||||
.onChange(of: appState.shouldPopToTaskList) { shouldPop in
|
||||
if shouldPop {
|
||||
appState.shouldPopToTaskList = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
|
@ -93,56 +121,129 @@ struct TaskDetailScreen: View {
|
|||
@ViewBuilder
|
||||
private func contentView(_ details: TaskDetails) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
customerSection(details)
|
||||
if task.isChat {
|
||||
chatButton(details)
|
||||
VStack(spacing: 0) {
|
||||
// Colored header section
|
||||
VStack(spacing: 16) {
|
||||
if isCashTask && orderTotalCents > 0 {
|
||||
cashAmountBanner
|
||||
}
|
||||
customerSection(details)
|
||||
if task.isChat {
|
||||
chatButton(details)
|
||||
}
|
||||
}
|
||||
locationSection(details)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(task.color)
|
||||
.foregroundColor(.white)
|
||||
|
||||
if !details.tableMembers.isEmpty {
|
||||
tableMembersSection(details)
|
||||
// White content section
|
||||
VStack(spacing: 16) {
|
||||
locationSection(details)
|
||||
|
||||
if !details.tableMembers.isEmpty {
|
||||
tableMembersSection(details)
|
||||
}
|
||||
|
||||
if !details.mainItems.isEmpty {
|
||||
orderItemsSection(details)
|
||||
}
|
||||
|
||||
if !details.orderRemarks.isEmpty {
|
||||
remarksSection(details)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 80)
|
||||
}
|
||||
|
||||
if !details.mainItems.isEmpty {
|
||||
orderItemsSection(details)
|
||||
}
|
||||
|
||||
if !details.orderRemarks.isEmpty {
|
||||
remarksSection(details)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 80)
|
||||
.padding(16)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if showAcceptButton || showCompleteButton {
|
||||
if effectiveShowAcceptButton || effectiveShowCompleteButton {
|
||||
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
|
||||
|
||||
private func customerSection(_ d: TaskDetails) -> some View {
|
||||
HStack(spacing: 16) {
|
||||
// Avatar
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(task.color.opacity(0.2))
|
||||
.frame(width: 64, height: 64)
|
||||
// For chat/call server tasks, only show avatar after accepted
|
||||
let showAvatar = !task.isChat || effectiveShowCompleteButton
|
||||
|
||||
if !d.customerPhotoUrl.isEmpty, let url = URL(string: d.customerPhotoUrl) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
return HStack(spacing: 16) {
|
||||
// Avatar - only show for chat tasks after accepted
|
||||
if showAvatar {
|
||||
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
case .failure(let error):
|
||||
// Show initials on error
|
||||
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)
|
||||
.clipShape(Circle())
|
||||
.onAppear {
|
||||
NSLog("[Avatar] Loading customer avatar from: %@", photoUrl)
|
||||
}
|
||||
} else {
|
||||
initialsView(d)
|
||||
.onAppear {
|
||||
NSLog("[Avatar] No photo URL available, customerUserId=%d", d.customerUserId)
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView(d)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +253,7 @@ struct TaskDetailScreen: View {
|
|||
if !d.customerPhone.isEmpty {
|
||||
Text(d.customerPhone)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,14 +262,14 @@ struct TaskDetailScreen: View {
|
|||
if !d.customerPhone.isEmpty {
|
||||
Button { callCustomer(d.customerPhone) } label: {
|
||||
Image(systemName: "phone.fill")
|
||||
.foregroundColor(.green)
|
||||
.foregroundColor(.white)
|
||||
.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 {
|
||||
|
|
@ -177,7 +278,7 @@ struct TaskDetailScreen: View {
|
|||
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
|
||||
return Text(text)
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(task.color)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
// MARK: - Chat Button
|
||||
|
|
@ -186,36 +287,68 @@ struct TaskDetailScreen: View {
|
|||
private func chatButton(_ d: TaskDetails) -> some View {
|
||||
Button { showingChat = true } label: {
|
||||
HStack(spacing: 12) {
|
||||
// Customer avatar
|
||||
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.payfritGreen.opacity(0.1))
|
||||
.frame(width: 48, height: 48)
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
.font(.title2)
|
||||
Circle()
|
||||
.fill(task.color.opacity(0.3))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
|
||||
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) {
|
||||
Text("Chat with Customer")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundColor(.primary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(d.customerFullName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.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)
|
||||
}
|
||||
.foregroundColor(.black.opacity(0.6))
|
||||
}
|
||||
|
||||
Text("Tap to open chat")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
.font(.title2)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.background(Color.white)
|
||||
.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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -409,7 +542,7 @@ struct TaskDetailScreen: View {
|
|||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
if showAcceptButton {
|
||||
if effectiveShowAcceptButton {
|
||||
Button { showAcceptAlert = true } label: {
|
||||
Label("Accept Task", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
|
|
@ -419,14 +552,24 @@ struct TaskDetailScreen: View {
|
|||
.tint(.payfritGreen)
|
||||
}
|
||||
|
||||
if showCompleteButton {
|
||||
Button { showCompleteAlert = true } label: {
|
||||
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
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: {
|
||||
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
|
@ -459,8 +602,30 @@ struct TaskDetailScreen: View {
|
|||
details = d
|
||||
isLoading = false
|
||||
|
||||
if showCompleteButton && !d.beaconUUID.isEmpty {
|
||||
startBeaconScanning(d.beaconUUID)
|
||||
// Fetch customer avatar separately if not included in task details
|
||||
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 {
|
||||
self.error = error.localizedDescription
|
||||
|
|
@ -468,9 +633,9 @@ struct TaskDetailScreen: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func startBeaconScanning(_ uuid: String) {
|
||||
private func startBeaconScanning(_ servicePointId: Int) {
|
||||
let scanner = BeaconScanner(
|
||||
targetUUID: uuid,
|
||||
targetServicePointId: servicePointId,
|
||||
onBeaconDetected: { [self] _ in
|
||||
if !beaconDetected && !autoCompleting {
|
||||
beaconDetected = true
|
||||
|
|
@ -480,7 +645,7 @@ struct TaskDetailScreen: View {
|
|||
}
|
||||
},
|
||||
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
|
||||
scanner.startScanning()
|
||||
|
|
@ -490,10 +655,16 @@ struct TaskDetailScreen: View {
|
|||
Task {
|
||||
do {
|
||||
try await APIService.shared.acceptTask(taskId: task.taskId)
|
||||
taskAccepted = true
|
||||
|
||||
if task.isChat {
|
||||
// Go directly to chat for chat tasks
|
||||
showingChat = true
|
||||
} else {
|
||||
dismiss()
|
||||
// Stay on detail screen, start beacon scanning if applicable
|
||||
if let d = details, d.servicePointId > 0 {
|
||||
startBeaconScanning(d.servicePointId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
|
@ -505,7 +676,7 @@ struct TaskDetailScreen: View {
|
|||
Task {
|
||||
do {
|
||||
try await APIService.shared.completeTask(taskId: task.taskId)
|
||||
dismiss()
|
||||
appState.popToRoot()
|
||||
} catch {
|
||||
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
|
||||
|
||||
struct AutoCompleteCountdownView: View {
|
||||
let taskId: Int
|
||||
var cashReceivedCents: Int? = nil
|
||||
let onResult: (String) -> Void
|
||||
|
||||
@State private var countdown = 3
|
||||
|
|
@ -571,7 +892,7 @@ struct AutoCompleteCountdownView: View {
|
|||
message = "Completing task..."
|
||||
|
||||
do {
|
||||
try await APIService.shared.completeTask(taskId: taskId)
|
||||
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cashReceivedCents)
|
||||
message = "Closing this window now"
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
onResult("success")
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ struct TaskListScreen: View {
|
|||
MyTasksScreen()
|
||||
}
|
||||
.task { loadTasks() }
|
||||
.onAppear { loadTasks(silent: true) }
|
||||
.onReceive(refreshTimer) { _ in loadTasks(silent: true) }
|
||||
}
|
||||
|
||||
|
|
@ -92,13 +93,13 @@ struct TaskListScreen: View {
|
|||
.frame(width: 4)
|
||||
.cornerRadius(2)
|
||||
|
||||
// Chat icon or category icon
|
||||
// Task type icon - uses task type color
|
||||
ZStack {
|
||||
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)
|
||||
Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill")
|
||||
.foregroundColor(task.isChat ? .payfritGreen : task.color)
|
||||
.foregroundColor(task.color)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +111,7 @@ struct TaskListScreen: View {
|
|||
HStack(spacing: 4) {
|
||||
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.payfritGreen)
|
||||
.foregroundColor(task.color)
|
||||
Text(task.locationDisplay)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -134,14 +135,28 @@ struct TaskListScreen: View {
|
|||
.cornerRadius(8)
|
||||
|
||||
if task.isChat {
|
||||
Text("Chat")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.payfritGreen)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bubble.left.fill")
|
||||
.font(.caption2)
|
||||
Text("Chat")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.foregroundColor(task.color)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(task.color.opacity(0.15))
|
||||
.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.payfritGreen.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
.background(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Text(task.timeAgo)
|
||||
|
|
@ -152,9 +167,15 @@ struct TaskListScreen: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: task.isChat ? "arrow.right" : "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
if task.isChat {
|
||||
Image(systemName: "bubble.right.fill")
|
||||
.foregroundColor(task.color)
|
||||
.font(.body)
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue