Add ios-marketing idiom for App Store icon display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-02-10 19:37:59 -08:00
parent 99464d88ce
commit c71b9f7dea
18 changed files with 1853 additions and 262 deletions

14
ExportOptions.plist Normal file
View 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>

View file

@ -27,6 +27,7 @@
B01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000031; }; B01000000031 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000031; };
B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; }; B01000000032 /* BeaconScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000032; };
B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; }; B01000000033 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000033; };
B01000000034 /* BeaconShardPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000034; };
/* Widgets */ /* Widgets */
B01000000050 /* DevRibbon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000050; }; B01000000050 /* DevRibbon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000050; };
@ -40,6 +41,8 @@
B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; }; B01000000045 /* MyTasksScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000045; };
B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; }; B01000000046 /* ChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000046; };
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; }; B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
B01000000048 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000048; };
B01000000049 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000049; };
/* Resources */ /* Resources */
B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; }; B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; };
@ -71,6 +74,7 @@
B02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; }; B02000000031 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; };
B02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; }; B02000000032 /* BeaconScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconScanner.swift; sourceTree = "<group>"; };
B02000000033 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = "<group>"; }; B02000000033 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = "<group>"; };
B02000000034 /* BeaconShardPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconShardPool.swift; sourceTree = "<group>"; };
/* Widgets */ /* Widgets */
B02000000050 /* DevRibbon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevRibbon.swift; sourceTree = "<group>"; }; B02000000050 /* DevRibbon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevRibbon.swift; sourceTree = "<group>"; };
@ -84,6 +88,8 @@
B02000000045 /* MyTasksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksScreen.swift; sourceTree = "<group>"; }; B02000000045 /* MyTasksScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksScreen.swift; sourceTree = "<group>"; };
B02000000046 /* ChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScreen.swift; sourceTree = "<group>"; }; B02000000046 /* ChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScreen.swift; sourceTree = "<group>"; };
B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; }; B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; };
B02000000048 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = "<group>"; };
B02000000049 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = "<group>"; };
/* Resources */ /* Resources */
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -154,6 +160,7 @@
B02000000031 /* AuthStorage.swift */, B02000000031 /* AuthStorage.swift */,
B02000000032 /* BeaconScanner.swift */, B02000000032 /* BeaconScanner.swift */,
B02000000033 /* ChatService.swift */, B02000000033 /* ChatService.swift */,
B02000000034 /* BeaconShardPool.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@ -177,6 +184,8 @@
B02000000045 /* MyTasksScreen.swift */, B02000000045 /* MyTasksScreen.swift */,
B02000000046 /* ChatScreen.swift */, B02000000046 /* ChatScreen.swift */,
B02000000047 /* AccountScreen.swift */, B02000000047 /* AccountScreen.swift */,
B02000000048 /* AboutScreen.swift */,
B02000000049 /* ProfileScreen.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -291,6 +300,7 @@
B01000000031 /* AuthStorage.swift in Sources */, B01000000031 /* AuthStorage.swift in Sources */,
B01000000032 /* BeaconScanner.swift in Sources */, B01000000032 /* BeaconScanner.swift in Sources */,
B01000000033 /* ChatService.swift in Sources */, B01000000033 /* ChatService.swift in Sources */,
B01000000034 /* BeaconShardPool.swift in Sources */,
B01000000050 /* DevRibbon.swift in Sources */, B01000000050 /* DevRibbon.swift in Sources */,
B01000000040 /* RootView.swift in Sources */, B01000000040 /* RootView.swift in Sources */,
B01000000041 /* LoginScreen.swift in Sources */, B01000000041 /* LoginScreen.swift in Sources */,
@ -300,6 +310,8 @@
B01000000045 /* MyTasksScreen.swift in Sources */, B01000000045 /* MyTasksScreen.swift in Sources */,
B01000000046 /* ChatScreen.swift in Sources */, B01000000046 /* ChatScreen.swift in Sources */,
B01000000047 /* AccountScreen.swift in Sources */, B01000000047 /* AccountScreen.swift in Sources */,
B01000000048 /* AboutScreen.swift in Sources */,
B01000000049 /* ProfileScreen.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -429,7 +441,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritWorks/Info.plist; INFOPLIST_FILE = PayfritWorks/Info.plist;
@ -461,7 +473,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = U83YL8VRF3; DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritWorks/Info.plist; INFOPLIST_FILE = PayfritWorks/Info.plist;

View file

@ -5,6 +5,12 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "appicon.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
} }
], ],
"info" : { "info" : {

View file

@ -38,5 +38,19 @@
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
<false/> <false/>
</dict> </dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -13,6 +13,9 @@ struct WorkTask: Identifiable {
let sourceId: Int let sourceId: Int
let categoryName: String let categoryName: String
let categoryColor: String let categoryColor: String
let taskTypeName: String
let taskTypeColor: String
let orderTotal: Double
// Location (may be included in list responses) // Location (may be included in list responses)
let servicePointName: String let servicePointName: String
@ -34,8 +37,12 @@ struct WorkTask: Identifiable {
sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0 sourceId = Self.parseInt(json["SourceID"] ?? json["TaskSourceID"]) ?? 0
// Server key varies: CategoryName, Name, TaskCategoryName try all // Server key varies: CategoryName, Name, TaskCategoryName try all
categoryName = Self.nonEmpty(json["CategoryName"]) ?? Self.nonEmpty(json["Name"]) ?? Self.nonEmpty(json["TaskCategoryName"]) ?? Self.nonEmpty(json["TaskTypeName"]) ?? "Uncategorized" categoryName = Self.nonEmpty(json["CategoryName"]) ?? Self.nonEmpty(json["Name"]) ?? Self.nonEmpty(json["TaskCategoryName"]) ?? Self.nonEmpty(json["TaskTypeName"]) ?? "Uncategorized"
// Server key varies: CategoryColor, Color, TaskCategoryColor try all, then TaskTypeColor fallback // Category color as fallback
categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? Self.nonEmpty(json["TaskTypeColor"]) ?? "#888888" categoryColor = Self.nonEmpty(json["CategoryColor"]) ?? Self.nonEmpty(json["Color"]) ?? Self.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
taskTypeName = (json["TaskTypeName"] as? String) ?? ""
// TaskType color is the primary color source
taskTypeColor = Self.nonEmpty(json["TaskTypeColor"]) ?? ""
orderTotal = Self.parseDouble(json["OrderTotal"]) ?? 0
servicePointName = (json["ServicePointName"] as? String) ?? "" servicePointName = (json["ServicePointName"] as? String) ?? ""
deliveryAddress = (json["DeliveryAddress"] as? String) ?? "" deliveryAddress = (json["DeliveryAddress"] as? String) ?? ""
} }
@ -46,8 +53,10 @@ struct WorkTask: Identifiable {
return "" return ""
} }
/// Primary color from TaskType, falls back to category color
var color: Color { var color: Color {
let hex = categoryColor.replacingOccurrences(of: "#", with: "") let hexSource = !taskTypeColor.isEmpty ? taskTypeColor : categoryColor
let hex = hexSource.replacingOccurrences(of: "#", with: "")
guard hex.count == 6, let val = UInt64(hex, radix: 16) else { guard hex.count == 6, let val = UInt64(hex, radix: 16) else {
return Color(red: 0.53, green: 0.53, blue: 0.53) return Color(red: 0.53, green: 0.53, blue: 0.53)
} }
@ -78,7 +87,9 @@ struct WorkTask: Identifiable {
return "\(hours / 24)d ago" return "\(hours / 24)d ago"
} }
var isChat: Bool { taskTypeId == 2 } /// Chat or Call Team Member tasks - both involve customer interaction
var isChat: Bool { taskTypeId == 2 || taskTypeId == 6 }
var isCash: Bool { taskTypeName.lowercased().contains("cash") }
// MARK: - Flexible parsing helpers // MARK: - Flexible parsing helpers
@ -88,6 +99,15 @@ struct WorkTask: Identifiable {
return s return s
} }
static func parseDouble(_ value: Any?) -> Double? {
guard let value = value else { return nil }
if let d = value as? Double { return d }
if let i = value as? Int { return Double(i) }
if let s = value as? String, let d = Double(s) { return d }
if let n = value as? NSNumber { return n.doubleValue }
return nil
}
static func parseInt(_ value: Any?) -> Int? { static func parseInt(_ value: Any?) -> Int? {
guard let value = value else { return nil } guard let value = value else { return nil }
if let v = value as? Int { return v } if let v = value as? Int { return v }

View file

@ -9,6 +9,8 @@ struct TaskDetails {
let statusId: Int let statusId: Int
let categoryName: String let categoryName: String
let categoryColor: String let categoryColor: String
let taskTypeName: String
let orderTotal: Double
// Order info // Order info
let orderId: Int let orderId: Int
@ -47,6 +49,8 @@ struct TaskDetails {
statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0 statusId = WorkTask.parseInt(json["StatusID"] ?? json["TaskStatusID"]) ?? 0
categoryName = WorkTask.nonEmpty(json["CategoryName"]) ?? WorkTask.nonEmpty(json["Name"]) ?? WorkTask.nonEmpty(json["TaskCategoryName"]) ?? "General" categoryName = WorkTask.nonEmpty(json["CategoryName"]) ?? WorkTask.nonEmpty(json["Name"]) ?? WorkTask.nonEmpty(json["TaskCategoryName"]) ?? "General"
categoryColor = WorkTask.nonEmpty(json["CategoryColor"]) ?? WorkTask.nonEmpty(json["Color"]) ?? WorkTask.nonEmpty(json["TaskCategoryColor"]) ?? "#888888" categoryColor = WorkTask.nonEmpty(json["CategoryColor"]) ?? WorkTask.nonEmpty(json["Color"]) ?? WorkTask.nonEmpty(json["TaskCategoryColor"]) ?? "#888888"
taskTypeName = (json["TaskTypeName"] as? String) ?? ""
orderTotal = Self.parseDouble(json["OrderTotal"]) ?? 0
orderId = WorkTask.parseInt(json["OrderID"]) ?? 0 orderId = WorkTask.parseInt(json["OrderID"]) ?? 0
orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? "" orderRemarks = (json["OrderRemarks"] as? String) ?? (json["Remarks"] as? String) ?? ""
if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty { if let s = (json["OrderSubmittedOn"] ?? json["SubmittedOn"]) as? String, !s.isEmpty {
@ -64,7 +68,25 @@ struct TaskDetails {
customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? "" customerFirstName = (json["CustomerFirstName"] as? String) ?? (json["FirstName"] as? String) ?? ""
customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? "" customerLastName = (json["CustomerLastName"] as? String) ?? (json["LastName"] as? String) ?? ""
customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? "" customerPhone = (json["CustomerPhone"] as? String) ?? (json["Phone"] as? String) ?? ""
let rawPhoto = (json["CustomerPhotoUrl"] as? String) ?? (json["PhotoUrl"] as? String) ?? ""
// Check multiple key variations for customer photo URL
let photoKeys = ["CustomerPhotoUrl", "CustomerPhotoURL", "CUSTOMERPHOTOURL",
"PhotoUrl", "PhotoURL", "PHOTOURL", "Photo_Url", "PHOTO_URL",
"AvatarUrl", "AvatarURL", "AVATARURL", "Avatar_Url", "AVATAR_URL",
"CustomerAvatar", "CUSTOMERAVATAR", "UserPhotoUrl", "USERPHOTOURL"]
var rawPhoto = ""
for key in photoKeys {
if let url = json[key] as? String, !url.isEmpty {
rawPhoto = url
break
}
}
// If no photo URL but we have customerUserId, construct direct avatar URL
if rawPhoto.isEmpty && customerUserId > 0 {
rawPhoto = "/uploads/avatars/\(customerUserId).jpg"
}
customerPhotoUrl = APIService.resolvePhotoUrl(rawPhoto) customerPhotoUrl = APIService.resolvePhotoUrl(rawPhoto)
beaconUUID = (json["BeaconUUID"] as? String) ?? "" beaconUUID = (json["BeaconUUID"] as? String) ?? ""
@ -113,6 +135,7 @@ struct TaskDetails {
return "No location specified" return "No location specified"
} }
var isCash: Bool { taskTypeName.lowercased().contains("cash") }
var isDelivery: Bool { !deliveryAddress.isEmpty } var isDelivery: Bool { !deliveryAddress.isEmpty }
var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty } var isTableService: Bool { servicePointId > 0 && !servicePointName.isEmpty }

View file

@ -1,10 +1,13 @@
import Foundation import Foundation
import os.log
private let logger = Logger(subsystem: "com.payfrit.works", category: "API")
// MARK: - Dev Flag // MARK: - Dev Flag
/// Master flag: flip to `false` for production builds. /// Master flag: flip to `false` for production builds.
/// Controls API endpoint, dev banner, and magic OTPs. /// Controls API endpoint, dev banner, and magic OTPs.
let IS_DEV = true let IS_DEV = false
// MARK: - API Errors // MARK: - API Errors
@ -37,6 +40,13 @@ struct LoginResponse {
let photoUrl: String let photoUrl: String
} }
// MARK: - OTP Response
struct SendOtpResponse {
let uuid: String
let message: String
}
// MARK: - Chat Messages Result // MARK: - Chat Messages Result
struct ChatMessagesResult { struct ChatMessagesResult {
@ -108,6 +118,35 @@ actor APIService {
throw APIError.decodingError("Non-JSON response") throw APIError.decodingError("Non-JSON response")
} }
private func getJSON(_ path: String) async throws -> [String: Any] {
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let token = userToken, !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if businessId > 0 {
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
}
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
}
if let json = tryDecodeJSON(data) {
return json
}
throw APIError.decodingError("Non-JSON response")
}
private func tryDecodeJSON(_ data: Data) -> [String: Any]? { private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
// Try direct parse // Try direct parse
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
@ -162,7 +201,8 @@ actor APIService {
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt", "GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents", "TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
"AccountID", "ACCOUNT_ID", "URL", "url", "AccountID", "ACCOUNT_ID", "URL", "url",
"CHAT_CLOSED", "chat_closed", "PayCents" "CHAT_CLOSED", "chat_closed", "PayCents",
"TaskTypeName", "OrderTotal", "CashReceivedCents"
] ]
/// Build a lookup: stripped key (no underscores, lowercased) expected PascalCase key. /// Build a lookup: stripped key (no underscores, lowercased) expected PascalCase key.
@ -250,6 +290,57 @@ actor APIService {
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl) return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
} }
// MARK: - OTP Login
func sendLoginOtp(phone: String) async throws -> SendOtpResponse {
let json = try await postJSON("/auth/loginOTP.cfm", payload: [
"phone": phone
])
guard ok(json) else {
throw APIError.serverError("Failed to send code: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
let uuid = (data["UUID"] as? String) ?? (data["uuid"] as? String) ?? ""
let message = (data["MESSAGE"] as? String) ?? (data["message"] as? String) ?? "OTP sent"
return SendOtpResponse(uuid: uuid, message: message)
}
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
let json = try await postJSON("/auth/verifyLoginOTP.cfm", payload: [
"uuid": uuid,
"otp": otp
])
guard ok(json) else {
throw APIError.serverError("Invalid code")
}
let data = json["DATA"] as? [String: Any] ?? json
let uid = (data["UserID"] as? Int)
?? Int(data["UserID"] as? String ?? "")
?? (data["USERID"] as? Int)
?? 0
let token = (data["Token"] as? String)
?? (data["TOKEN"] as? String)
?? (data["token"] as? String)
?? ""
let firstName = (data["FirstName"] as? String)
?? (data["FIRSTNAME"] as? String)
?? (data["USERFIRSTNAME"] as? String)
?? ""
let photoUrl = (data["PhotoUrl"] as? String)
?? (data["PHOTOURL"] as? String)
?? ""
self.userToken = token
self.userId = uid
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
}
func logout() { func logout() {
userToken = nil userToken = nil
userId = nil userId = nil
@ -328,22 +419,27 @@ actor APIService {
return arr.map { WorkTask(json: $0) } return arr.map { WorkTask(json: $0) }
} }
func completeTask(taskId: Int) async throws { func completeTask(taskId: Int, cashReceivedCents: Int? = nil) async throws {
let json = try await postJSON("/tasks/complete.cfm", payload: [ var payload: [String: Any] = [
"TaskID": taskId, "TaskID": taskId,
"UserID": userId ?? 0 "UserID": userId ?? 0
]) ]
if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents
}
let json = try await postJSON("/tasks/complete.cfm", payload: payload)
guard ok(json) else { guard ok(json) else {
throw APIError.serverError("Failed to complete task: \(err(json))") throw APIError.serverError("Failed to complete task: \(err(json))")
} }
} }
func closeChat(taskId: Int) async throws { func closeChat(taskId: Int) async throws {
let json = try await postJSON("/tasks/completeChat.cfm", payload: [ let payload: [String: Any] = [
"TaskID": taskId "TaskID": taskId
]) ]
let json = try await postJSON("/tasks/completeChat.cfm", payload: payload)
guard ok(json) else { guard ok(json) else {
throw APIError.serverError("Failed to close chat: \(err(json))") throw APIError.serverError("Close chat failed: \(err(json))")
} }
} }
@ -370,6 +466,25 @@ actor APIService {
throw APIError.serverError("Invalid task details response") throw APIError.serverError("Invalid task details response")
} }
// Debug: log ALL fields to find customer data
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let debugFile = docs.appendingPathComponent("task_debug.txt")
var lines: [String] = ["=== ALL KEYS ==="]
for key in taskJson.keys.sorted() {
let val = taskJson[key]
if let str = val as? String {
lines.append("\(key): \(str)")
} else if let num = val as? Int {
lines.append("\(key): \(num)")
} else if val is [[String: Any]] {
lines.append("\(key): [array]")
} else {
lines.append("\(key): \(val ?? "nil")")
}
}
try? lines.joined(separator: "\n").write(to: debugFile, atomically: true, encoding: .utf8)
}
let details = TaskDetails(json: taskJson) let details = TaskDetails(json: taskJson)
return details return details
} }
@ -474,6 +589,108 @@ actor APIService {
return try JSONDecoder().decode(LedgerResponse.self, from: data) return try JSONDecoder().decode(LedgerResponse.self, from: data)
} }
// MARK: - Avatar
func getAvatarUrl() async throws -> String? {
let json = try await getJSON("/auth/avatar.cfm")
print("[Avatar] Response: \(json)")
guard ok(json) else {
print("[Avatar] Response not OK")
return nil
}
let data = json["DATA"] as? [String: Any] ?? json
print("[Avatar] Data: \(data)")
// Try all possible key variations for avatar URL
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
for key in keys {
if let url = data[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)")
return resolved
}
if let url = json[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)")
return resolved
}
}
print("[Avatar] No avatar URL found in response")
return nil
}
/// Get avatar URL for any user by their userId
func getUserAvatarUrl(userId: Int) async throws -> String? {
// Try the avatar endpoint with UserID
let json = try await postJSON("/auth/avatar.cfm", payload: ["UserID": userId])
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)")
let data = json["DATA"] as? [String: Any] ?? json
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
for key in keys {
if let url = data[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)")
return Self.resolvePhotoUrl(url)
}
if let url = json[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)")
return Self.resolvePhotoUrl(url)
}
}
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())")
return nil
}
/// Construct direct avatar URL for a user (fallback pattern)
nonisolated static func directAvatarUrl(userId: Int) -> String {
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
return "\(baseDomain)/uploads/avatars/\(userId).jpg"
}
// MARK: - Beacon Sharding
func resolveServicePoint(uuid: String, major: Int, minor: Int) async throws -> Int {
let json = try await postJSON("/beacon-sharding/resolve_servicepoint.cfm", payload: [
"UUID": uuid,
"Major": major,
"Minor": minor
])
guard ok(json) else {
throw APIError.serverError("Failed to resolve service point: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
if let spId = data["ServicePointID"] as? Int { return spId }
if let spId = data["SERVICEPOINTID"] as? Int { return spId }
if let spId = data["servicePointId"] as? Int { return spId }
if let spId = json["ServicePointID"] as? Int { return spId }
if let spId = json["SERVICEPOINTID"] as? Int { return spId }
throw APIError.decodingError("ServicePointID not found in response")
}
func resolveBusiness(uuid: String, major: Int) async throws -> (businessId: Int, businessName: String) {
let json = try await postJSON("/beacon-sharding/resolve_business.cfm", payload: [
"UUID": uuid,
"Major": major
])
guard ok(json) else {
throw APIError.serverError("Failed to resolve business: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
let bizId = (data["BusinessID"] as? Int) ?? (data["BUSINESSID"] as? Int) ?? (json["BusinessID"] as? Int) ?? 0
let bizName = (data["BusinessName"] as? String) ?? (data["BUSINESSNAME"] as? String) ?? (json["BusinessName"] as? String) ?? ""
return (businessId: bizId, businessName: bizName)
}
// MARK: - Debug // MARK: - Debug
/// Returns raw JSON string for a given endpoint (for debugging key issues) /// Returns raw JSON string for a given endpoint (for debugging key issues)

View file

@ -2,10 +2,10 @@ import UIKit
import CoreBluetooth import CoreBluetooth
import CoreLocation import CoreLocation
/// Beacon scanner for task auto-completion. /// Beacon scanner for task auto-completion using the Payfrit shard system.
/// Scans for a specific UUID and triggers callback after dwell time is met. /// Scans for ALL shard UUIDs and resolves via API to find the target ServicePoint.
final class BeaconScanner: NSObject, ObservableObject { final class BeaconScanner: NSObject, ObservableObject {
private let targetUUID: String private let targetServicePointId: Int
private let onBeaconDetected: (Double) -> Void private let onBeaconDetected: (Double) -> Void
private let onBluetoothOff: (() -> Void)? private let onBluetoothOff: (() -> Void)?
private let onPermissionDenied: (() -> Void)? private let onPermissionDenied: (() -> Void)?
@ -13,37 +13,39 @@ final class BeaconScanner: NSObject, ObservableObject {
@Published var isScanning = false @Published var isScanning = false
private var locationManager: CLLocationManager? private var locationManager: CLLocationManager?
private var activeConstraint: CLBeaconIdentityConstraint? private var activeConstraints: [CLBeaconIdentityConstraint] = []
private var checkTimer: Timer? private var checkTimer: Timer?
// RSSI samples for dwell time enforcement // RSSI samples for dwell time enforcement
private var rssiSamples: [Int] = [] private var rssiSamples: [Int] = []
private let minSamplesToConfirm = 5 // ~5 seconds private let minSamplesToConfirm = 5 // ~5 seconds at 1Hz ranging
private let rssiThreshold = -75 private let rssiThreshold = -75
init(targetUUID: String, // Track resolved beacons to avoid repeated API calls
private var resolvedBeacons: [String: Int] = [:] // "uuid-major-minor" -> servicePointId
private var pendingResolutions: Set<String> = []
init(targetServicePointId: Int,
onBeaconDetected: @escaping (Double) -> Void, onBeaconDetected: @escaping (Double) -> Void,
onBluetoothOff: (() -> Void)? = nil, onBluetoothOff: (() -> Void)? = nil,
onPermissionDenied: (() -> Void)? = nil) { onPermissionDenied: (() -> Void)? = nil) {
self.targetUUID = targetUUID self.targetServicePointId = targetServicePointId
self.onBeaconDetected = onBeaconDetected self.onBeaconDetected = onBeaconDetected
self.onBluetoothOff = onBluetoothOff self.onBluetoothOff = onBluetoothOff
self.onPermissionDenied = onPermissionDenied self.onPermissionDenied = onPermissionDenied
super.init() super.init()
} }
// MARK: - UUID formatting // Legacy init for backwards compatibility with UUID-based scanning
convenience init(targetUUID: String,
private func formatUUID(_ uuid: String) -> String { onBeaconDetected: @escaping (Double) -> Void,
let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() onBluetoothOff: (() -> Void)? = nil,
guard clean.count == 32 else { return uuid } onPermissionDenied: (() -> Void)? = nil) {
let i = clean.startIndex // Use 0 as a placeholder - this path won't use ServicePoint matching
let p1 = clean[i..<clean.index(i, offsetBy: 8)] self.init(targetServicePointId: 0,
let p2 = clean[clean.index(i, offsetBy: 8)..<clean.index(i, offsetBy: 12)] onBeaconDetected: onBeaconDetected,
let p3 = clean[clean.index(i, offsetBy: 12)..<clean.index(i, offsetBy: 16)] onBluetoothOff: onBluetoothOff,
let p4 = clean[clean.index(i, offsetBy: 16)..<clean.index(i, offsetBy: 20)] onPermissionDenied: onPermissionDenied)
let p5 = clean[clean.index(i, offsetBy: 20)..<clean.index(i, offsetBy: 32)]
return "\(p1)-\(p2)-\(p3)-\(p4)-\(p5)"
} }
// MARK: - Start/Stop // MARK: - Start/Stop
@ -51,26 +53,30 @@ final class BeaconScanner: NSObject, ObservableObject {
func startScanning() { func startScanning() {
guard !isScanning else { return } guard !isScanning else { return }
let formatted = formatUUID(targetUUID)
guard let uuid = UUID(uuidString: formatted) else { return }
locationManager = CLLocationManager() locationManager = CLLocationManager()
locationManager?.delegate = self locationManager?.delegate = self
let status = locationManager!.authorizationStatus let status = locationManager!.authorizationStatus
if status == .notDetermined {
locationManager?.requestWhenInUseAuthorization()
return
}
guard status == .authorizedWhenInUse || status == .authorizedAlways else { guard status == .authorizedWhenInUse || status == .authorizedAlways else {
onPermissionDenied?() onPermissionDenied?()
return return
} }
let constraint = CLBeaconIdentityConstraint(uuid: uuid) // Start ranging for ALL shard UUIDs
activeConstraint = constraint for uuid in BeaconShardPool.uuids {
locationManager?.startRangingBeacons(satisfying: constraint) let constraint = CLBeaconIdentityConstraint(uuid: uuid)
activeConstraints.append(constraint)
locationManager?.startRangingBeacons(satisfying: constraint)
}
isScanning = true isScanning = true
rssiSamples.removeAll() rssiSamples.removeAll()
// Idle timer disabled so screen stays on during scanning // Keep screen on during scanning
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.isIdleTimerDisabled = true
} }
@ -82,14 +88,16 @@ final class BeaconScanner: NSObject, ObservableObject {
self?.onBluetoothOff?() self?.onBluetoothOff?()
} }
} }
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)")
} }
func stopScanning() { func stopScanning() {
isScanning = false isScanning = false
if let constraint = activeConstraint { for constraint in activeConstraints {
locationManager?.stopRangingBeacons(satisfying: constraint) locationManager?.stopRangingBeacons(satisfying: constraint)
} }
activeConstraint = nil activeConstraints.removeAll()
checkTimer?.invalidate() checkTimer?.invalidate()
checkTimer = nil checkTimer = nil
rssiSamples.removeAll() rssiSamples.removeAll()
@ -106,6 +114,39 @@ final class BeaconScanner: NSObject, ObservableObject {
stopScanning() stopScanning()
locationManager = nil locationManager = nil
} }
// MARK: - Beacon Resolution
private func beaconKey(_ beacon: CLBeacon) -> String {
return "\(beacon.uuid.uuidString)-\(beacon.major)-\(beacon.minor)"
}
private func resolveBeacon(_ beacon: CLBeacon) {
let key = beaconKey(beacon)
guard resolvedBeacons[key] == nil && !pendingResolutions.contains(key) else { return }
pendingResolutions.insert(key)
Task {
do {
let servicePointId = try await APIService.shared.resolveServicePoint(
uuid: beacon.uuid.uuidString,
major: beacon.major.intValue,
minor: beacon.minor.intValue
)
await MainActor.run {
self.resolvedBeacons[key] = servicePointId
self.pendingResolutions.remove(key)
print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)")
}
} catch {
await MainActor.run {
self.pendingResolutions.remove(key)
print("[BeaconScanner] Failed to resolve \(key): \(error)")
}
}
}
}
} }
// MARK: - CLLocationManagerDelegate // MARK: - CLLocationManagerDelegate
@ -113,23 +154,34 @@ final class BeaconScanner: NSObject, ObservableObject {
extension BeaconScanner: CLLocationManagerDelegate { extension BeaconScanner: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
satisfying constraint: CLBeaconIdentityConstraint) { satisfying constraint: CLBeaconIdentityConstraint) {
let normalizedTarget = targetUUID.replacingOccurrences(of: "-", with: "").uppercased() var foundTarget = false
var foundThisCycle = false
for beacon in beacons { for beacon in beacons {
let rssi = beacon.rssi let rssi = beacon.rssi
guard rssi != 0 else { continue } guard rssi != 0 else { continue }
let detectedUUID = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased() // Check if this is a Payfrit beacon
guard detectedUUID == normalizedTarget else { continue } guard BeaconShardPool.isPayfrit(beacon.uuid) else { continue }
foundThisCycle = true let key = beaconKey(beacon)
// Try to resolve if not already resolved
if resolvedBeacons[key] == nil {
resolveBeacon(beacon)
continue
}
// Check if this is our target service point
guard resolvedBeacons[key] == targetServicePointId else { continue }
foundTarget = true
if rssi >= rssiThreshold { if rssi >= rssiThreshold {
rssiSamples.append(rssi) rssiSamples.append(rssi)
if rssiSamples.count >= minSamplesToConfirm { if rssiSamples.count >= minSamplesToConfirm {
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)")
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.onBeaconDetected(avg) self?.onBeaconDetected(avg)
} }
@ -140,13 +192,24 @@ extension BeaconScanner: CLLocationManagerDelegate {
} }
} }
// Beacon lost this cycle // Target beacon lost this cycle
if !foundThisCycle && !rssiSamples.isEmpty { if !foundTarget && !rssiSamples.isEmpty {
rssiSamples.removeAll() rssiSamples.removeAll()
} }
} }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
if status == .authorizedWhenInUse || status == .authorizedAlways {
if !isScanning {
startScanning()
}
} else if status == .denied || status == .restricted {
onPermissionDenied?()
}
}
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
// Ranging failed - could be Bluetooth off print("[BeaconScanner] Ranging failed: \(error)")
} }
} }

View file

@ -8,6 +8,21 @@ final class AppState: ObservableObject {
@Published var userToken: String? @Published var userToken: String?
@Published var businessId: Int = 0 @Published var businessId: Int = 0
@Published var isAuthenticated = false @Published var isAuthenticated = false
@Published var shouldPopToRoot = false
@Published var shouldPopToTaskList = false
@Published var needsRefresh = false
/// Navigate back to root (business selection) and trigger a data refresh
func popToRoot() {
needsRefresh = true
shouldPopToRoot = true
}
/// Navigate back to task list (not all the way to business selection)
func popToTaskList() {
needsRefresh = true
shouldPopToTaskList = true
}
var isLoggedIn: Bool { userId != nil && userToken != nil } var isLoggedIn: Bool { userId != nil && userToken != nil }

View 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()
}
}

View file

@ -10,30 +10,113 @@ struct AccountScreen: View {
@State private var error: String? @State private var error: String?
@State private var showingMyTasks = false @State private var showingMyTasks = false
@State private var showActivationInfo = false @State private var showActivationInfo = false
@State private var appearAnimation = false
@State private var avatarURLLoaded: URL?
private var displayName: String {
appState.userName ?? "Worker"
}
private var avatarURL: URL? {
// Prefer loaded URL from API, fall back to appState
if let loaded = avatarURLLoaded { return loaded }
guard let urlString = appState.userPhotoUrl, !urlString.isEmpty else { return nil }
return URL(string: urlString)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Avatar section
VStack(spacing: 8) {
if let url = avatarURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
placeholderAvatar
default:
ProgressView()
.frame(width: 70, height: 70)
}
}
.frame(width: 70, height: 70)
.clipShape(Circle())
.overlay(Circle().stroke(Color.payfritGreen.opacity(0.3), lineWidth: 2))
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
} else {
placeholderAvatar
}
Text(displayName)
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.scaleEffect(appearAnimation ? 1 : 0.9)
.opacity(appearAnimation ? 1 : 0)
// Navigation cards
VStack(spacing: 12) {
NavigationLink {
ProfileScreen()
.environmentObject(appState)
} label: {
accountCard(
icon: "person",
title: "Edit Profile",
subtitle: "View your account info"
)
}
.staggeredAppear(index: 0, appeared: appearAnimation)
NavigationLink {
AboutScreen()
} label: {
accountCard(
icon: "info.circle",
title: "About Payfrit",
subtitle: "App info and features"
)
}
.staggeredAppear(index: 1, appeared: appearAnimation)
}
.padding(.horizontal, 16)
// Payout content
if isLoading { if isLoading {
ProgressView() ProgressView()
.padding(.top, 40) .padding(.top, 20)
} else if let error = error { } else if let error = error {
errorView(error) errorView(error)
} else { } else {
if let tier = tierStatus { VStack(spacing: 16) {
tierCard(tier) if let tier = tierStatus {
activationCard(tier) tierCard(tier)
} .staggeredAppear(index: 2, appeared: appearAnimation)
activationCard(tier)
.staggeredAppear(index: 3, appeared: appearAnimation)
}
if let ledger = ledger { if let ledger = ledger {
earningsCard(ledger) earningsCard(ledger)
} .staggeredAppear(index: 4, appeared: appearAnimation)
}
logoutButton logoutButton
.staggeredAppear(index: 5, appeared: appearAnimation)
}
.padding(.horizontal, 16)
} }
} }
.padding(16) .padding(.vertical, 16)
} }
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("Account") .navigationTitle("Account")
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: { Button { showingMyTasks = true } label: {
@ -51,8 +134,16 @@ struct AccountScreen: View {
.navigationDestination(isPresented: $showingMyTasks) { .navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen() MyTasksScreen()
} }
.task { await loadData() } .task {
await loadAvatar()
await loadData()
}
.refreshable { await loadData() } .refreshable { await loadData() }
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
appearAnimation = true
}
}
.alert("What is Activation?", isPresented: $showActivationInfo) { .alert("What is Activation?", isPresented: $showActivationInfo) {
Button("Got it", role: .cancel) { } Button("Got it", role: .cancel) { }
} message: { } message: {
@ -60,6 +151,53 @@ struct AccountScreen: View {
} }
} }
// MARK: - Avatar
private var placeholderAvatar: some View {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundStyle(.linearGradient(
colors: [Color.payfritGreen.opacity(0.6), Color.payfritGreen.opacity(0.3)],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 70, height: 70)
.shadow(color: .black.opacity(0.1), radius: 6, y: 3)
}
// MARK: - Account Card
private func accountCard(icon: String, title: String, subtitle: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.subheadline)
.foregroundColor(.payfritGreen)
.frame(width: 28, height: 28)
.background(Color.payfritGreen.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(subtitle)
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.contentShape(Rectangle())
}
// MARK: - Tier Card // MARK: - Tier Card
@ViewBuilder @ViewBuilder
@ -75,7 +213,6 @@ struct AccountScreen: View {
} }
if tier.tier >= 1 { if tier.tier >= 1 {
// Tier 1 unlocked
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
@ -84,7 +221,6 @@ struct AccountScreen: View {
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
} }
} else if !tier.stripe.hasAccount { } else if !tier.stripe.hasAccount {
// No account yet
Text("Tier 1 is locked") Text("Tier 1 is locked")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -96,7 +232,6 @@ struct AccountScreen: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.payfritGreen) .tint(.payfritGreen)
} else if tier.stripe.setupIncomplete { } else if tier.stripe.setupIncomplete {
// Account exists but incomplete
Text("Stripe needs more info to enable payouts.") Text("Stripe needs more info to enable payouts.")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -137,7 +272,6 @@ struct AccountScreen: View {
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
} }
} else { } else {
// Progress bar
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ProgressView(value: tier.activation.progress) ProgressView(value: tier.activation.progress)
.tint(.payfritGreen) .tint(.payfritGreen)
@ -269,11 +403,21 @@ struct AccountScreen: View {
// MARK: - Actions // MARK: - Actions
private func loadAvatar() async {
do {
if let urlString = try await APIService.shared.getAvatarUrl(),
let url = URL(string: urlString) {
avatarURLLoaded = url
}
} catch {
// Avatar load failed, will use placeholder
}
}
private func loadData() async { private func loadData() async {
isLoading = true isLoading = true
error = nil error = nil
// Load tier status (required)
do { do {
tierStatus = try await APIService.shared.getTierStatus() tierStatus = try await APIService.shared.getTierStatus()
} catch { } catch {
@ -282,7 +426,6 @@ struct AccountScreen: View {
return return
} }
// Load ledger (optional don't block screen if it fails)
do { do {
ledger = try await APIService.shared.getLedger() ledger = try await APIService.shared.getLedger()
} catch { } catch {
@ -304,7 +447,6 @@ struct AccountScreen: View {
#endif #endif
} }
} }
// Refresh on return
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: 2_000_000_000)
await loadData() await loadData()
} catch { } catch {
@ -351,3 +493,34 @@ struct AccountScreen: View {
} }
} }
} }
// MARK: - Staggered Appear Modifier
private struct StaggeredAppearModifier: ViewModifier {
let index: Int
let appeared: Bool
func body(content: Content) -> some View {
content
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.animation(
.spring(response: 0.4, dampingFraction: 0.8)
.delay(Double(index) * 0.06),
value: appeared
)
}
}
private extension View {
func staggeredAppear(index: Int, appeared: Bool) -> some View {
modifier(StaggeredAppearModifier(index: index, appeared: appeared))
}
}
#Preview {
NavigationStack {
AccountScreen()
.environmentObject(AppState())
}
}

View file

@ -3,6 +3,7 @@ import SwiftUI
struct BusinessSelectionScreen: View { struct BusinessSelectionScreen: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var businesses: [Employment] = [] @State private var businesses: [Employment] = []
@State private var myTaskBusinessIds: Set<Int> = []
@State private var isLoading = true @State private var isLoading = true
@State private var error: String? @State private var error: String?
@State private var showingTaskList = false @State private var showingTaskList = false
@ -81,6 +82,20 @@ struct BusinessSelectionScreen: View {
.navigationDestination(isPresented: $showingAccount) { .navigationDestination(isPresented: $showingAccount) {
AccountScreen() AccountScreen()
} }
.onChange(of: appState.shouldPopToRoot) { shouldPop in
if shouldPop {
showingTaskList = false
showingMyTasks = false
showingAccount = false
appState.shouldPopToRoot = false
}
}
.onChange(of: appState.needsRefresh) { needsRefresh in
if needsRefresh {
loadBusinesses()
appState.needsRefresh = false
}
}
} }
.sheet(isPresented: $showingDebug) { .sheet(isPresented: $showingDebug) {
NavigationStack { NavigationStack {
@ -108,7 +123,8 @@ struct BusinessSelectionScreen: View {
ScrollView { ScrollView {
LazyVStack(spacing: 12) { LazyVStack(spacing: 12) {
ForEach(businesses) { emp in ForEach(businesses) { emp in
if emp.pendingTaskCount > 0 { let hasActivity = emp.pendingTaskCount > 0 || myTaskBusinessIds.contains(emp.businessId)
if hasActivity {
Button { selectBusiness(emp) } label: { Button { selectBusiness(emp) } label: {
businessCard(emp) businessCard(emp)
} }
@ -152,19 +168,34 @@ struct BusinessSelectionScreen: View {
Spacer() Spacer()
// Task count or clear checkmark // Task indicators
if emp.pendingTaskCount > 0 { HStack(spacing: 8) {
Text("\(emp.pendingTaskCount)") // Red indicator if user has active tasks with this business
.font(.title3.bold()) if myTaskBusinessIds.contains(emp.businessId) {
.foregroundColor(.payfritGreen) HStack(spacing: 4) {
} else { Image(systemName: "exclamationmark.circle.fill")
VStack(spacing: 2) { .font(.body)
Image(systemName: "checkmark.circle.fill") .foregroundColor(.red)
.font(.title2) 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) .foregroundColor(.payfritGreen)
Text("clear") } else if !myTaskBusinessIds.contains(emp.businessId) {
.font(.caption2) VStack(spacing: 2) {
.foregroundColor(.secondary) Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundColor(.payfritGreen)
Text("clear")
.font(.caption2)
.foregroundColor(.secondary)
}
} }
} }
@ -239,6 +270,13 @@ struct BusinessSelectionScreen: View {
do { do {
let result = try await APIService.shared.getMyBusinesses() let result = try await APIService.shared.getMyBusinesses()
businesses = result businesses = result
// Fetch user's active tasks to show indicator
let myTasks = try? await APIService.shared.listMyTasks(filterType: "active")
if let tasks = myTasks {
myTaskBusinessIds = Set(tasks.map { $0.businessId })
}
isLoading = false isLoading = false
} catch { } catch {
if !silent { if !silent {

View file

@ -4,6 +4,9 @@ struct ChatScreen: View {
let taskId: Int let taskId: Int
let userType: String // "customer" or "worker" let userType: String // "customer" or "worker"
var otherPartyName: String? var otherPartyName: String?
var otherPartyPhotoUrl: String?
var servicePointName: String?
var taskColor: Color = .blue
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@ -18,13 +21,18 @@ struct ChatScreen: View {
@State private var otherUserName: String? @State private var otherUserName: String?
@State private var chatEnded = false @State private var chatEnded = false
@State private var showCloseChatAlert = false @State private var showCloseChatAlert = false
@State private var showingMyTasks = false @State private var workerClosedChat = false // Track if this worker closed the chat
// Polling timer // Polling timer
private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() private let pollTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Customer header (for workers)
if userType == "worker" {
customerHeader
}
// Chat ended banner // Chat ended banner
if chatEnded { if chatEnded {
Text("This chat has ended") Text("This chat has ended")
@ -67,16 +75,23 @@ struct ChatScreen: View {
} }
} }
.navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer") .navigationTitle(userType == "customer" ? "Chat with Staff" : "Chat with Customer")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(taskColor, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.tint(.white)
.navigationBarBackButtonHidden(chatEnded)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { if chatEnded {
if chatService.isConnected { ToolbarItem(placement: .navigationBarLeading) {
Image(systemName: "wifi") Button {
.foregroundColor(.green) appState.popToTaskList()
.font(.caption) } label: {
} else { HStack(spacing: 4) {
Image(systemName: "wifi.slash") Image(systemName: "chevron.left")
.foregroundColor(.payfritGreen) Text("Tasks")
.font(.caption) }
}
} }
} }
if userType == "worker" && !chatEnded { if userType == "worker" && !chatEnded {
@ -88,27 +103,11 @@ struct ChatScreen: View {
} }
} }
.alert("Close Chat", isPresented: $showCloseChatAlert) { .alert("Close Chat", isPresented: $showCloseChatAlert) {
Button("Close Chat") { closeChatAction() }
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Close", role: .destructive) { closeChatAction() }
} message: { } message: {
Text("Are you sure you want to close this chat?") Text("Are you sure you want to close this chat?")
} }
.overlay(alignment: .bottomTrailing) {
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, chatEnded ? 16 : 60)
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.task { .task {
otherUserName = otherPartyName otherUserName = otherPartyName
await initializeChat() await initializeChat()
@ -233,29 +232,100 @@ struct ChatScreen: View {
// MARK: - Input // MARK: - Input
private var inputArea: some View { private var inputArea: some View {
HStack(spacing: 8) { let canSend = !isSending && !messageText.trimmingCharacters(in: .whitespaces).isEmpty
return HStack(spacing: 8) {
TextField("Type a message...", text: $messageText) TextField("Type a message...", text: $messageText)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.onSubmit { sendMessage() } .onSubmit { sendMessage() }
Button(action: sendMessage) { Button(action: sendMessage) {
if isSending { ZStack {
ProgressView() Circle()
.frame(width: 36, height: 36) .fill(canSend ? Color.accentColor : Color.accentColor.opacity(0.4))
} else { .frame(width: 40, height: 40)
Image(systemName: "paperplane.fill")
.frame(width: 36, height: 36) if isSending {
ProgressView()
.tint(.white)
} else {
Image(systemName: "paperplane.fill")
.foregroundColor(.white)
.font(.system(size: 16))
}
} }
} }
.buttonStyle(.borderedProminent) .disabled(!canSend)
.clipShape(Circle())
.disabled(isSending || messageText.trimmingCharacters(in: .whitespaces).isEmpty)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
// MARK: - Customer Header
private var customerHeader: some View {
HStack(spacing: 12) {
// Customer avatar
ZStack {
Circle()
.fill(taskColor.opacity(0.2))
.frame(width: 44, height: 44)
if let photoUrl = otherPartyPhotoUrl, !photoUrl.isEmpty, let url = URL(string: photoUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
customerInitials
}
}
.frame(width: 44, height: 44)
.clipShape(Circle())
} else {
customerInitials
}
}
VStack(alignment: .leading, spacing: 2) {
Text(otherPartyName ?? "Customer")
.font(.headline)
if let sp = servicePointName, !sp.isEmpty {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.caption2)
Text(sp)
.font(.caption)
}
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
}
private var customerInitials: some View {
let name = otherPartyName ?? ""
let parts = name.split(separator: " ")
let initials: String
if parts.count >= 2 {
initials = "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
} else if let first = parts.first {
initials = String(first.prefix(1)).uppercased()
} else {
initials = "C"
}
return Text(initials)
.font(.headline.bold())
.foregroundColor(taskColor)
}
// MARK: - Actions // MARK: - Actions
private func initializeChat() async { private func initializeChat() async {
@ -357,12 +427,16 @@ struct ChatScreen: View {
private func closeChatAction() { private func closeChatAction() {
guard userType == "worker" else { return } guard userType == "worker" else { return }
workerClosedChat = true // Mark that worker is closing, not customer
Task { Task {
do { do {
// Close chat and complete task
try await APIService.shared.closeChat(taskId: taskId) try await APIService.shared.closeChat(taskId: taskId)
try await APIService.shared.completeTask(taskId: taskId)
chatService.closeChatWS() chatService.closeChatWS()
chatEnded = true chatEnded = true
dismiss() // Go back to task list
appState.popToTaskList()
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }

View file

@ -2,68 +2,58 @@ import SwiftUI
struct LoginScreen: View { struct LoginScreen: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
// Login mode
@State private var usePasswordLogin = false
// OTP flow
@State private var step: OtpStep = .phone
@State private var phoneNumber = ""
@State private var otpCode = ""
@State private var otpUuid = ""
// Password flow
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var showPassword = false @State private var showPassword = false
// Shared state
@State private var isLoading = false @State private var isLoading = false
@State private var error: String? @State private var error: String?
private enum OtpStep {
case phone
case otp
}
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
ScrollView { ScrollView {
VStack(spacing: 12) { VStack(spacing: 16) {
Spacer().frame(height: 40)
Image("PayfritLogoLight") Image("PayfritLogoLight")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 220) .frame(width: 180)
.padding(.horizontal, 16)
Text("Payfrit Works") Text("Payfrit Works")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
Text("Sign in to view and claim tasks") Text("Sign in to view and claim tasks")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.font(.subheadline)
if IS_DEV { if IS_DEV {
Text("DEV MODE — password: 123456") Text("DEV MODE — OTP: 123456")
.font(.caption) .font(.caption)
.foregroundColor(.red) .foregroundColor(.red)
.fontWeight(.medium) .fontWeight(.medium)
} }
VStack(spacing: 12) { Spacer().frame(height: 8)
TextField("Email or Phone", text: $username)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
// Password field with visibility toggle
ZStack(alignment: .trailing) {
Group {
if showPassword {
TextField("Password", text: $password)
.textContentType(.password)
} else {
SecureField("Password", text: $password)
.textContentType(.password)
}
}
.textFieldStyle(.roundedBorder)
.onSubmit { login() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
.font(.subheadline)
}
.padding(.trailing, 8)
}
}
.padding(.top, 8)
// Error message
if let error = error { if let error = error {
HStack { HStack {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
@ -78,29 +68,271 @@ struct LoginScreen: View {
.cornerRadius(8) .cornerRadius(8)
} }
Button(action: login) { // Main form
if isLoading { if usePasswordLogin {
ProgressView() passwordLoginView
.progressViewStyle(CircularProgressViewStyle(tint: .white)) } else {
.frame(maxWidth: .infinity, minHeight: 44) switch step {
} else { case .phone: phoneEntryView
Text("Sign In") case .otp: otpEntryView
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 44)
} }
} }
.buttonStyle(.borderedProminent)
.tint(.payfritGreen) Spacer().frame(height: 16)
.disabled(isLoading)
// 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) .padding(.horizontal, 24)
.frame(minHeight: geo.size.height) .frame(minHeight: geo.size.height)
} }
.scrollDismissesKeyboard(.interactively)
} }
.background(Color(.systemGroupedBackground)) .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 user = username.trimmingCharacters(in: .whitespaces)
let pass = password let pass = password
guard !user.isEmpty, !pass.isEmpty else { guard !user.isEmpty, !pass.isEmpty else {
@ -129,8 +361,13 @@ struct LoginScreen: View {
) )
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
isLoading = false
} }
isLoading = false
} }
} }
} }
#Preview {
LoginScreen()
.environmentObject(AppState())
}

View file

@ -144,7 +144,7 @@ struct MyTasksScreen: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle") Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
.font(.caption2) .font(.caption2)
.foregroundColor(.payfritGreen) .foregroundColor(task.color)
Text(task.locationDisplay) Text(task.locationDisplay)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -172,7 +172,7 @@ struct MyTasksScreen: View {
if task.isChat { if task.isChat {
Image(systemName: "bubble.left.fill") Image(systemName: "bubble.left.fill")
.foregroundColor(.payfritGreen) .foregroundColor(task.color)
.font(.footnote) .font(.footnote)
} }
@ -187,9 +187,15 @@ struct MyTasksScreen: View {
.cornerRadius(8) .cornerRadius(8)
} }
Image(systemName: task.isChat ? "arrow.right" : "chevron.right") if task.isChat {
.foregroundColor(.secondary) Image(systemName: "bubble.right.fill")
.font(.caption) .foregroundColor(task.color)
.font(.body)
} else {
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }

View 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())
}
}

View file

@ -22,6 +22,15 @@ struct TaskDetailScreen: View {
@State private var beaconScanner: BeaconScanner? @State private var beaconScanner: BeaconScanner?
@State private var showingMyTasks = false @State private var showingMyTasks = false
@State private var showingChat = false @State private var showingChat = false
@State private var showCashDialog = false
@State private var cashReceived = ""
@State private var cashError: String?
@State private var taskAccepted = false // Track if task was just accepted
@State private var customerAvatarUrl: String? // Fetched separately if not in task details
// Computed properties for effective button visibility after accepting
private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted }
private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted }
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
@ -35,6 +44,8 @@ struct TaskDetailScreen: View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.foregroundColor(.primary)
Button { showingMyTasks = true } label: { Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
@ -52,6 +63,7 @@ struct TaskDetailScreen: View {
.toolbarBackground(task.color, for: .navigationBar) .toolbarBackground(task.color, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar)
.tint(.white)
.alert("Accept Task?", isPresented: $showAcceptAlert) { .alert("Accept Task?", isPresented: $showAcceptAlert) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Accept") { acceptTask() } Button("Accept") { acceptTask() }
@ -68,7 +80,7 @@ struct TaskDetailScreen: View {
AutoCompleteCountdownView(taskId: task.taskId) { result in AutoCompleteCountdownView(taskId: task.taskId) { result in
showAutoCompleteDialog = false showAutoCompleteDialog = false
if result == "success" { if result == "success" {
dismiss() appState.popToRoot()
} else if result == "cancelled" || result == "error" { } else if result == "cancelled" || result == "error" {
autoCompleting = false autoCompleting = false
beaconDetected = false beaconDetected = false
@ -82,10 +94,26 @@ struct TaskDetailScreen: View {
} }
.navigationDestination(isPresented: $showingChat) { .navigationDestination(isPresented: $showingChat) {
ChatScreen(taskId: task.taskId, userType: "worker", ChatScreen(taskId: task.taskId, userType: "worker",
otherPartyName: details?.customerFullName) otherPartyName: details?.customerFullName,
otherPartyPhotoUrl: details?.customerPhotoUrl,
servicePointName: details?.servicePointName,
taskColor: task.color)
}
.sheet(isPresented: $showCashDialog) {
CashCollectionSheet(
orderTotalCents: orderTotalCents,
taskId: task.taskId,
onComplete: { appState.popToRoot() }
)
} }
.task { await loadDetails() } .task { await loadDetails() }
.onDisappear { beaconScanner?.dispose() } .onDisappear { beaconScanner?.dispose() }
.onChange(of: appState.shouldPopToTaskList) { shouldPop in
if shouldPop {
appState.shouldPopToTaskList = false
dismiss()
}
}
} }
// MARK: - Content // MARK: - Content
@ -93,56 +121,129 @@ struct TaskDetailScreen: View {
@ViewBuilder @ViewBuilder
private func contentView(_ details: TaskDetails) -> some View { private func contentView(_ details: TaskDetails) -> some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 0) {
customerSection(details) // Colored header section
if task.isChat { VStack(spacing: 16) {
chatButton(details) 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 { // White content section
tableMembersSection(details) 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)
} }
.padding(16)
if !details.mainItems.isEmpty {
orderItemsSection(details)
}
if !details.orderRemarks.isEmpty {
remarksSection(details)
}
Spacer().frame(height: 80)
} }
.padding(16)
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if showAcceptButton || showCompleteButton { if effectiveShowAcceptButton || effectiveShowCompleteButton {
bottomBar bottomBar
} }
} }
} }
// MARK: - Cash Helpers
private var isCashTask: Bool {
task.isCash || (details?.isCash ?? false)
}
private var orderTotalCents: Int {
let fromDetails = details?.orderTotal ?? 0
let fromTask = task.orderTotal
let cents = fromDetails > 0 ? fromDetails : fromTask
return Int(cents)
}
private var cashAmountBanner: some View {
HStack(spacing: 12) {
Image(systemName: "dollarsign.circle.fill")
.font(.title2)
.foregroundColor(.white)
VStack(alignment: .leading, spacing: 2) {
Text("Cash Payment Due")
.font(.caption.weight(.medium))
.foregroundColor(.white.opacity(0.9))
Text(String(format: "$%.2f", Double(orderTotalCents) / 100))
.font(.title2.bold())
.foregroundColor(.white)
}
Spacer()
}
.padding(16)
.background(Color(red: 0.13, green: 0.55, blue: 0.13))
.cornerRadius(12)
}
// MARK: - Customer // MARK: - Customer
private func customerSection(_ d: TaskDetails) -> some View { private func customerSection(_ d: TaskDetails) -> some View {
HStack(spacing: 16) { // For chat/call server tasks, only show avatar after accepted
// Avatar let showAvatar = !task.isChat || effectiveShowCompleteButton
ZStack {
Circle()
.fill(task.color.opacity(0.2))
.frame(width: 64, height: 64)
if !d.customerPhotoUrl.isEmpty, let url = URL(string: d.customerPhotoUrl) { return HStack(spacing: 16) {
AsyncImage(url: url) { image in // Avatar - only show for chat tasks after accepted
image.resizable().scaledToFill() if showAvatar {
} placeholder: { 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) 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 { if !d.customerPhone.isEmpty {
Text(d.customerPhone) Text(d.customerPhone)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.white.opacity(0.8))
} }
} }
@ -161,14 +262,14 @@ struct TaskDetailScreen: View {
if !d.customerPhone.isEmpty { if !d.customerPhone.isEmpty {
Button { callCustomer(d.customerPhone) } label: { Button { callCustomer(d.customerPhone) } label: {
Image(systemName: "phone.fill") Image(systemName: "phone.fill")
.foregroundColor(.green) .foregroundColor(.white)
.font(.title2) .font(.title2)
.padding(10)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
} }
} }
} }
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
} }
private func initialsView(_ d: TaskDetails) -> some View { private func initialsView(_ d: TaskDetails) -> some View {
@ -177,7 +278,7 @@ struct TaskDetailScreen: View {
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)" let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
return Text(text) return Text(text)
.font(.title2.bold()) .font(.title2.bold())
.foregroundColor(task.color) .foregroundColor(.white)
} }
// MARK: - Chat Button // MARK: - Chat Button
@ -186,36 +287,68 @@ struct TaskDetailScreen: View {
private func chatButton(_ d: TaskDetails) -> some View { private func chatButton(_ d: TaskDetails) -> some View {
Button { showingChat = true } label: { Button { showingChat = true } label: {
HStack(spacing: 12) { HStack(spacing: 12) {
// Customer avatar
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
ZStack { ZStack {
RoundedRectangle(cornerRadius: 12) Circle()
.fill(Color.payfritGreen.opacity(0.1)) .fill(task.color.opacity(0.3))
.frame(width: 48, height: 48) .frame(width: 56, height: 56)
Image(systemName: "bubble.left.and.bubble.right.fill")
.foregroundColor(.payfritGreen) if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
.font(.title2) AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
chatInitialsView(d)
}
}
.frame(width: 56, height: 56)
.clipShape(Circle())
} else {
chatInitialsView(d)
}
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 4) {
Text("Chat with Customer")
.font(.callout.weight(.medium))
.foregroundColor(.primary)
Text(d.customerFullName) Text(d.customerFullName)
.font(.caption) .font(.callout.weight(.semibold))
.foregroundColor(.secondary) .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() Spacer()
Image(systemName: "chevron.right") Image(systemName: "bubble.left.and.bubble.right.fill")
.foregroundColor(.secondary) .foregroundColor(.payfritGreen)
.font(.caption) .font(.title2)
} }
.padding(16) .padding(16)
.background(Color(.secondarySystemGroupedBackground)) .background(Color.white)
.cornerRadius(12) .cornerRadius(12)
} }
} }
private func chatInitialsView(_ d: TaskDetails) -> some View {
Text(String(d.customerFirstName.prefix(1) + d.customerLastName.prefix(1)).uppercased())
.font(.headline.bold())
.foregroundColor(task.color)
}
// MARK: - Location // MARK: - Location
private func locationSection(_ d: TaskDetails) -> some View { private func locationSection(_ d: TaskDetails) -> some View {
@ -246,7 +379,7 @@ struct TaskDetailScreen: View {
} }
} }
if !d.isDelivery && !d.beaconUUID.isEmpty && showCompleteButton { if !d.isDelivery && d.servicePointId > 0 && effectiveShowCompleteButton {
beaconIndicator beaconIndicator
} }
} }
@ -409,7 +542,7 @@ struct TaskDetailScreen: View {
private var bottomBar: some View { private var bottomBar: some View {
HStack { HStack {
if showAcceptButton { if effectiveShowAcceptButton {
Button { showAcceptAlert = true } label: { Button { showAcceptAlert = true } label: {
Label("Accept Task", systemImage: "plus.circle.fill") Label("Accept Task", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -419,14 +552,24 @@ struct TaskDetailScreen: View {
.tint(.payfritGreen) .tint(.payfritGreen)
} }
if showCompleteButton { if effectiveShowCompleteButton {
Button { showCompleteAlert = true } label: { if isCashTask && orderTotalCents > 0 {
Label("Complete Task", systemImage: "checkmark.circle.fill") Button { showCashDialog = true } label: {
.frame(maxWidth: .infinity) Label("Collect Cash", systemImage: "dollarsign.circle.fill")
.padding(.vertical, 14) .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) .padding(.horizontal)
@ -459,8 +602,30 @@ struct TaskDetailScreen: View {
details = d details = d
isLoading = false isLoading = false
if showCompleteButton && !d.beaconUUID.isEmpty { // Fetch customer avatar separately if not included in task details
startBeaconScanning(d.beaconUUID) if d.customerPhotoUrl.isEmpty && d.customerUserId > 0 {
Task {
// Try API first, then fallback to direct URL pattern
if let avatarUrl = try? await APIService.shared.getUserAvatarUrl(userId: d.customerUserId) {
await MainActor.run {
customerAvatarUrl = avatarUrl
}
} else {
// Try direct URL pattern as fallback
let directUrl = APIService.directAvatarUrl(userId: d.customerUserId)
await MainActor.run {
customerAvatarUrl = directUrl
}
}
}
}
print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
if showCompleteButton && d.servicePointId > 0 {
print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)")
startBeaconScanning(d.servicePointId)
} else {
print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
} }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
@ -468,9 +633,9 @@ struct TaskDetailScreen: View {
} }
} }
private func startBeaconScanning(_ uuid: String) { private func startBeaconScanning(_ servicePointId: Int) {
let scanner = BeaconScanner( let scanner = BeaconScanner(
targetUUID: uuid, targetServicePointId: servicePointId,
onBeaconDetected: { [self] _ in onBeaconDetected: { [self] _ in
if !beaconDetected && !autoCompleting { if !beaconDetected && !autoCompleting {
beaconDetected = true beaconDetected = true
@ -480,7 +645,7 @@ struct TaskDetailScreen: View {
} }
}, },
onBluetoothOff: { bluetoothOff = true }, onBluetoothOff: { bluetoothOff = true },
onPermissionDenied: { self.error = "Bluetooth permission is required for auto-complete. Please enable it in Settings." } onPermissionDenied: { self.error = "Location permission is required for beacon detection. Please enable it in Settings." }
) )
beaconScanner = scanner beaconScanner = scanner
scanner.startScanning() scanner.startScanning()
@ -490,10 +655,16 @@ struct TaskDetailScreen: View {
Task { Task {
do { do {
try await APIService.shared.acceptTask(taskId: task.taskId) try await APIService.shared.acceptTask(taskId: task.taskId)
taskAccepted = true
if task.isChat { if task.isChat {
// Go directly to chat for chat tasks
showingChat = true showingChat = true
} else { } else {
dismiss() // Stay on detail screen, start beacon scanning if applicable
if let d = details, d.servicePointId > 0 {
startBeaconScanning(d.servicePointId)
}
} }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
@ -505,7 +676,7 @@ struct TaskDetailScreen: View {
Task { Task {
do { do {
try await APIService.shared.completeTask(taskId: task.taskId) try await APIService.shared.completeTask(taskId: task.taskId)
dismiss() appState.popToRoot()
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
} }
@ -529,10 +700,160 @@ struct TaskDetailScreen: View {
} }
} }
// MARK: - Cash Collection Sheet
struct CashCollectionSheet: View {
let orderTotalCents: Int
let taskId: Int
let onComplete: () -> Void
@Environment(\.dismiss) var dismiss
@State private var cashReceivedText = ""
@State private var isProcessing = false
@State private var errorMessage: String?
private var orderTotalDollars: Double { Double(orderTotalCents) / 100 }
private var cashReceivedCents: Int? {
guard let dollars = Double(cashReceivedText) else { return nil }
return Int(round(dollars * 100))
}
private var changeDue: Double? {
guard let receivedCents = cashReceivedCents else { return nil }
let change = Double(receivedCents - orderTotalCents) / 100
return change >= 0 ? change : nil
}
private var isValid: Bool {
guard let receivedCents = cashReceivedCents else { return false }
return receivedCents >= orderTotalCents
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Order total
VStack(spacing: 4) {
Text("Amount Due")
.font(.subheadline)
.foregroundColor(.secondary)
Text(String(format: "$%.2f", orderTotalDollars))
.font(.system(size: 42, weight: .bold))
.foregroundColor(Color(red: 0.13, green: 0.55, blue: 0.13))
}
.padding(.top, 16)
// Cash received input
VStack(alignment: .leading, spacing: 8) {
Text("Cash Received")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
HStack {
Text("$")
.font(.title2.weight(.semibold))
.foregroundColor(.secondary)
TextField("0.00", text: $cashReceivedText)
.font(.title2.weight(.semibold))
.keyboardType(.decimalPad)
}
.padding(16)
.background(Color(.systemGray6))
.cornerRadius(12)
}
// Change display
if let change = changeDue {
HStack {
Image(systemName: "arrow.uturn.left.circle.fill")
.foregroundColor(.payfritGreen)
Text("Change:")
.font(.body.weight(.medium))
Spacer()
Text(String(format: "$%.2f", change))
.font(.title3.bold())
.foregroundColor(.payfritGreen)
}
.padding(16)
.background(Color.payfritGreen.opacity(0.1))
.cornerRadius(12)
} else if cashReceivedCents != nil {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text("Amount received must be at least \(String(format: "$%.2f", orderTotalDollars))")
.font(.callout)
.foregroundColor(.red)
}
.padding(16)
.background(Color.red.opacity(0.1))
.cornerRadius(12)
}
if let err = errorMessage {
Text(err)
.font(.callout)
.foregroundColor(.red)
.padding(.horizontal)
}
Spacer()
// Confirm button
Button {
confirmCashCollection()
} label: {
if isProcessing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Confirm Cash Received")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
.disabled(!isValid || isProcessing)
.padding(.bottom, 16)
}
.padding(.horizontal, 24)
.navigationTitle("Collect Cash")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func confirmCashCollection() {
guard let cents = cashReceivedCents, cents >= orderTotalCents else { return }
isProcessing = true
errorMessage = nil
Task {
do {
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
dismiss()
onComplete()
} catch {
errorMessage = error.localizedDescription
isProcessing = false
}
}
}
}
// MARK: - Auto-Complete Countdown // MARK: - Auto-Complete Countdown
struct AutoCompleteCountdownView: View { struct AutoCompleteCountdownView: View {
let taskId: Int let taskId: Int
var cashReceivedCents: Int? = nil
let onResult: (String) -> Void let onResult: (String) -> Void
@State private var countdown = 3 @State private var countdown = 3
@ -571,7 +892,7 @@ struct AutoCompleteCountdownView: View {
message = "Completing task..." message = "Completing task..."
do { do {
try await APIService.shared.completeTask(taskId: taskId) try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cashReceivedCents)
message = "Closing this window now" message = "Closing this window now"
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
onResult("success") onResult("success")

View file

@ -67,6 +67,7 @@ struct TaskListScreen: View {
MyTasksScreen() MyTasksScreen()
} }
.task { loadTasks() } .task { loadTasks() }
.onAppear { loadTasks(silent: true) }
.onReceive(refreshTimer) { _ in loadTasks(silent: true) } .onReceive(refreshTimer) { _ in loadTasks(silent: true) }
} }
@ -92,13 +93,13 @@ struct TaskListScreen: View {
.frame(width: 4) .frame(width: 4)
.cornerRadius(2) .cornerRadius(2)
// Chat icon or category icon // Task type icon - uses task type color
ZStack { ZStack {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(task.isChat ? Color.payfritGreen.opacity(0.15) : task.color.opacity(0.15)) .fill(task.color.opacity(0.15))
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill") Image(systemName: task.isChat ? "bubble.left.and.bubble.right.fill" : "doc.text.fill")
.foregroundColor(task.isChat ? .payfritGreen : task.color) .foregroundColor(task.color)
.font(.subheadline) .font(.subheadline)
} }
@ -110,7 +111,7 @@ struct TaskListScreen: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle") Image(systemName: task.deliveryAddress.isEmpty ? "mappin.circle" : "bicycle")
.font(.caption2) .font(.caption2)
.foregroundColor(.payfritGreen) .foregroundColor(task.color)
Text(task.locationDisplay) Text(task.locationDisplay)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -134,14 +135,28 @@ struct TaskListScreen: View {
.cornerRadius(8) .cornerRadius(8)
if task.isChat { if task.isChat {
Text("Chat") HStack(spacing: 4) {
.font(.caption2) Image(systemName: "bubble.left.fill")
.fontWeight(.medium) .font(.caption2)
.foregroundColor(.payfritGreen) 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(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.payfritGreen.opacity(0.15)) .background(Color(red: 0.13, green: 0.55, blue: 0.13))
.cornerRadius(8) .clipShape(Capsule())
} }
Text(task.timeAgo) Text(task.timeAgo)
@ -152,9 +167,15 @@ struct TaskListScreen: View {
Spacer() Spacer()
Image(systemName: task.isChat ? "arrow.right" : "chevron.right") if task.isChat {
.foregroundColor(.secondary) Image(systemName: "bubble.right.fill")
.font(.caption) .foregroundColor(task.color)
.font(.body)
} else {
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }