Compare commits

..

9 commits

Author SHA1 Message Date
5de67323c7 Merge pull request 'Merge parity-phase2: refresh intervals + profile editing + reusable components' (#4) from schwifty/parity-phase2 into main 2026-03-24 23:28:37 +00:00
543e19a664 fix: standardize refresh intervals to 3 seconds across all screens
Per parity decision #4 — both platforms conform to 3-second refresh intervals.
Previously iOS Works was at 2s, Android was at 5s. Now both at 3s.

Screens updated:
- MyTasksScreen: 2s → 3s
- BusinessSelectionScreen: 2s → 3s
- TaskListScreen: 2s → 3s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:21:39 +00:00
b8d648cdfc feat: add profile editing UI with first/last name fields
ProfileScreen now loads profile data from the API and shows an Edit
button. Tapping Edit reveals editable first/last name fields with
Save and Cancel buttons. On save, calls updateProfile API and refreshes
the displayed data including the appState display name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:15:58 +00:00
1c427a0902 feat: add reusable ErrorView and LoadingView components
Extract duplicated inline error/loading patterns from TaskListScreen,
MyTasksScreen, BusinessSelectionScreen, AccountScreen, and TaskDetailScreen
into shared components under Views/Components/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:15:03 +00:00
88a3a9d6e0 Merge pull request 'fix: Backend-authoritative cash change calculation' (#3) from schwifty/cash-change-backend-authoritative into main 2026-03-24 00:41:24 +00:00
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
11 changed files with 591 additions and 120 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"
} }
} }
} }
@ -54,6 +56,22 @@ struct ChatMessagesResult {
let chatClosed: Bool 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 // MARK: - API Service
actor APIService { actor APIService {
@ -472,11 +490,16 @@ 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 { /// 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] = [ 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,8 +508,27 @@ 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)")
} }
// 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 { func closeChat(taskId: Int) async throws {

View file

@ -90,10 +90,10 @@ struct AccountScreen: View {
// Payout content // Payout content
if isLoading { if isLoading {
ProgressView() LoadingView()
.padding(.top, 20) .padding(.top, 20)
} else if let error = error { } else if let error = error {
errorView(error) ErrorView(message: error) { Task { await loadData() } }
} else { } else {
VStack(spacing: 16) { VStack(spacing: 16) {
if let tier = tierStatus { if let tier = tierStatus {
@ -386,21 +386,6 @@ struct AccountScreen: View {
.padding(.top, 8) .padding(.top, 8)
} }
// MARK: - Error
private func errorView(_ msg: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(msg)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadData() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions // MARK: - Actions
private func loadAvatar() async { private func loadAvatar() async {

View file

@ -13,16 +13,16 @@ struct BusinessSelectionScreen: View {
@State private var selectedBusiness: Employment? @State private var selectedBusiness: Employment?
@State private var debugText = "" @State private var debugText = ""
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Group { Group {
if isLoading { if isLoading {
ProgressView() LoadingView()
} else if let error = error { } else if let error = error {
errorView(error) ErrorView(message: error) { loadBusinesses() }
} else if businesses.isEmpty { } else if businesses.isEmpty {
emptyView emptyView
} else { } else {
@ -246,19 +246,6 @@ struct BusinessSelectionScreen: View {
.padding() .padding()
} }
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { loadBusinesses() }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions // MARK: - Actions
private func loadBusinesses(silent: Bool = false) { private func loadBusinesses(silent: Bool = false) {

View file

@ -0,0 +1,34 @@
import SwiftUI
/// Reusable error state view with icon, message, and optional retry button.
/// Replaces inline error patterns across all screens.
struct ErrorView: View {
let message: String
var onRetry: (() -> Void)?
var body: some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
if let onRetry = onRetry {
Button("Retry", action: onRetry)
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
#Preview("With Retry") {
ErrorView(message: "Something went wrong. Please try again.") {
print("Retry tapped")
}
}
#Preview("No Retry") {
ErrorView(message: "Could not load data.")
}

View file

@ -0,0 +1,27 @@
import SwiftUI
/// Reusable loading state view with a spinner and optional message.
/// Replaces inline ProgressView() patterns across all screens.
struct LoadingView: View {
var message: String?
var body: some View {
VStack(spacing: 12) {
ProgressView()
if let message = message {
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview("With Message") {
LoadingView(message: "Loading tasks...")
}
#Preview("No Message") {
LoadingView()
}

View file

@ -20,7 +20,7 @@ struct MyTasksScreen: View {
FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"), FilterTab(value: "completed", label: "Done", icon: "checkmark.circle.fill"),
] ]
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -94,18 +94,9 @@ struct MyTasksScreen: View {
let tasks = tasksByFilter[filterType] ?? [] let tasks = tasksByFilter[filterType] ?? []
if isLoading { if isLoading {
ProgressView() LoadingView()
} else if let error = error { } else if let error = error {
VStack(spacing: 16) { ErrorView(message: error) { loadTasks(filterType) }
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(error)
.multilineTextAlignment(.center)
Button("Retry") { loadTasks(filterType) }
.buttonStyle(.borderedProminent)
}
.padding()
} else if tasks.isEmpty { } else if tasks.isEmpty {
emptyView(filterType) emptyView(filterType)
} else { } else {

View file

@ -6,8 +6,23 @@ struct ProfileScreen: View {
@State private var appearAnimation = false @State private var appearAnimation = false
@State private var avatarURLLoaded: URL? @State private var avatarURLLoaded: URL?
// Profile data from API
@State private var profile: APIService.UserProfile?
@State private var isLoadingProfile = true
@State private var profileError: String?
// Edit mode
@State private var isEditing = false
@State private var editFirstName = ""
@State private var editLastName = ""
@State private var isSaving = false
@State private var saveError: String?
private var displayName: String { private var displayName: String {
appState.userName ?? "Worker" if let p = profile {
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
}
return appState.userName ?? "Worker"
} }
private var avatarURL: URL? { private var avatarURL: URL? {
@ -57,34 +72,20 @@ struct ProfileScreen: View {
.scaleEffect(appearAnimation ? 1 : 0.9) .scaleEffect(appearAnimation ? 1 : 0.9)
.opacity(appearAnimation ? 1 : 0) .opacity(appearAnimation ? 1 : 0)
// Info section // Content
VStack(alignment: .leading, spacing: 12) { if isLoadingProfile {
Text("Account Information") LoadingView(message: "Loading profile...")
.font(.subheadline) .frame(height: 120)
.fontWeight(.semibold) } else if let error = profileError {
.foregroundColor(.payfritGreen) ErrorView(message: error) { loadProfile() }
.padding(.horizontal, 16) } else if isEditing {
editSection
VStack(spacing: 0) { .transition(.opacity.combined(with: .move(edge: .bottom)))
infoRow(icon: "person.fill", label: "Name", value: displayName) } else {
Divider().padding(.leading, 54) infoSection
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName) .opacity(appearAnimation ? 1 : 0)
} .offset(y: appearAnimation ? 0 : 15)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, 16)
} }
.opacity(appearAnimation ? 1 : 0)
.offset(y: appearAnimation ? 0 : 15)
// Note
Text("Profile information is managed through your employer. Contact your manager if you need to update your details.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.top, 8)
.opacity(appearAnimation ? 1 : 0)
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
@ -93,16 +94,17 @@ struct ProfileScreen: View {
.background(Color(.systemGroupedBackground).ignoresSafeArea()) .background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("Profile") .navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .toolbar {
do { if !isEditing && !isLoadingProfile && profileError == nil {
if let urlString = try await APIService.shared.getAvatarUrl(), ToolbarItem(placement: .navigationBarTrailing) {
let url = URL(string: urlString) { Button("Edit") { startEditing() }
avatarURLLoaded = url .foregroundColor(.payfritGreen)
} }
} catch {
// Avatar load failed
} }
} }
.task {
await loadAvatarAndProfile()
}
.onAppear { .onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
appearAnimation = true appearAnimation = true
@ -110,6 +112,89 @@ struct ProfileScreen: View {
} }
} }
// MARK: - Info Section (Read-Only)
private var infoSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Account Information")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.payfritGreen)
.padding(.horizontal, 16)
VStack(spacing: 0) {
if let p = profile {
infoRow(icon: "person.fill", label: "First Name", value: p.firstName.isEmpty ? "-" : p.firstName)
Divider().padding(.leading, 54)
infoRow(icon: "person.fill", label: "Last Name", value: p.lastName.isEmpty ? "-" : p.lastName)
Divider().padding(.leading, 54)
}
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, 16)
}
}
// MARK: - Edit Section
private var editSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Edit Profile")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.payfritGreen)
.padding(.horizontal, 16)
VStack(spacing: 0) {
editRow(icon: "person.fill", label: "First Name", text: $editFirstName)
Divider().padding(.leading, 54)
editRow(icon: "person.fill", label: "Last Name", text: $editLastName)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, 16)
if let error = saveError {
Text(error)
.font(.caption)
.foregroundColor(.red)
.padding(.horizontal, 16)
}
HStack(spacing: 12) {
Button {
withAnimation { isEditing = false }
saveError = nil
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(isSaving)
Button { saveProfile() } label: {
if isSaving {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 20)
} else {
Text("Save")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
.disabled(isSaving)
}
.padding(.horizontal, 16)
.padding(.top, 4)
}
}
// MARK: - Subviews
private var placeholderAvatar: some View { private var placeholderAvatar: some View {
Image(systemName: "person.circle.fill") Image(systemName: "person.circle.fill")
.resizable() .resizable()
@ -143,6 +228,96 @@ struct ProfileScreen: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
} }
private func editRow(icon: String, label: String, text: Binding<String>) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.subheadline)
.foregroundColor(.payfritGreen)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
TextField(label, text: text)
.font(.subheadline)
.textContentType(label == "First Name" ? .givenName : .familyName)
.autocorrectionDisabled()
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Actions
private func loadAvatarAndProfile() async {
// Load avatar
do {
if let urlString = try await APIService.shared.getAvatarUrl(),
let url = URL(string: urlString) {
avatarURLLoaded = url
}
} catch {
// Avatar load failed, continue
}
// Load profile
loadProfile()
}
private func loadProfile() {
isLoadingProfile = true
profileError = nil
Task {
do {
let p = try await APIService.shared.getProfile()
profile = p
isLoadingProfile = false
} catch {
profileError = error.localizedDescription
isLoadingProfile = false
}
}
}
private func startEditing() {
editFirstName = profile?.firstName ?? ""
editLastName = profile?.lastName ?? ""
saveError = nil
withAnimation { isEditing = true }
}
private func saveProfile() {
isSaving = true
saveError = nil
Task {
do {
try await APIService.shared.updateProfile(
firstName: editFirstName,
lastName: editLastName
)
// Refresh profile data
let p = try await APIService.shared.getProfile()
profile = p
// Update appState display name
let newName = [p.firstName, p.lastName]
.filter { !$0.isEmpty }
.joined(separator: " ")
appState.userName = newName.isEmpty ? nil : newName
isSaving = false
withAnimation { isEditing = false }
} catch {
saveError = error.localizedDescription
isSaving = false
}
}
}
} }
#Preview { #Preview {

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 }
@ -38,9 +42,9 @@ struct TaskDetailScreen: View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Group { Group {
if isLoading { if isLoading {
ProgressView() LoadingView()
} else if let error = error { } else if let error = error {
errorView(error) ErrorView(message: error) { Task { await loadDetails() } }
} else if let details = details { } else if let details = details {
contentView(details) contentView(details)
} }
@ -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
@ -636,21 +663,6 @@ struct TaskDetailScreen: View {
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
// MARK: - Error
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { Task { await loadDetails() } }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions // MARK: - Actions
private func loadDetails() async { private func loadDetails() async {
@ -737,12 +749,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 {
@ -785,6 +819,7 @@ struct CashCollectionSheet: View {
@State private var cashReceivedText = "" @State private var cashReceivedText = ""
@State private var isProcessing = false @State private var isProcessing = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var completionResult: CashCompletionResult?
private var isAdmin: Bool { roleId >= 2 } private var isAdmin: Bool { roleId >= 2 }
@ -795,7 +830,8 @@ struct CashCollectionSheet: View {
return Int(round(dollars * 100)) 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 } guard let receivedCents = cashReceivedCents else { return nil }
let change = Double(receivedCents - orderTotalCents) / 100 let change = Double(receivedCents - orderTotalCents) / 100
return change >= 0 ? change : nil return change >= 0 ? change : nil
@ -839,8 +875,35 @@ struct CashCollectionSheet: View {
.cornerRadius(12) .cornerRadius(12)
} }
// Change display // Change display backend-authoritative after completion, estimate before
if let change = changeDue { 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 { HStack {
Image(systemName: "arrow.uturn.left.circle.fill") Image(systemName: "arrow.uturn.left.circle.fill")
.foregroundColor(.payfritGreen) .foregroundColor(.payfritGreen)
@ -905,7 +968,7 @@ struct CashCollectionSheet: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13)) .tint(Color(red: 0.13, green: 0.55, blue: 0.13))
.disabled(!isValid || isProcessing) .disabled(!isValid || isProcessing || completionResult != nil)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
@ -927,7 +990,14 @@ struct CashCollectionSheet: View {
Task { Task {
do { 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() dismiss()
onComplete() onComplete()
} catch { } catch {
@ -985,6 +1055,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")
} }

View file

@ -11,15 +11,15 @@ struct TaskListScreen: View {
@State private var selectedTask: WorkTask? @State private var selectedTask: WorkTask?
@State private var showingMyTasks = false @State private var showingMyTasks = false
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
Group { Group {
if isLoading { if isLoading {
ProgressView() LoadingView()
} else if let error = error { } else if let error = error {
errorView(error) ErrorView(message: error) { loadTasks() }
} else if tasks.isEmpty { } else if tasks.isEmpty {
emptyView emptyView
} else { } else {
@ -195,19 +195,6 @@ struct TaskListScreen: View {
} }
} }
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 48))
.foregroundColor(.red)
Text(message)
.multilineTextAlignment(.center)
Button("Retry") { loadTasks() }
.buttonStyle(.borderedProminent)
}
.padding()
}
// MARK: - Actions // MARK: - Actions
private func loadTasks(silent: Bool = false) { private func loadTasks(silent: Bool = false) {