Compare commits

..

5 commits

Author SHA1 Message Date
873cbba2aa fix: use backend-authoritative change amount for cash transactions
Instead of calculating change client-side (which doesn't account for
balance applied, fees, etc.), the app now uses the Change value returned
by the /tasks/complete.php endpoint after processing.

Changes:
- APIService.completeTask now returns CashCompletionResult with backend values
- Added CashCompletionResult struct (cashReceived, orderTotal, change, fees, routing)
- CashCollectionSheet shows confirmed backend change after completion
- Added ratingRequired error case to APIError enum
- Client-side estimate still shown as preview before confirmation
2026-03-24 00:41:10 +00:00
551637f0ec Merge pull request 'feat: Customer rating dialog on task completion' (#2) from schwifty/customer-rating into main 2026-03-24 00:39:44 +00:00
bd98471f4c Merge pull request 'fix: remove 2,562 build-sim/ artifacts from repo' (#1) from schwifty/remove-build-artifacts into main 2026-03-24 00:39:23 +00:00
ece36cb484 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
2026-03-22 12:30:18 +00:00
debe0686b7 fix: remove build-sim/ artifacts and add to .gitignore
2,562 Xcode simulator build files were committed by mistake.
Actual source is only ~35 files in PayfritWorks/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:01:14 +00:00
2567 changed files with 228 additions and 7421 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Xcode
build/
build-sim/
DerivedData/
*.xcuserstate
*.xcworkspacedata

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

@ -28,7 +28,7 @@ enum APIError: LocalizedError {
case .serverError(let msg): return msg
case .unauthorized: return "Unauthorized"
case .networkError(let msg): return msg
case .ratingRequired: return "Customer rating is required before completing this task"
case .ratingRequired: return "Rating required"
}
}
}
@ -492,11 +492,14 @@ actor APIService {
/// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided.
@discardableResult
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws -> CashCompletionResult? {
func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws -> CashCompletionResult? {
var payload: [String: Any] = [
"TaskID": taskId,
"UserID": userId ?? 0
]
if let rating = workerRating {
payload["workerRating"] = rating
}
if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents
}

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 {
@ -1021,6 +1070,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")
}

Some files were not shown because too many files have changed in this diff Show more