Compare commits
No commits in common. "main" and "schwifty/remove-build-artifacts" have entirely different histories.
main
...
schwifty/r
11 changed files with 121 additions and 592 deletions
|
|
@ -43,7 +43,6 @@
|
||||||
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; };
|
||||||
|
|
@ -91,7 +90,6 @@
|
||||||
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>"; };
|
||||||
|
|
@ -188,7 +186,6 @@
|
||||||
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>";
|
||||||
|
|
@ -315,7 +312,6 @@
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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 {
|
||||||
|
|
@ -28,7 +27,6 @@ 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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,22 +54,6 @@ 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 {
|
||||||
|
|
@ -490,16 +472,11 @@ actor APIService {
|
||||||
return arr.map { WorkTask(json: $0) }
|
return arr.map { WorkTask(json: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete a task. Returns CashCompletionResult when cashReceivedCents is provided.
|
func completeTask(taskId: Int, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws {
|
||||||
@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
|
||||||
}
|
}
|
||||||
|
|
@ -508,27 +485,8 @@ 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 {
|
||||||
let errorMsg = err(json)
|
throw APIError.serverError("Failed to complete task: \(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 {
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,10 @@ struct AccountScreen: View {
|
||||||
|
|
||||||
// Payout content
|
// Payout content
|
||||||
if isLoading {
|
if isLoading {
|
||||||
LoadingView()
|
ProgressView()
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
ErrorView(message: error) { Task { await loadData() } }
|
errorView(error)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
if let tier = tierStatus {
|
if let tier = tierStatus {
|
||||||
|
|
@ -386,6 +386,21 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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: 3, on: .main, in: .common).autoconnect()
|
private let refreshTimer = Timer.publish(every: 2, 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 {
|
||||||
LoadingView()
|
ProgressView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
ErrorView(message: error) { loadBusinesses() }
|
errorView(error)
|
||||||
} else if businesses.isEmpty {
|
} else if businesses.isEmpty {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -246,6 +246,19 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -94,9 +94,18 @@ struct MyTasksScreen: View {
|
||||||
let tasks = tasksByFilter[filterType] ?? []
|
let tasks = tasksByFilter[filterType] ?? []
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
LoadingView()
|
ProgressView()
|
||||||
} else if let error = error {
|
} 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 {
|
} else if tasks.isEmpty {
|
||||||
emptyView(filterType)
|
emptyView(filterType)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,8 @@ 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 {
|
||||||
if let p = profile {
|
appState.userName ?? "Worker"
|
||||||
return [p.firstName, p.lastName].filter { !$0.isEmpty }.joined(separator: " ")
|
|
||||||
}
|
|
||||||
return appState.userName ?? "Worker"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var avatarURL: URL? {
|
private var avatarURL: URL? {
|
||||||
|
|
@ -72,20 +57,34 @@ struct ProfileScreen: View {
|
||||||
.scaleEffect(appearAnimation ? 1 : 0.9)
|
.scaleEffect(appearAnimation ? 1 : 0.9)
|
||||||
.opacity(appearAnimation ? 1 : 0)
|
.opacity(appearAnimation ? 1 : 0)
|
||||||
|
|
||||||
// Content
|
// Info section
|
||||||
if isLoadingProfile {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
LoadingView(message: "Loading profile...")
|
Text("Account Information")
|
||||||
.frame(height: 120)
|
.font(.subheadline)
|
||||||
} else if let error = profileError {
|
.fontWeight(.semibold)
|
||||||
ErrorView(message: error) { loadProfile() }
|
.foregroundColor(.payfritGreen)
|
||||||
} else if isEditing {
|
.padding(.horizontal, 16)
|
||||||
editSection
|
|
||||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
VStack(spacing: 0) {
|
||||||
} else {
|
infoRow(icon: "person.fill", label: "Name", value: displayName)
|
||||||
infoSection
|
Divider().padding(.leading, 54)
|
||||||
.opacity(appearAnimation ? 1 : 0)
|
infoRow(icon: "briefcase.fill", label: "Role", value: appState.roleName)
|
||||||
.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)
|
||||||
|
|
@ -94,16 +93,15 @@ struct ProfileScreen: View {
|
||||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
.navigationTitle("Profile")
|
.navigationTitle("Profile")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
|
||||||
if !isEditing && !isLoadingProfile && profileError == nil {
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button("Edit") { startEditing() }
|
|
||||||
.foregroundColor(.payfritGreen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
await loadAvatarAndProfile()
|
do {
|
||||||
|
if let urlString = try await APIService.shared.getAvatarUrl(),
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
avatarURLLoaded = url
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Avatar load failed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
|
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 {
|
private var placeholderAvatar: some View {
|
||||||
Image(systemName: "person.circle.fill")
|
Image(systemName: "person.circle.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
|
@ -228,96 +143,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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,10 +30,6 @@ 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 }
|
||||||
|
|
@ -42,9 +38,9 @@ struct TaskDetailScreen: View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
LoadingView()
|
ProgressView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
ErrorView(message: error) { Task { await loadDetails() } }
|
errorView(error)
|
||||||
} else if let details = details {
|
} else if let details = details {
|
||||||
contentView(details)
|
contentView(details)
|
||||||
}
|
}
|
||||||
|
|
@ -93,10 +89,6 @@ 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
|
||||||
|
|
@ -123,25 +115,6 @@ 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
|
||||||
|
|
@ -663,6 +636,21 @@ 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 {
|
||||||
|
|
@ -749,34 +737,12 @@ 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 {
|
||||||
|
|
@ -819,7 +785,6 @@ 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 }
|
||||||
|
|
||||||
|
|
@ -830,8 +795,7 @@ struct CashCollectionSheet: View {
|
||||||
return Int(round(dollars * 100))
|
return Int(round(dollars * 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client-side estimate shown before confirmation (preview only)
|
private var changeDue: Double? {
|
||||||
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
|
||||||
|
|
@ -875,35 +839,8 @@ struct CashCollectionSheet: View {
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change display — backend-authoritative after completion, estimate before
|
// Change display
|
||||||
if let result = completionResult {
|
if let change = changeDue {
|
||||||
// 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)
|
||||||
|
|
@ -968,7 +905,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 || completionResult != nil)
|
.disabled(!isValid || isProcessing)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|
@ -990,14 +927,7 @@ struct CashCollectionSheet: View {
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents)
|
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 {
|
||||||
|
|
@ -1055,12 +985,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: 3, on: .main, in: .common).autoconnect()
|
private let refreshTimer = Timer.publish(every: 2, 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 {
|
||||||
LoadingView()
|
ProgressView()
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
ErrorView(message: error) { loadTasks() }
|
errorView(error)
|
||||||
} else if tasks.isEmpty {
|
} else if tasks.isEmpty {
|
||||||
emptyView
|
emptyView
|
||||||
} else {
|
} 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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func loadTasks(silent: Bool = false) {
|
private func loadTasks(silent: Bool = false) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue