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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -2,68 +2,58 @@ import SwiftUI
struct LoginScreen: View {
@EnvironmentObject var appState: AppState
// Login mode
@State private var usePasswordLogin = false
// OTP flow
@State private var step: OtpStep = .phone
@State private var phoneNumber = ""
@State private var otpCode = ""
@State private var otpUuid = ""
// Password flow
@State private var username = ""
@State private var password = ""
@State private var showPassword = false
// Shared state
@State private var isLoading = false
@State private var error: String?
private enum OtpStep {
case phone
case otp
}
var body: some View {
GeometryReader { geo in
ScrollView {
VStack(spacing: 12) {
VStack(spacing: 16) {
Spacer().frame(height: 40)
Image("PayfritLogoLight")
.resizable()
.scaledToFit()
.frame(width: 220)
.padding(.horizontal, 16)
.frame(width: 180)
Text("Payfrit Works")
.font(.system(size: 28, weight: .bold))
Text("Sign in to view and claim tasks")
.foregroundColor(.secondary)
.font(.subheadline)
if IS_DEV {
Text("DEV MODE — password: 123456")
Text("DEV MODE — OTP: 123456")
.font(.caption)
.foregroundColor(.red)
.fontWeight(.medium)
}
VStack(spacing: 12) {
TextField("Email or Phone", text: $username)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
// Password field with visibility toggle
ZStack(alignment: .trailing) {
Group {
if showPassword {
TextField("Password", text: $password)
.textContentType(.password)
} else {
SecureField("Password", text: $password)
.textContentType(.password)
}
}
.textFieldStyle(.roundedBorder)
.onSubmit { login() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
.font(.subheadline)
}
.padding(.trailing, 8)
}
}
.padding(.top, 8)
Spacer().frame(height: 8)
// Error message
if let error = error {
HStack {
Image(systemName: "exclamationmark.circle.fill")
@ -78,7 +68,192 @@ struct LoginScreen: View {
.cornerRadius(8)
}
Button(action: login) {
// Main form
if usePasswordLogin {
passwordLoginView
} else {
switch step {
case .phone: phoneEntryView
case .otp: otpEntryView
}
}
Spacer().frame(height: 16)
// Toggle login mode
Button {
withAnimation {
usePasswordLogin.toggle()
error = nil
// Reset states
step = .phone
otpCode = ""
password = ""
}
} label: {
Text(usePasswordLogin ? "Use phone number instead" : "Use email & password instead")
.font(.subheadline)
.foregroundColor(.payfritGreen)
}
Spacer()
}
.padding(.horizontal, 24)
.frame(minHeight: geo.size.height)
}
.scrollDismissesKeyboard(.interactively)
}
.background(Color(.systemGroupedBackground))
}
// MARK: - Phone Entry View
private var phoneEntryView: some View {
VStack(spacing: 16) {
Text("Phone Number")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 0) {
Text("+1")
.font(.system(size: 17, weight: .medium))
.padding(.leading, 16)
Divider()
.frame(height: 20)
.padding(.horizontal, 12)
TextField("(555) 555-1234", text: $phoneNumber)
.font(.system(size: 17))
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.padding(.trailing, 16)
}
.frame(height: 52)
.background(Color(.systemBackground))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 1)
)
let canSend = phoneNumber.filter { $0.isNumber }.count >= 10 && !isLoading
Button(action: sendOtp) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Send Login Code")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(canSend ? .payfritGreen : .payfritGreen.opacity(0.5))
.disabled(!canSend)
}
}
// MARK: - OTP Entry View
private var otpEntryView: some View {
VStack(spacing: 16) {
VStack(spacing: 4) {
Text("We sent a code to")
.font(.subheadline)
.foregroundColor(.secondary)
Text(formattedPhone)
.font(.headline)
}
TextField("000000", text: $otpCode)
.font(.system(size: 28, weight: .semibold, design: .monospaced))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.tracking(8)
.frame(height: 56)
.background(Color(.systemBackground))
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.onChange(of: otpCode) { newValue in
otpCode = String(newValue.prefix(6))
}
let canVerify = otpCode.count == 6 && !isLoading
Button(action: verifyOtp) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Sign In")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(canVerify ? .payfritGreen : .payfritGreen.opacity(0.5))
.disabled(!canVerify)
HStack(spacing: 24) {
Button("Resend Code") {
sendOtp()
}
.foregroundColor(.payfritGreen)
Button("Change Number") {
step = .phone
otpCode = ""
error = nil
}
.foregroundColor(.secondary)
}
.font(.subheadline)
}
}
// MARK: - Password Login View
private var passwordLoginView: some View {
VStack(spacing: 12) {
TextField("Email or Phone", text: $username)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
ZStack(alignment: .trailing) {
Group {
if showPassword {
TextField("Password", text: $password)
.textContentType(.password)
} else {
SecureField("Password", text: $password)
.textContentType(.password)
}
}
.textFieldStyle(.roundedBorder)
.onSubmit { loginWithPassword() }
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.foregroundColor(.secondary)
.font(.subheadline)
}
.padding(.trailing, 8)
}
Button(action: loginWithPassword) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
@ -93,14 +268,71 @@ struct LoginScreen: View {
.tint(.payfritGreen)
.disabled(isLoading)
}
.padding(.horizontal, 24)
.frame(minHeight: geo.size.height)
}
}
.background(Color(.systemGroupedBackground))
}
private func login() {
// MARK: - Helpers
private var formattedPhone: String {
let digits = phoneNumber.filter { $0.isNumber }
guard digits.count >= 10 else { return "+1 \(phoneNumber)" }
let area = String(digits.prefix(3))
let mid = String(digits.dropFirst(3).prefix(3))
let last = String(digits.dropFirst(6).prefix(4))
return "+1 (\(area)) \(mid)-\(last)"
}
// MARK: - API Calls
private func sendOtp() {
let cleanPhone = phoneNumber.filter { $0.isNumber }
guard cleanPhone.count >= 10 else {
error = "Please enter a valid phone number"
return
}
isLoading = true
error = nil
Task {
do {
let response = try await APIService.shared.sendLoginOtp(phone: "+1\(cleanPhone)")
otpUuid = response.uuid
step = .otp
} catch {
self.error = "Failed to send code. Please try again."
}
isLoading = false
}
}
private func verifyOtp() {
isLoading = true
error = nil
Task {
do {
let response = try await APIService.shared.verifyLoginOtp(uuid: otpUuid, otp: otpCode)
let resolvedPhoto = APIService.resolvePhotoUrl(response.photoUrl)
await AuthStorage.shared.saveAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
appState.setAuth(
userId: response.userId,
token: response.token,
userName: response.userFirstName,
photoUrl: resolvedPhoto
)
} catch {
self.error = "Invalid code. Please try again."
}
isLoading = false
}
}
private func loginWithPassword() {
let user = username.trimmingCharacters(in: .whitespaces)
let pass = password
guard !user.isEmpty, !pass.isEmpty else {
@ -129,8 +361,13 @@ struct LoginScreen: View {
)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}
#Preview {
LoginScreen()
.environmentObject(AppState())
}

View file

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

View file

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