feat: Customer rating dialog on task completion #2

Merged
schwifty merged 1 commit from schwifty/customer-rating into main 2026-03-24 00:39:45 +00:00
4 changed files with 234 additions and 2 deletions

View file

@ -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 = "<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>"; };
B0200000004A /* RatingDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingDialog.swift; sourceTree = "<group>"; };
/* Resources */
B02000000060 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -186,6 +188,7 @@
B02000000047 /* AccountScreen.swift */,
B02000000048 /* AboutScreen.swift */,
B02000000049 /* ProfileScreen.swift */,
B0200000004A /* RatingDialog.swift */,
);
path = Views;
sourceTree = "<group>";
@ -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;
};

View file

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

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