feat: add customer rating dialog on task completion

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
This commit is contained in:
Schwifty 2026-03-22 12:30:18 +00:00
parent 0639fe12c2
commit ece36cb484
4 changed files with 234 additions and 2 deletions

View file

@ -43,6 +43,7 @@
B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; }; B01000000047 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000047; };
B01000000048 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000048; }; B01000000048 /* AboutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000048; };
B01000000049 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000049; }; B01000000049 /* ProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02000000049; };
B0100000004A /* RatingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0200000004A; };
/* Resources */ /* Resources */
B01000000060 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B02000000060; }; 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 = "<group>"; }; B02000000047 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; };
B02000000048 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = "<group>"; }; B02000000048 /* AboutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutScreen.swift; sourceTree = "<group>"; };
B02000000049 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = "<group>"; }; B02000000049 /* ProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileScreen.swift; sourceTree = "<group>"; };
B0200000004A /* RatingDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingDialog.swift; sourceTree = "<group>"; };
/* Resources */ /* Resources */
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -186,6 +188,7 @@
B02000000047 /* AccountScreen.swift */, B02000000047 /* AccountScreen.swift */,
B02000000048 /* AboutScreen.swift */, B02000000048 /* AboutScreen.swift */,
B02000000049 /* ProfileScreen.swift */, B02000000049 /* ProfileScreen.swift */,
B0200000004A /* RatingDialog.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -312,6 +315,7 @@
B01000000047 /* AccountScreen.swift in Sources */, B01000000047 /* AccountScreen.swift in Sources */,
B01000000048 /* AboutScreen.swift in Sources */, B01000000048 /* AboutScreen.swift in Sources */,
B01000000049 /* ProfileScreen.swift in Sources */, B01000000049 /* ProfileScreen.swift in Sources */,
B0100000004A /* RatingDialog.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -18,6 +18,7 @@ enum APIError: LocalizedError {
case serverError(String) case serverError(String)
case unauthorized case unauthorized
case networkError(String) case networkError(String)
case ratingRequired(customerUserId: Int)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -27,6 +28,7 @@ enum APIError: LocalizedError {
case .serverError(let msg): return msg case .serverError(let msg): return msg
case .unauthorized: return "Unauthorized" case .unauthorized: return "Unauthorized"
case .networkError(let msg): return msg case .networkError(let msg): return msg
case .ratingRequired: return "Rating required"
} }
} }
} }
@ -472,11 +474,14 @@ actor APIService {
return arr.map { WorkTask(json: $0) } 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] = [ var payload: [String: Any] = [
"TaskID": taskId, "TaskID": taskId,
"UserID": userId ?? 0 "UserID": userId ?? 0
] ]
if let rating = workerRating {
payload["workerRating"] = rating
}
if let cents = cashReceivedCents { if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents payload["CashReceivedCents"] = cents
} }
@ -485,7 +490,12 @@ actor APIService {
} }
let json = try await postJSON("/tasks/complete.php", payload: payload) let json = try await postJSON("/tasks/complete.php", payload: payload)
guard ok(json) else { 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)")
} }
} }

View file

@ -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
)
}
}

View file

@ -30,6 +30,10 @@ struct TaskDetailScreen: View {
@State private var taskAccepted = false // Track if task was just accepted @State private var taskAccepted = false // Track if task was just accepted
@State private var customerAvatarUrl: String? // Fetched separately if not in task details @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 // Computed properties for effective button visibility after accepting
private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted } private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted }
private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted } private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted }
@ -89,6 +93,10 @@ struct TaskDetailScreen: View {
showAutoCompleteDialog = false showAutoCompleteDialog = false
if result == "success" { if result == "success" {
appState.popToRoot() appState.popToRoot()
} else if result == "rating_required" {
autoCompleting = false
beaconDetected = false
showRatingDialog = true
} else if result == "cancelled" || result == "error" { } else if result == "cancelled" || result == "error" {
autoCompleting = false autoCompleting = false
beaconDetected = false beaconDetected = false
@ -115,6 +123,25 @@ struct TaskDetailScreen: View {
onComplete: { appState.popToRoot() } 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() } .task { await loadDetails() }
.onDisappear { beaconScanner?.dispose() } .onDisappear { beaconScanner?.dispose() }
.onChange(of: appState.shouldPopToTaskList) { shouldPop in .onChange(of: appState.shouldPopToTaskList) { shouldPop in
@ -737,12 +764,34 @@ struct TaskDetailScreen: View {
do { do {
try await APIService.shared.completeTask(taskId: task.taskId) try await APIService.shared.completeTask(taskId: task.taskId)
appState.popToRoot() appState.popToRoot()
} catch let apiError as APIError {
if case .ratingRequired = apiError {
showRatingDialog = true
} else {
self.error = apiError.localizedDescription
}
} catch { } catch {
self.error = error.localizedDescription 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() { private func cancelOrder() {
isCancelingOrder = true isCancelingOrder = true
Task { Task {
@ -985,6 +1034,12 @@ struct AutoCompleteCountdownView: View {
message = "Closing this window now" message = "Closing this window now"
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
onResult("success") onResult("success")
} catch let apiError as APIError {
if case .ratingRequired = apiError {
onResult("rating_required")
} else {
onResult("error")
}
} catch { } catch {
onResult("error") onResult("error")
} }