Compare commits

..

3 commits

Author SHA1 Message Date
d5d1c35b55 fix: gate debug print() statements behind IS_DEV flag
Wrapped all debug print() calls in APIService (avatar debugging),
BeaconScanner (scan/resolve logging), TaskDetailScreen (beacon state),
and AboutScreen (error logging) with IS_DEV checks so they are silent
in production builds. Preview-only prints in RatingDialog left as-is.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:12:13 +00:00
54923ba341 fix: add hasCompleted guard to prevent double-completion race condition
If a user confirms cash payment AND a beacon triggers auto-complete at the
same time, two completion calls could fire. Added @State hasCompleted flag
that gates all completion paths (manual complete, beacon auto-complete, and
cash collection). Resets on error/cancel so user can retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:10:56 +00:00
db7fe31b8a fix: standardize task polling interval from 2s to 5s (Android parity)
TaskListScreen, MyTasksScreen, and BusinessSelectionScreen all had 2-second
refresh timers. Changed to 5 seconds to match Android and reduce server load.
Chat polling (3s) left unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:10:07 +00:00
11 changed files with 143 additions and 305 deletions

View file

@ -691,14 +691,14 @@ actor APIService {
func getAvatarUrl() async throws -> String? { func getAvatarUrl() async throws -> String? {
let json = try await getJSON("/auth/avatar.php") let json = try await getJSON("/auth/avatar.php")
print("[Avatar] Response: \(json)") if IS_DEV { print("[Avatar] Response: \(json)") }
guard ok(json) else { guard ok(json) else {
print("[Avatar] Response not OK") if IS_DEV { print("[Avatar] Response not OK") }
return nil return nil
} }
let data = json["DATA"] as? [String: Any] ?? json let data = json["DATA"] as? [String: Any] ?? json
print("[Avatar] Data: \(data)") if IS_DEV { print("[Avatar] Data: \(data)") }
// Try all possible key variations for avatar URL // Try all possible key variations for avatar URL
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url", let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
@ -707,16 +707,16 @@ actor APIService {
for key in keys { for key in keys {
if let url = data[key] as? String, !url.isEmpty { if let url = data[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url) let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)") if IS_DEV { print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)") }
return resolved return resolved
} }
if let url = json[key] as? String, !url.isEmpty { if let url = json[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url) let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)") if IS_DEV { print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)") }
return resolved return resolved
} }
} }
print("[Avatar] No avatar URL found in response") if IS_DEV { print("[Avatar] No avatar URL found in response") }
return nil return nil
} }
@ -724,7 +724,7 @@ actor APIService {
func getUserAvatarUrl(userId: Int) async throws -> String? { func getUserAvatarUrl(userId: Int) async throws -> String? {
// Try the avatar endpoint with UserID // Try the avatar endpoint with UserID
let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId]) let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId])
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)") if IS_DEV { print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)") }
let data = json["DATA"] as? [String: Any] ?? json let data = json["DATA"] as? [String: Any] ?? json
@ -733,15 +733,15 @@ actor APIService {
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"] "UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
for key in keys { for key in keys {
if let url = data[key] as? String, !url.isEmpty { if let url = data[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)") if IS_DEV { print("[Avatar] Found avatar for userId \(userId): \(url)") }
return Self.resolvePhotoUrl(url) return Self.resolvePhotoUrl(url)
} }
if let url = json[key] as? String, !url.isEmpty { if let url = json[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)") if IS_DEV { print("[Avatar] Found avatar for userId \(userId): \(url)") }
return Self.resolvePhotoUrl(url) return Self.resolvePhotoUrl(url)
} }
} }
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())") if IS_DEV { print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())") }
return nil return nil
} }

View file

@ -89,7 +89,7 @@ final class BeaconScanner: NSObject, ObservableObject {
} }
} }
print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)") if IS_DEV { print("[BeaconScanner] Started scanning for \(BeaconShardPool.uuids.count) shard UUIDs, target ServicePointId: \(targetServicePointId)") }
} }
func stopScanning() { func stopScanning() {
@ -137,12 +137,12 @@ final class BeaconScanner: NSObject, ObservableObject {
await MainActor.run { await MainActor.run {
self.resolvedBeacons[key] = servicePointId self.resolvedBeacons[key] = servicePointId
self.pendingResolutions.remove(key) self.pendingResolutions.remove(key)
print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)") if IS_DEV { print("[BeaconScanner] Resolved \(key) -> ServicePointId \(servicePointId)") }
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
self.pendingResolutions.remove(key) self.pendingResolutions.remove(key)
print("[BeaconScanner] Failed to resolve \(key): \(error)") if IS_DEV { print("[BeaconScanner] Failed to resolve \(key): \(error)") }
} }
} }
} }
@ -181,7 +181,7 @@ extension BeaconScanner: CLLocationManagerDelegate {
if rssiSamples.count >= minSamplesToConfirm { if rssiSamples.count >= minSamplesToConfirm {
let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count)
print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)") if IS_DEV { print("[BeaconScanner] Target beacon confirmed! Avg RSSI: \(avg)") }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.onBeaconDetected(avg) self?.onBeaconDetected(avg)
} }
@ -210,6 +210,6 @@ extension BeaconScanner: CLLocationManagerDelegate {
} }
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
print("[BeaconScanner] Ranging failed: \(error)") if IS_DEV { print("[BeaconScanner] Ranging failed: \(error)") }
} }
} }

View file

@ -128,7 +128,7 @@ struct AboutScreen: View {
aboutInfo = try await APIService.shared.getAboutInfo() aboutInfo = try await APIService.shared.getAboutInfo()
} catch { } catch {
// Use fallback on error // Use fallback on error
print("Failed to load about info: \(error)") if IS_DEV { print("Failed to load about info: \(error)") }
} }
isLoading = false isLoading = false
} }

View file

@ -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 {

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: 3, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 5, 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) {

View file

@ -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.")
}

View file

@ -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()
}

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: 3, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 5, 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 {

View file

@ -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 {

View file

@ -28,6 +28,7 @@ struct TaskDetailScreen: View {
@State private var showCancelOrderAlert = false @State private var showCancelOrderAlert = false
@State private var isCancelingOrder = false @State private var isCancelingOrder = false
@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 hasCompleted = false // Guard against double-completion
@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 // Rating dialog
@ -42,9 +43,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)
} }
@ -100,6 +101,7 @@ struct TaskDetailScreen: View {
} else if result == "cancelled" || result == "error" { } else if result == "cancelled" || result == "error" {
autoCompleting = false autoCompleting = false
beaconDetected = false beaconDetected = false
hasCompleted = false
beaconScanner?.resetSamples() beaconScanner?.resetSamples()
beaconScanner?.startScanning() beaconScanner?.startScanning()
} }
@ -629,6 +631,7 @@ struct TaskDetailScreen: 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(hasCompleted)
} else { } else {
Button { showCompleteAlert = true } label: { Button { showCompleteAlert = true } label: {
Label("Complete Task", systemImage: "checkmark.circle.fill") Label("Complete Task", systemImage: "checkmark.circle.fill")
@ -637,6 +640,7 @@ struct TaskDetailScreen: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.green) .tint(.green)
.disabled(hasCompleted)
} }
} }
} }
@ -663,6 +667,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 {
@ -691,12 +710,12 @@ struct TaskDetailScreen: View {
} }
} }
print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") if IS_DEV { print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") }
if showCompleteButton && d.servicePointId > 0 { if showCompleteButton && d.servicePointId > 0 {
print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)") if IS_DEV { print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)") }
startBeaconScanning(d.servicePointId) startBeaconScanning(d.servicePointId)
} else { } else {
print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") if IS_DEV { print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)") }
} }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
@ -708,9 +727,10 @@ struct TaskDetailScreen: View {
let scanner = BeaconScanner( let scanner = BeaconScanner(
targetServicePointId: servicePointId, targetServicePointId: servicePointId,
onBeaconDetected: { [self] _ in onBeaconDetected: { [self] _ in
if !beaconDetected && !autoCompleting { if !beaconDetected && !autoCompleting && !hasCompleted {
beaconDetected = true beaconDetected = true
autoCompleting = true autoCompleting = true
hasCompleted = true
beaconScanner?.stopScanning() beaconScanner?.stopScanning()
showAutoCompleteDialog = true showAutoCompleteDialog = true
} }
@ -745,6 +765,8 @@ struct TaskDetailScreen: View {
} }
private func completeTask() { private func completeTask() {
guard !hasCompleted else { return }
hasCompleted = true
Task { Task {
do { do {
try await APIService.shared.completeTask(taskId: task.taskId) try await APIService.shared.completeTask(taskId: task.taskId)
@ -753,9 +775,11 @@ struct TaskDetailScreen: View {
if case .ratingRequired = apiError { if case .ratingRequired = apiError {
showRatingDialog = true showRatingDialog = true
} else { } else {
hasCompleted = false
self.error = apiError.localizedDescription self.error = apiError.localizedDescription
} }
} catch { } catch {
hasCompleted = false
self.error = error.localizedDescription self.error = error.localizedDescription
} }
} }

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: 3, on: .main, in: .common).autoconnect() private let refreshTimer = Timer.publish(every: 5, 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) {