Compare commits
No commits in common. "main" and "schwifty/customer-rating" have entirely different histories.
main
...
schwifty/c
2572 changed files with 7539 additions and 360 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
|||
# Xcode
|
||||
build/
|
||||
build-sim/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
*.xcworkspacedata
|
||||
|
|
|
|||
|
|
@ -56,22 +56,6 @@ 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 {
|
||||
|
|
@ -490,9 +474,7 @@ actor APIService {
|
|||
return arr.map { WorkTask(json: $0) }
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws {
|
||||
var payload: [String: Any] = [
|
||||
"TaskID": taskId,
|
||||
"UserID": userId ?? 0
|
||||
|
|
@ -515,20 +497,6 @@ 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 {
|
||||
LoadingView()
|
||||
ProgressView()
|
||||
.padding(.top, 20)
|
||||
} else if let error = error {
|
||||
ErrorView(message: error) { Task { await loadData() } }
|
||||
errorView(error)
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
if let tier = tierStatus {
|
||||
|
|
@ -386,6 +386,21 @@ 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: 3, on: .main, in: .common).autoconnect()
|
||||
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
LoadingView()
|
||||
ProgressView()
|
||||
} else if let error = error {
|
||||
ErrorView(message: error) { loadBusinesses() }
|
||||
errorView(error)
|
||||
} else if businesses.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
|
|
@ -246,6 +246,19 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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.")
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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: 3, on: .main, in: .common).autoconnect()
|
||||
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -94,9 +94,18 @@ struct MyTasksScreen: View {
|
|||
let tasks = tasksByFilter[filterType] ?? []
|
||||
|
||||
if isLoading {
|
||||
LoadingView()
|
||||
ProgressView()
|
||||
} else if let error = error {
|
||||
ErrorView(message: error) { loadTasks(filterType) }
|
||||
VStack(spacing: 16) {
|
||||
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 {
|
||||
emptyView(filterType)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,23 +6,8 @@ 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 {
|
||||
if let p = profile {
|
||||
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
return appState.userName ?? "Worker"
|
||||
appState.userName ?? "Worker"
|
||||
}
|
||||
|
||||
private var avatarURL: URL? {
|
||||
|
|
@ -72,20 +57,34 @@ struct ProfileScreen: View {
|
|||
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||
.opacity(appearAnimation ? 1 : 0)
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
.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)
|
||||
|
|
@ -94,16 +93,15 @@ struct ProfileScreen: View {
|
|||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !isEditing && !isLoadingProfile && profileError == nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Edit") { startEditing() }
|
||||
.foregroundColor(.payfritGreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadAvatarAndProfile()
|
||||
do {
|
||||
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||
let url = URL(string: urlString) {
|
||||
avatarURLLoaded = url
|
||||
}
|
||||
} catch {
|
||||
// Avatar load failed
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
||||
|
|
@ -112,89 +110,6 @@ 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()
|
||||
|
|
@ -228,96 +143,6 @@ 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 {
|
||||
LoadingView()
|
||||
ProgressView()
|
||||
} else if let error = error {
|
||||
ErrorView(message: error) { Task { await loadDetails() } }
|
||||
errorView(error)
|
||||
} else if let details = details {
|
||||
contentView(details)
|
||||
}
|
||||
|
|
@ -663,6 +663,21 @@ 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 {
|
||||
|
|
@ -819,7 +834,6 @@ 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 }
|
||||
|
||||
|
|
@ -830,8 +844,7 @@ struct CashCollectionSheet: View {
|
|||
return Int(round(dollars * 100))
|
||||
}
|
||||
|
||||
/// Client-side estimate shown before confirmation (preview only)
|
||||
private var estimatedChangeDue: Double? {
|
||||
private var changeDue: Double? {
|
||||
guard let receivedCents = cashReceivedCents else { return nil }
|
||||
let change = Double(receivedCents - orderTotalCents) / 100
|
||||
return change >= 0 ? change : nil
|
||||
|
|
@ -875,35 +888,8 @@ struct CashCollectionSheet: View {
|
|||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Change display
|
||||
if let change = changeDue {
|
||||
HStack {
|
||||
Image(systemName: "arrow.uturn.left.circle.fill")
|
||||
.foregroundColor(.payfritGreen)
|
||||
|
|
@ -968,7 +954,7 @@ struct CashCollectionSheet: View {
|
|||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
|
||||
.disabled(!isValid || isProcessing || completionResult != nil)
|
||||
.disabled(!isValid || isProcessing)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
|
@ -990,14 +976,7 @@ struct CashCollectionSheet: View {
|
|||
|
||||
Task {
|
||||
do {
|
||||
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)
|
||||
}
|
||||
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
|
||||
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: 3, on: .main, in: .common).autoconnect()
|
||||
private let refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if isLoading {
|
||||
LoadingView()
|
||||
ProgressView()
|
||||
} else if let error = error {
|
||||
ErrorView(message: error) { loadTasks() }
|
||||
errorView(error)
|
||||
} else if tasks.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
|
|
@ -195,6 +195,19 @@ 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