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:
parent
0639fe12c2
commit
ece36cb484
4 changed files with 234 additions and 2 deletions
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
163
PayfritWorks/Views/RatingDialog.swift
Normal file
163
PayfritWorks/Views/RatingDialog.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue