Compare commits
5 commits
46e008b20b
...
873cbba2aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 873cbba2aa | |||
| 551637f0ec | |||
| bd98471f4c | |||
| ece36cb484 | |||
| debe0686b7 |
2567 changed files with 308 additions and 7426 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# Xcode
|
||||
build/
|
||||
build-sim/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +56,22 @@ struct ChatMessagesResult {
|
|||
let chatClosed: Bool
|
||||
}
|
||||
|
||||
/// Backend-authoritative cash completion result.
|
||||
/// All monetary values are formatted strings (e.g. "12.50") from the server.
|
||||
struct CashCompletionResult {
|
||||
let cashReceived: String
|
||||
let orderTotal: String
|
||||
let change: String
|
||||
let customerFee: String?
|
||||
let businessFee: String?
|
||||
let cashRoutedTo: String // "worker" or "business"
|
||||
let balanceApplied: String?
|
||||
|
||||
var changeDollars: Double {
|
||||
Double(change) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Service
|
||||
|
||||
actor APIService {
|
||||
|
|
@ -472,11 +490,16 @@ actor APIService {
|
|||
return arr.map { WorkTask(json: $0) }
|
||||
}
|
||||
|
||||
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws {
|
||||
/// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided.
|
||||
@discardableResult
|
||||
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
|
||||
}
|
||||
|
|
@ -485,8 +508,27 @@ 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)")
|
||||
}
|
||||
|
||||
// Parse cash completion response from backend (authoritative values)
|
||||
if let _ = cashReceivedCents, json["CashProcessed"] as? Bool == true {
|
||||
return CashCompletionResult(
|
||||
cashReceived: json["CashReceived"] as? String ?? "0.00",
|
||||
orderTotal: json["OrderTotal"] as? String ?? "0.00",
|
||||
change: json["Change"] as? String ?? "0.00",
|
||||
customerFee: json["CustomerFee"] as? String,
|
||||
businessFee: json["BusinessFee"] as? String,
|
||||
cashRoutedTo: json["CashRoutedTo"] as? String ?? "worker",
|
||||
balanceApplied: json["BalanceApplied"] as? String
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeChat(taskId: Int) async throws {
|
||||
|
|
|
|||
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 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 {
|
||||
|
|
@ -785,6 +834,7 @@ struct CashCollectionSheet: View {
|
|||
@State private var cashReceivedText = ""
|
||||
@State private var isProcessing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var completionResult: CashCompletionResult?
|
||||
|
||||
private var isAdmin: Bool { roleId >= 2 }
|
||||
|
||||
|
|
@ -795,7 +845,8 @@ struct CashCollectionSheet: View {
|
|||
return Int(round(dollars * 100))
|
||||
}
|
||||
|
||||
private var changeDue: Double? {
|
||||
/// Client-side estimate shown before confirmation (preview only)
|
||||
private var estimatedChangeDue: Double? {
|
||||
guard let receivedCents = cashReceivedCents else { return nil }
|
||||
let change = Double(receivedCents - orderTotalCents) / 100
|
||||
return change >= 0 ? change : nil
|
||||
|
|
@ -839,8 +890,35 @@ struct CashCollectionSheet: View {
|
|||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
// Change display
|
||||
if let change = changeDue {
|
||||
// Change display — backend-authoritative after completion, estimate before
|
||||
if let result = completionResult {
|
||||
// Show confirmed change from backend
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
Text("Change Due:")
|
||||
.font(.body.weight(.medium))
|
||||
Spacer()
|
||||
Text("$\(result.change)")
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
if let balanceApplied = result.balanceApplied {
|
||||
HStack {
|
||||
Text("Balance applied:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text("$\(balanceApplied)")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.payfritGreen.opacity(0.15))
|
||||
.cornerRadius(12)
|
||||
} else if let change = estimatedChangeDue {
|
||||
HStack {
|
||||
Image(systemName: "arrow.uturn.left.circle.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
|
|
@ -905,7 +983,7 @@ struct CashCollectionSheet: View {
|
|||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||
.disabled(!isValid || isProcessing)
|
||||
.disabled(!isValid || isProcessing || completionResult != nil)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
|
@ -927,7 +1005,14 @@ struct CashCollectionSheet: View {
|
|||
|
||||
Task {
|
||||
do {
|
||||
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
|
||||
let result = try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
|
||||
if let result = result {
|
||||
// Show backend-authoritative change before dismissing
|
||||
completionResult = result
|
||||
isProcessing = false
|
||||
// Brief pause so worker can see the confirmed change amount
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
}
|
||||
dismiss()
|
||||
onComplete()
|
||||
} catch {
|
||||
|
|
@ -985,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")
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue