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