Compare commits
9 commits
schwifty/c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de67323c7 | |||
| 543e19a664 | |||
| b8d648cdfc | |||
| 1c427a0902 | |||
| 88a3a9d6e0 | |||
| 873cbba2aa | |||
| 551637f0ec | |||
| bd98471f4c | |||
| debe0686b7 |
2572 changed files with 359 additions and 7538 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# Xcode
|
||||
build/
|
||||
build-sim/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
|
|
|
|||
|
|
@ -56,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 {
|
||||
|
|
@ -474,7 +490,9 @@ actor APIService {
|
|||
return arr.map { WorkTask(json: $0) }
|
||||
}
|
||||
|
||||
func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, 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
|
||||
|
|
@ -497,6 +515,20 @@ actor APIService {
|
|||
}
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ struct AccountScreen: View {
|
|||
|
||||
// Payout content
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
.padding(.top, 20)
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { Task { await loadData() } }
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
if let tier = tierStatus {
|
||||
|
|
@ -386,21 +386,6 @@ struct AccountScreen: View {
|
|||
.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
|
||||
|
||||
private func loadAvatar() async {
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ struct BusinessSelectionScreen: View {
|
|||
@State private var selectedBusiness: Employment?
|
||||
@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 {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { loadBusinesses() }
|
||||
} else if businesses.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
|
|
@ -246,19 +246,6 @@ struct BusinessSelectionScreen: View {
|
|||
.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
|
||||
|
||||
private func loadBusinesses(silent: Bool = false) {
|
||||
|
|
|
|||
34
PayfritWorks/Views/Components/ErrorView.swift
Normal file
34
PayfritWorks/Views/Components/ErrorView.swift
Normal 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.")
|
||||
}
|
||||
27
PayfritWorks/Views/Components/LoadingView.swift
Normal file
27
PayfritWorks/Views/Components/LoadingView.swift
Normal 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()
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ struct MyTasksScreen: View {
|
|||
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 {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -94,18 +94,9 @@ struct MyTasksScreen: View {
|
|||
let tasks = tasksByFilter[filterType] ?? []
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.red)
|
||||
Text(error)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { loadTasks(filterType) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
ErrorView(message: error) { loadTasks(filterType) }
|
||||
} else if tasks.isEmpty {
|
||||
emptyView(filterType)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,23 @@ struct ProfileScreen: View {
|
|||
@State private var appearAnimation = false
|
||||
@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 {
|
||||
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? {
|
||||
|
|
@ -57,34 +72,20 @@ struct ProfileScreen: View {
|
|||
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||
.opacity(appearAnimation ? 1 : 0)
|
||||
|
||||
// Info section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Account Information")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.payfritGreen)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
infoRow(icon: "person.fill", label: "Name", value: displayName)
|
||||
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)
|
||||
// Content
|
||||
if isLoadingProfile {
|
||||
LoadingView(message: "Loading profile...")
|
||||
.frame(height: 120)
|
||||
} else if let error = profileError {
|
||||
ErrorView(message: error) { loadProfile() }
|
||||
} else if isEditing {
|
||||
editSection
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
} else {
|
||||
infoSection
|
||||
.opacity(appearAnimation ? 1 : 0)
|
||||
.offset(y: appearAnimation ? 0 : 15)
|
||||
}
|
||||
.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()
|
||||
.frame(height: 20)
|
||||
|
|
@ -93,16 +94,17 @@ struct ProfileScreen: View {
|
|||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
do {
|
||||
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||
let url = URL(string: urlString) {
|
||||
avatarURLLoaded = url
|
||||
.toolbar {
|
||||
if !isEditing && !isLoadingProfile && profileError == nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Edit") { startEditing() }
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
} catch {
|
||||
// Avatar load failed
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadAvatarAndProfile()
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
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 {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.resizable()
|
||||
|
|
@ -143,6 +228,96 @@ struct ProfileScreen: View {
|
|||
.padding(.horizontal, 16)
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ struct TaskDetailScreen: View {
|
|||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { Task { await loadDetails() } }
|
||||
} else if let details = details {
|
||||
contentView(details)
|
||||
}
|
||||
|
|
@ -663,21 +663,6 @@ struct TaskDetailScreen: View {
|
|||
.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
|
||||
|
||||
private func loadDetails() async {
|
||||
|
|
@ -834,6 +819,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 }
|
||||
|
||||
|
|
@ -844,7 +830,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
|
||||
|
|
@ -888,8 +875,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)
|
||||
|
|
@ -954,7 +968,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)
|
||||
|
|
@ -976,7 +990,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 {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ struct TaskListScreen: View {
|
|||
@State private var selectedTask: WorkTask?
|
||||
@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 {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
LoadingView()
|
||||
} else if let error = error {
|
||||
errorView(error)
|
||||
ErrorView(message: error) { loadTasks() }
|
||||
} else if tasks.isEmpty {
|
||||
emptyView
|
||||
} 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
|
||||
|
||||
private func loadTasks(silent: Bool = false) {
|
||||
|
|
|
|||
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