diff --git a/ExportOptions.plist b/ExportOptions.plist
new file mode 100644
index 0000000..c7c819b
--- /dev/null
+++ b/ExportOptions.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ method
+ app-store-connect
+ signingStyle
+ automatic
+ uploadSymbols
+
+ destination
+ upload
+
+
diff --git a/PayfritWorks.xcodeproj/project.pbxproj b/PayfritWorks.xcodeproj/project.pbxproj
index 4424436..8fe1771 100644
--- a/PayfritWorks.xcodeproj/project.pbxproj
+++ b/PayfritWorks.xcodeproj/project.pbxproj
@@ -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 = ""; };
B02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = ""; };
B02000000033 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = ""; };
+ B02000000034 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = ""; };
/* Widgets */
B02000000050 /* DevRibbon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevRibbon.swift; sourceTree = ""; };
@@ -84,6 +88,8 @@
B02000000045 /* MyTasksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksScreen.swift; sourceTree = ""; };
B02000000046 /* ChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScreen.swift; sourceTree = ""; };
B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = ""; };
+ B02000000048 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = ""; };
+ B02000000049 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = ""; };
/* Resources */
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
@@ -154,6 +160,7 @@
B02000000031 /* AuthStorage.swift */,
B02000000032 /* BeaconScanner.swift */,
B02000000033 /* ChatService.swift */,
+ B02000000034 /* BeaconShardPool.swift */,
);
path = Services;
sourceTree = "";
@@ -177,6 +184,8 @@
B02000000045 /* MyTasksScreen.swift */,
B02000000046 /* ChatScreen.swift */,
B02000000047 /* AccountScreen.swift */,
+ B02000000048 /* AboutScreen.swift */,
+ B02000000049 /* ProfileScreen.swift */,
);
path = Views;
sourceTree = "";
@@ -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;
diff --git a/PayfritWorks/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritWorks/Assets.xcassets/AppIcon.appiconset/Contents.json
index 3193f63..421ff78 100644
--- a/PayfritWorks/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/PayfritWorks/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -5,6 +5,12 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
+ },
+ {
+ "filename" : "appicon.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
}
],
"info" : {
diff --git a/PayfritWorks/Info.plist b/PayfritWorks/Info.plist
index 2c3c0ab..d804634 100644
--- a/PayfritWorks/Info.plist
+++ b/PayfritWorks/Info.plist
@@ -38,5 +38,19 @@
UIApplicationSupportsMultipleScenes
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
diff --git a/PayfritWorks/Models/Task.swift b/PayfritWorks/Models/Task.swift
index 0672ee5..bc2b480 100644
--- a/PayfritWorks/Models/Task.swift
+++ b/PayfritWorks/Models/Task.swift
@@ -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 }
diff --git a/PayfritWorks/Models/TaskDetails.swift b/PayfritWorks/Models/TaskDetails.swift
index ed7b16c..20ace8f 100644
--- a/PayfritWorks/Models/TaskDetails.swift
+++ b/PayfritWorks/Models/TaskDetails.swift
@@ -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 }
diff --git a/PayfritWorks/Services/APIService.swift b/PayfritWorks/Services/APIService.swift
index 57be562..ea5c473 100644
--- a/PayfritWorks/Services/APIService.swift
+++ b/PayfritWorks/Services/APIService.swift
@@ -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)
diff --git a/PayfritWorks/Services/BeaconScanner.swift b/PayfritWorks/Services/BeaconScanner.swift
index 5e6d558..ca9b02d 100644
--- a/PayfritWorks/Services/BeaconScanner.swift
+++ b/PayfritWorks/Services/BeaconScanner.swift
@@ -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 = []
+
+ 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.. 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)")
}
}
diff --git a/PayfritWorks/ViewModels/AppState.swift b/PayfritWorks/ViewModels/AppState.swift
index 0a214be..9212ff8 100644
--- a/PayfritWorks/ViewModels/AppState.swift
+++ b/PayfritWorks/ViewModels/AppState.swift
@@ -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 }
diff --git a/PayfritWorks/Views/AboutScreen.swift b/PayfritWorks/Views/AboutScreen.swift
new file mode 100644
index 0000000..29c1a21
--- /dev/null
+++ b/PayfritWorks/Views/AboutScreen.swift
@@ -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()
+ }
+}
diff --git a/PayfritWorks/Views/AccountScreen.swift b/PayfritWorks/Views/AccountScreen.swift
index f9d9c7a..232107f 100644
--- a/PayfritWorks/Views/AccountScreen.swift
+++ b/PayfritWorks/Views/AccountScreen.swift
@@ -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())
+ }
+}
diff --git a/PayfritWorks/Views/BusinessSelectionScreen.swift b/PayfritWorks/Views/BusinessSelectionScreen.swift
index f9737bf..1fa0f04 100644
--- a/PayfritWorks/Views/BusinessSelectionScreen.swift
+++ b/PayfritWorks/Views/BusinessSelectionScreen.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct BusinessSelectionScreen: View {
@EnvironmentObject var appState: AppState
@State private var businesses: [Employment] = []
+ @State private var myTaskBusinessIds: Set = []
@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 {
diff --git a/PayfritWorks/Views/ChatScreen.swift b/PayfritWorks/Views/ChatScreen.swift
index 1a7e5f4..d851642 100644
--- a/PayfritWorks/Views/ChatScreen.swift
+++ b/PayfritWorks/Views/ChatScreen.swift
@@ -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
}
diff --git a/PayfritWorks/Views/LoginScreen.swift b/PayfritWorks/Views/LoginScreen.swift
index e069a9c..a9fc4c9 100644
--- a/PayfritWorks/Views/LoginScreen.swift
+++ b/PayfritWorks/Views/LoginScreen.swift
@@ -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())
+}
diff --git a/PayfritWorks/Views/MyTasksScreen.swift b/PayfritWorks/Views/MyTasksScreen.swift
index 3daee78..661132c 100644
--- a/PayfritWorks/Views/MyTasksScreen.swift
+++ b/PayfritWorks/Views/MyTasksScreen.swift
@@ -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)
}
diff --git a/PayfritWorks/Views/ProfileScreen.swift b/PayfritWorks/Views/ProfileScreen.swift
new file mode 100644
index 0000000..4ee62a0
--- /dev/null
+++ b/PayfritWorks/Views/ProfileScreen.swift
@@ -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())
+ }
+}
diff --git a/PayfritWorks/Views/TaskDetailScreen.swift b/PayfritWorks/Views/TaskDetailScreen.swift
index 91a99af..0d9a950 100644
--- a/PayfritWorks/Views/TaskDetailScreen.swift
+++ b/PayfritWorks/Views/TaskDetailScreen.swift
@@ -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")
diff --git a/PayfritWorks/Views/TaskListScreen.swift b/PayfritWorks/Views/TaskListScreen.swift
index 4cfb9b0..2cfc565 100644
--- a/PayfritWorks/Views/TaskListScreen.swift
+++ b/PayfritWorks/Views/TaskListScreen.swift
@@ -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)
}