From ece36cb4843a940e654d4f8c8270d01f70baff1d Mon Sep 17 00:00:00 2001 From: Schwifty Date: Sun, 22 Mar 2026 12:30:18 +0000 Subject: [PATCH] feat: add customer rating dialog on task completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a worker completes a service point task and the API requires a rating, a dialog now appears with 4 yes/no questions matching Android: - Was the customer prepared? - Was the scope clear? - Was the customer respectful? - Would you serve them again? The rating is submitted with the task completion request via the workerRating payload. Also handles rating_required during beacon auto-complete by dismissing the countdown and showing the dialog. Files changed: - RatingDialog.swift (new) — rating dialog UI with toggle chips - APIService.swift — added workerRating param + ratingRequired error - TaskDetailScreen.swift — rating flow in completeTask + auto-complete - project.pbxproj — added RatingDialog.swift to Xcode project --- PayfritWorks.xcodeproj/project.pbxproj | 4 + PayfritWorks/Services/APIService.swift | 14 +- PayfritWorks/Views/RatingDialog.swift | 163 ++++++++++++++++++++++ PayfritWorks/Views/TaskDetailScreen.swift | 55 ++++++++ 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 PayfritWorks/Views/RatingDialog.swift diff --git a/PayfritWorks.xcodeproj/project.pbxproj b/PayfritWorks.xcodeproj/project.pbxproj index 915f056..1bd6571 100644 --- a/PayfritWorks.xcodeproj/project.pbxproj +++ b/PayfritWorks.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 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; }; + B0100000004A /* RatingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0200000004A; }; /* Resources */ B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; }; @@ -90,6 +91,7 @@ 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 = ""; }; + B0200000004A /* RatingDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingDialog.swift; sourceTree = ""; }; /* Resources */ B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -186,6 +188,7 @@ B02000000047 /* AccountScreen.swift */, B02000000048 /* AboutScreen.swift */, B02000000049 /* ProfileScreen.swift */, + B0200000004A /* RatingDialog.swift */, ); path = Views; sourceTree = ""; @@ -312,6 +315,7 @@ B01000000047 /* AccountScreen.swift in Sources */, B01000000048 /* AboutScreen.swift in Sources */, B01000000049 /* ProfileScreen.swift in Sources */, + B0100000004A /* RatingDialog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PayfritWorks/Services/APIService.swift b/PayfritWorks/Services/APIService.swift index fd15218..09c4d28 100644 --- a/PayfritWorks/Services/APIService.swift +++ b/PayfritWorks/Services/APIService.swift @@ -18,6 +18,7 @@ enum APIError: LocalizedError { case serverError(String) case unauthorized case networkError(String) + case ratingRequired(customerUserId: Int) var errorDescription: String? { switch self { @@ -27,6 +28,7 @@ enum APIError: LocalizedError { case .serverError(let msg): return msg case .unauthorized: return "Unauthorized" case .networkError(let msg): return msg + case .ratingRequired: return "Rating required" } } } @@ -472,11 +474,14 @@ actor APIService { return arr.map { WorkTask(json: $0) } } - func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws { + func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws { var payload: [String: Any] = [ "TaskID": taskId, "UserID": userId ?? 0 ] + if let rating = workerRating { + payload["workerRating"] = rating + } if let cents = cashReceivedCents { payload["CashReceivedCents"] = cents } @@ -485,7 +490,12 @@ actor APIService { } let json = try await postJSON("/tasks/complete.php", payload: payload) guard ok(json) else { - throw APIError.serverError("Failed to complete task: \(err(json))") + let errorMsg = err(json) + if errorMsg == "rating_required" { + let customerUserId = json["CustomerUserID"] as? Int ?? 0 + throw APIError.ratingRequired(customerUserId: customerUserId) + } + throw APIError.serverError("Failed to complete task: \(errorMsg)") } } diff --git a/PayfritWorks/Views/RatingDialog.swift b/PayfritWorks/Views/RatingDialog.swift new file mode 100644 index 0000000..3159780 --- /dev/null +++ b/PayfritWorks/Views/RatingDialog.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// Customer rating dialog shown when completing a service point task. +/// Matches Android RatingDialog: 4 yes/no questions about the customer. +struct RatingDialog: View { + let onSubmit: ([String: Bool]) -> Void + let onDismiss: () -> Void + let isSubmitting: Bool + + @State private var prepared: Bool? + @State private var completedScope: Bool? + @State private var respectful: Bool? + @State private var wouldAutoAssign: Bool? + + private var allAnswered: Bool { + prepared != nil && completedScope != nil && respectful != nil && wouldAutoAssign != nil + } + + var body: some View { + VStack(spacing: 20) { + // Star icon + Image(systemName: "star.fill") + .font(.system(size: 48)) + .foregroundColor(Color(red: 1.0, green: 0.76, blue: 0.03)) // Golden yellow + + Text("Rate this customer") + .font(.title2.bold()) + + Text("Quick feedback helps improve the experience.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 16) { + RatingQuestion(question: "Was the customer prepared?", value: $prepared) + RatingQuestion(question: "Was the scope clear?", value: $completedScope) + RatingQuestion(question: "Was the customer respectful?", value: $respectful) + RatingQuestion(question: "Would you serve them again?", value: $wouldAutoAssign) + } + + HStack(spacing: 12) { + if !isSubmitting { + Button("Cancel") { + onDismiss() + } + .buttonStyle(.bordered) + .foregroundColor(.secondary) + } + + Button { + guard let p = prepared, let c = completedScope, + let r = respectful, let w = wouldAutoAssign else { return } + onSubmit([ + "prepared": p, + "completedScope": c, + "respectful": r, + "wouldAutoAssign": w + ]) + } label: { + if isSubmitting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity, minHeight: 44) + } else { + Text("Submit & Complete") + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 44) + } + } + .buttonStyle(.borderedProminent) + .tint(.payfritGreen) + .disabled(!allAnswered || isSubmitting) + } + } + .padding(24) + .background(Color(.systemBackground)) + .cornerRadius(20) + .shadow(radius: 20) + .padding(.horizontal, 24) + } +} + +/// A single yes/no rating question with thumb up/down toggle chips. +private struct RatingQuestion: View { + let question: String + @Binding var value: Bool? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(question) + .font(.subheadline.weight(.medium)) + + HStack(spacing: 8) { + ToggleChip( + label: "Yes", + icon: "hand.thumbsup.fill", + isSelected: value == true, + selectedColor: .green + ) { + value = true + } + + ToggleChip( + label: "No", + icon: "hand.thumbsdown.fill", + isSelected: value == false, + selectedColor: .red + ) { + value = false + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// A selectable chip with icon, similar to Android FilterChip. +private struct ToggleChip: View { + let label: String + let icon: String + let isSelected: Bool + let selectedColor: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + if isSelected { + Image(systemName: icon) + .font(.caption) + .foregroundColor(selectedColor) + } + Text(label) + .font(.subheadline) + .foregroundColor(isSelected ? selectedColor : .primary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + isSelected + ? selectedColor.opacity(0.15) + : Color(.systemGray5) + ) + .cornerRadius(20) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(isSelected ? selectedColor.opacity(0.4) : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + RatingDialog( + onSubmit: { rating in print(rating) }, + onDismiss: { print("dismissed") }, + isSubmitting: false + ) + } +} diff --git a/PayfritWorks/Views/TaskDetailScreen.swift b/PayfritWorks/Views/TaskDetailScreen.swift index ed64745..1f57ae6 100644 --- a/PayfritWorks/Views/TaskDetailScreen.swift +++ b/PayfritWorks/Views/TaskDetailScreen.swift @@ -30,6 +30,10 @@ struct TaskDetailScreen: View { @State private var taskAccepted = false // Track if task was just accepted @State private var customerAvatarUrl: String? // Fetched separately if not in task details + // Rating dialog + @State private var showRatingDialog = false + @State private var isSubmittingRating = false + // Computed properties for effective button visibility after accepting private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted } private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted } @@ -89,6 +93,10 @@ struct TaskDetailScreen: View { showAutoCompleteDialog = false if result == "success" { appState.popToRoot() + } else if result == "rating_required" { + autoCompleting = false + beaconDetected = false + showRatingDialog = true } else if result == "cancelled" || result == "error" { autoCompleting = false beaconDetected = false @@ -115,6 +123,25 @@ struct TaskDetailScreen: View { onComplete: { appState.popToRoot() } ) } + .overlay { + if showRatingDialog { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + if !isSubmittingRating { showRatingDialog = false } + } + + RatingDialog( + onSubmit: { rating in submitRating(rating) }, + onDismiss: { showRatingDialog = false }, + isSubmitting: isSubmittingRating + ) + } + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: showRatingDialog) + } + } .task { await loadDetails() } .onDisappear { beaconScanner?.dispose() } .onChange(of: appState.shouldPopToTaskList) { shouldPop in @@ -737,12 +764,34 @@ struct TaskDetailScreen: View { do { try await APIService.shared.completeTask(taskId: task.taskId) appState.popToRoot() + } catch let apiError as APIError { + if case .ratingRequired = apiError { + showRatingDialog = true + } else { + self.error = apiError.localizedDescription + } } catch { self.error = error.localizedDescription } } } + private func submitRating(_ rating: [String: Bool]) { + isSubmittingRating = true + Task { + do { + try await APIService.shared.completeTask(taskId: task.taskId, workerRating: rating) + showRatingDialog = false + isSubmittingRating = false + appState.popToRoot() + } catch { + isSubmittingRating = false + showRatingDialog = false + self.error = error.localizedDescription + } + } + } + private func cancelOrder() { isCancelingOrder = true Task { @@ -985,6 +1034,12 @@ struct AutoCompleteCountdownView: View { message = "Closing this window now" try? await Task.sleep(nanoseconds: 1_000_000_000) onResult("success") + } catch let apiError as APIError { + if case .ratingRequired = apiError { + onResult("rating_required") + } else { + onResult("error") + } } catch { onResult("error") } -- 2.43.0