payfrit-works-ios/PayfritWorks/Views/TaskDetailScreen.swift
Schwifty 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

1135 lines
42 KiB
Swift

import SwiftUI
struct TaskDetailScreen: View {
let task: WorkTask
var showCompleteButton = false
var showAcceptButton = false
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var details: TaskDetails?
@State private var isLoading = true
@State private var error: String?
// Beacon
@State private var beaconDetected = false
@State private var bluetoothOff = false
@State private var autoCompleting = false
@State private var showAutoCompleteDialog = false
@State private var showAcceptAlert = false
@State private var showCompleteAlert = false
@State private var beaconScanner: BeaconScanner?
@State private var showingMyTasks = false
@State private var showingChat = false
@State private var showCashDialog = false
@State private var cashReceived = ""
@State private var cashError: String?
@State private var showCancelOrderAlert = false
@State private var isCancelingOrder = false
@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
// Rating dialog
@State private var showRatingDialog = false
@State private var isSubmittingRating = false
// Computed properties for effective button visibility after accepting
private var effectiveShowAcceptButton: Bool { showAcceptButton && !taskAccepted }
private var effectiveShowCompleteButton: Bool { showCompleteButton || taskAccepted }
var body: some View {
ZStack(alignment: .bottomTrailing) {
Group {
if isLoading {
ProgressView()
} else if let error = error {
errorView(error)
} else if let details = details {
contentView(details)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
.foregroundColor(.primary)
Button { showingMyTasks = true } label: {
Image(systemName: "checkmark.circle.fill")
.font(.title3)
.padding(12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding(.trailing, 16)
.padding(.bottom, 80)
}
.navigationTitle(task.title)
.toolbarBackground(task.color, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
.tint(.white)
.alert("Accept Task?", isPresented: $showAcceptAlert) {
Button("Cancel", role: .cancel) { }
Button("Accept") { acceptTask() }
} message: {
Text("Claim this task and add it to your tasks?")
}
.alert("Complete Task?", isPresented: $showCompleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Complete") { completeTask() }
} message: {
Text("Mark this task as completed?")
}
.alert("Cancel Order?", isPresented: $showCancelOrderAlert) {
Button("Go Back", role: .cancel) { }
Button("Cancel Order", role: .destructive) { cancelOrder() }
} message: {
Text("The customer will not be charged. This cannot be undone.")
}
.sheet(isPresented: $showAutoCompleteDialog) {
AutoCompleteCountdownView(taskId: task.taskId) { result in
showAutoCompleteDialog = false
if result == "success" {
appState.popToRoot()
} else if result == "rating_required" {
autoCompleting = false
beaconDetected = false
showRatingDialog = true
} else if result == "cancelled" || result == "error" {
autoCompleting = false
beaconDetected = false
hasCompleted = false
beaconScanner?.resetSamples()
beaconScanner?.startScanning()
}
}
}
.navigationDestination(isPresented: $showingMyTasks) {
MyTasksScreen()
}
.navigationDestination(isPresented: $showingChat) {
ChatScreen(taskId: task.taskId, userType: "worker",
otherPartyName: details?.customerFullName,
otherPartyPhotoUrl: details?.customerPhotoUrl,
servicePointName: details?.servicePointName,
taskColor: task.color)
}
.sheet(isPresented: $showCashDialog) {
CashCollectionSheet(
orderTotalCents: orderTotalCents,
taskId: task.taskId,
roleId: appState.roleId,
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() }
.onDisappear { beaconScanner?.dispose() }
.onChange(of: appState.shouldPopToTaskList) { shouldPop in
if shouldPop {
appState.shouldPopToTaskList = false
dismiss()
}
}
}
// MARK: - Content
@ViewBuilder
private func contentView(_ details: TaskDetails) -> some View {
ScrollView {
VStack(spacing: 0) {
// Colored header section
VStack(spacing: 16) {
if isCashTask && orderTotalCents > 0 {
cashAmountBanner
}
customerSection(details)
if task.isChat {
chatButton(details)
}
}
.padding(16)
.frame(maxWidth: .infinity)
.background(task.color)
.foregroundColor(.white)
// White content section
VStack(spacing: 16) {
locationSection(details)
if !details.tableMembers.isEmpty {
tableMembersSection(details)
}
if !details.mainItems.isEmpty {
orderItemsSection(details)
}
if !details.orderRemarks.isEmpty {
remarksSection(details)
}
Spacer().frame(height: 80)
}
.padding(16)
}
}
.safeAreaInset(edge: .bottom) {
if effectiveShowAcceptButton || effectiveShowCompleteButton {
bottomBar
}
}
}
// MARK: - Cash Helpers
private var isCashTask: Bool {
task.isCash || (details?.isCash ?? false)
}
private var orderTotalCents: Int {
let fromDetails = details?.orderTotal ?? 0
let fromTask = task.orderTotal
let dollars = fromDetails > 0 ? fromDetails : fromTask
return Int(dollars * 100)
}
private var cashAmountBanner: some View {
HStack(spacing: 12) {
Image(systemName: "dollarsign.circle.fill")
.font(.title2)
.foregroundColor(.black)
VStack(alignment: .leading, spacing: 2) {
Text("Cash Payment Due")
.font(.caption.weight(.medium))
.foregroundColor(.black.opacity(0.7))
Text(String(format: "$%.2f", Double(orderTotalCents) / 100))
.font(.title2.bold())
.foregroundColor(.black)
}
Spacer()
}
.padding(16)
.background(Color.payfritGreen)
.cornerRadius(12)
}
// MARK: - Customer
private func customerSection(_ d: TaskDetails) -> some View {
// For chat/call server tasks, only show avatar after accepted
let showAvatar = !task.isChat || effectiveShowCompleteButton
return HStack(spacing: 16) {
// Avatar - only show for chat tasks after accepted
if showAvatar {
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
ZStack {
Circle()
.fill(Color.white.opacity(0.2))
.frame(width: 64, height: 64)
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure(let error):
// Show initials on error
initialsView(d)
.onAppear {
NSLog("[Avatar] Failed to load image from %@: %@", photoUrl, error.localizedDescription)
}
case .empty:
ProgressView().tint(.white)
@unknown default:
initialsView(d)
}
}
.frame(width: 64, height: 64)
.clipShape(Circle())
.onAppear {
NSLog("[Avatar] Loading customer avatar from: %@", photoUrl)
}
} else {
initialsView(d)
.onAppear {
NSLog("[Avatar] No photo URL available, customerUserId=%d", d.customerUserId)
}
}
}
}
VStack(alignment: .leading, spacing: 4) {
Text(d.customerFullName)
.font(.title3.bold())
if !d.customerPhone.isEmpty {
Text(d.customerPhone)
.font(.subheadline)
.foregroundColor(.white.opacity(0.8))
}
}
Spacer()
// Chat button for chat tasks
if task.isChat {
Button { showingChat = true } label: {
Image(systemName: "bubble.left.and.bubble.right.fill")
.foregroundColor(.white)
.font(.title2)
.padding(10)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
}
if !d.customerPhone.isEmpty {
Button { callCustomer(d.customerPhone) } label: {
Image(systemName: "phone.fill")
.foregroundColor(.white)
.font(.title2)
.padding(10)
.background(Color.white.opacity(0.2))
.clipShape(Circle())
}
}
}
}
private func initialsView(_ d: TaskDetails) -> some View {
let f = d.customerFirstName.isEmpty ? "" : String(d.customerFirstName.prefix(1)).uppercased()
let l = d.customerLastName.isEmpty ? "" : String(d.customerLastName.prefix(1)).uppercased()
let text = "\(f)\(l)".isEmpty ? "?" : "\(f)\(l)"
return Text(text)
.font(.title2.bold())
.foregroundColor(.white)
}
// MARK: - Chat Button
@ViewBuilder
private func chatButton(_ d: TaskDetails) -> some View {
Button { showingChat = true } label: {
HStack(spacing: 12) {
// Customer avatar
let photoUrl = !d.customerPhotoUrl.isEmpty ? d.customerPhotoUrl : (customerAvatarUrl ?? "")
ZStack {
Circle()
.fill(task.color.opacity(0.3))
.frame(width: 56, height: 56)
if !photoUrl.isEmpty, let url = URL(string: photoUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
chatInitialsView(d)
}
}
.frame(width: 56, height: 56)
.clipShape(Circle())
} else {
chatInitialsView(d)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(d.customerFullName)
.font(.callout.weight(.semibold))
.foregroundColor(.black)
// Show service point where chat originated
if !d.servicePointName.isEmpty {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.caption2)
Text(d.servicePointName)
.font(.caption)
}
.foregroundColor(.black.opacity(0.6))
}
Text("Tap to open chat")
.font(.caption2)
.foregroundColor(.payfritGreen)
}
Spacer()
Image(systemName: "bubble.left.and.bubble.right.fill")
.foregroundColor(.payfritGreen)
.font(.title2)
}
.padding(16)
.background(Color.white)
.cornerRadius(12)
}
}
private func chatInitialsView(_ d: TaskDetails) -> some View {
Text(String(d.customerFirstName.prefix(1) + d.customerLastName.prefix(1)).uppercased())
.font(.headline.bold())
.foregroundColor(task.color)
}
// MARK: - Location
private func locationSection(_ d: TaskDetails) -> some View {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.payfritGreen.opacity(0.1))
.frame(width: 48, height: 48)
Image(systemName: d.isDelivery ? "bicycle" : "table.furniture")
.foregroundColor(.payfritGreen)
.font(.title2)
}
VStack(alignment: .leading, spacing: 4) {
Text(d.isDelivery ? "Delivery" : "Table Service")
.font(.caption)
.foregroundColor(.secondary)
Text(d.locationDisplay)
.font(.callout.weight(.medium))
}
Spacer()
if d.isDelivery && d.deliveryLat != 0 {
Button { openMaps(d) } label: {
Image(systemName: "arrow.triangle.turn.up.right.diamond.fill")
.foregroundColor(.payfritGreen)
}
}
if !d.isDelivery && d.servicePointId > 0 && effectiveShowCompleteButton {
beaconIndicator
}
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
@ViewBuilder
private var beaconIndicator: some View {
if autoCompleting {
ProgressView()
} else if bluetoothOff {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.foregroundColor(.payfritGreen)
} else if beaconDetected {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.green)
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundColor(.payfritGreen)
.opacity(0.7)
}
}
// MARK: - Table Members
private func tableMembersSection(_ d: TaskDetails) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "person.3.fill")
.foregroundColor(.secondary)
Text("Table Members (\(d.tableMembers.count))")
.font(.subheadline.weight(.semibold))
Spacer()
}
FlowLayout(spacing: 8) {
ForEach(d.tableMembers) { member in
HStack(spacing: 6) {
Circle()
.fill(member.isHost ? Color.yellow.opacity(0.3) : Color(.systemGray4))
.frame(width: 28, height: 28)
.overlay(
Text(member.initials)
.font(.caption2)
)
Text(member.fullName)
.font(.subheadline)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(member.isHost ? Color.yellow.opacity(0.1) : Color(.systemGray6))
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(member.isHost ? Color.yellow : Color.clear, lineWidth: 1)
)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
// MARK: - Order Items
private func orderItemsSection(_ d: TaskDetails) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "list.bullet.rectangle.portrait")
.foregroundColor(.secondary)
Text("Order Items (\(d.mainItems.count))")
.font(.subheadline.weight(.semibold))
Spacer()
}
ForEach(d.mainItems) { item in
orderItemRow(item, details: d)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12)
}
private func orderItemRow(_ item: OrderLineItem, details d: TaskDetails) -> some View {
let modifiers = d.modifiers(for: item.lineItemId)
return HStack(alignment: .top, spacing: 12) {
// Item image placeholder
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 56, height: 56)
.overlay(
Image(systemName: "fork.knife")
.foregroundColor(.secondary)
)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("\(item.quantity)x")
.font(.caption.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(task.color)
.cornerRadius(4)
Text(item.itemName)
.font(.callout.weight(.semibold))
}
ForEach(modifiers) { mod in
// For inverted groups: show removed defaults as "NO {name}"
if mod.isInvertedGroup && !mod.removedDefaults.isEmpty {
ForEach(mod.removedDefaults, id: \.self) { removedName in
Text("- NO \(removedName)")
.font(.caption)
.foregroundColor(.red)
}
}
let children = d.lineItems.filter { $0.parentLineItemId == mod.lineItemId }
if !children.isEmpty {
ForEach(children) { child in
// Skip default items in inverted groups (they're unchanged)
if mod.isInvertedGroup && child.isCheckedByDefault {
// Don't display - this is an unchanged default
} else {
Text("- \(mod.itemName): \(child.itemName)")
.font(.caption)
.foregroundColor(.secondary)
}
}
} else if !mod.isInvertedGroup {
// Regular modifier (not inverted group)
Text("- \(mod.itemName)")
.font(.caption)
.foregroundColor(.secondary)
}
}
if !item.remark.isEmpty {
Text("\"\(item.remark)\"")
.font(.caption)
.foregroundColor(.payfritGreen)
.italic()
}
}
}
.padding(.bottom, 8)
}
// MARK: - Remarks
private func remarksSection(_ d: TaskDetails) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "note.text")
.foregroundColor(.yellow)
VStack(alignment: .leading, spacing: 4) {
Text("Order Notes")
.font(.caption.weight(.semibold))
.foregroundColor(.yellow)
Text(d.orderRemarks)
.font(.callout)
}
Spacer()
}
.padding(16)
.background(Color.yellow.opacity(0.1))
.cornerRadius(12)
}
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 12) {
HStack {
if effectiveShowAcceptButton {
Button { showAcceptAlert = true } label: {
Label("Accept Task", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.payfritGreen)
}
if effectiveShowCompleteButton {
if isCashTask && orderTotalCents > 0 {
Button { showCashDialog = true } label: {
Label("Collect Cash", systemImage: "dollarsign.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
.disabled(hasCompleted)
} else {
Button { showCompleteAlert = true } label: {
Label("Complete Task", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.tint(.green)
.disabled(hasCompleted)
}
}
}
// Cancel Order button for cash tasks
if effectiveShowCompleteButton && isCashTask && orderTotalCents > 0 {
Button {
showCancelOrderAlert = true
} label: {
if isCancelingOrder {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .red))
} else {
Label("Cancel Order", systemImage: "xmark.circle")
.font(.subheadline)
}
}
.foregroundColor(.red)
.disabled(isCancelingOrder)
}
}
.padding(.horizontal)
.padding(.vertical, 12)
.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 {
isLoading = true
error = nil
do {
let d = try await APIService.shared.getTaskDetails(taskId: task.taskId)
details = d
isLoading = false
// Fetch customer avatar separately if not included in task details
if d.customerPhotoUrl.isEmpty && d.customerUserId > 0 {
Task {
// Try API first, then fallback to direct URL pattern
if let avatarUrl = try? await APIService.shared.getUserAvatarUrl(userId: d.customerUserId) {
await MainActor.run {
customerAvatarUrl = avatarUrl
}
} else {
// Try direct URL pattern as fallback
let directUrl = APIService.directAvatarUrl(userId: d.customerUserId)
await MainActor.run {
customerAvatarUrl = directUrl
}
}
}
}
print("[Beacon] showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
if showCompleteButton && d.servicePointId > 0 {
print("[Beacon] Starting beacon scanning for ServicePointId: \(d.servicePointId)")
startBeaconScanning(d.servicePointId)
} else {
print("[Beacon] NOT starting scan - showCompleteButton=\(showCompleteButton), servicePointId=\(d.servicePointId)")
}
} catch {
self.error = error.localizedDescription
isLoading = false
}
}
private func startBeaconScanning(_ servicePointId: Int) {
let scanner = BeaconScanner(
targetServicePointId: servicePointId,
onBeaconDetected: { [self] _ in
if !beaconDetected && !autoCompleting && !hasCompleted {
beaconDetected = true
autoCompleting = true
hasCompleted = true
beaconScanner?.stopScanning()
showAutoCompleteDialog = true
}
},
onBluetoothOff: { bluetoothOff = true },
onPermissionDenied: { self.error = "Location permission is required for beacon detection. Please enable it in Settings." }
)
beaconScanner = scanner
scanner.startScanning()
}
private func acceptTask() {
Task {
do {
try await APIService.shared.acceptTask(taskId: task.taskId)
if task.isChat {
// Go directly to chat for chat tasks
// Don't set taskAccepted to avoid triggering unwanted state changes
showingChat = true
} else {
// Stay on detail screen
taskAccepted = true
if let d = details, d.servicePointId > 0 {
startBeaconScanning(d.servicePointId)
}
}
} catch {
self.error = error.localizedDescription
}
}
}
private func completeTask() {
guard !hasCompleted else { return }
hasCompleted = true
Task {
do {
try await APIService.shared.completeTask(taskId: task.taskId)
appState.popToRoot()
} catch let apiError as APIError {
if case .ratingRequired = apiError {
showRatingDialog = true
} else {
hasCompleted = false
self.error = apiError.localizedDescription
}
} catch {
hasCompleted = false
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() {
isCancelingOrder = true
Task {
do {
try await APIService.shared.completeTask(taskId: task.taskId, cancelOrder: true)
appState.popToRoot()
} catch {
self.error = error.localizedDescription
isCancelingOrder = false
}
}
}
private func callCustomer(_ phone: String) {
guard let url = URL(string: "tel:\(phone)") else { return }
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
private func openMaps(_ d: TaskDetails) {
guard d.deliveryLat != 0, d.deliveryLng != 0 else { return }
let urlStr = "https://www.google.com/maps/dir/?api=1&destination=\(d.deliveryLat),\(d.deliveryLng)"
guard let url = URL(string: urlStr) else { return }
#if os(iOS)
UIApplication.shared.open(url)
#endif
}
}
// MARK: - Cash Collection Sheet
struct CashCollectionSheet: View {
let orderTotalCents: Int
let taskId: Int
let roleId: Int // 1=Staff, 2=Manager, 3=Admin
let onComplete: () -> Void
@Environment(\.dismiss) var dismiss
@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 }
private var orderTotalDollars: Double { Double(orderTotalCents) / 100 }
private var cashReceivedCents: Int? {
guard let dollars = Double(cashReceivedText) else { return nil }
return Int(round(dollars * 100))
}
/// Client-side estimate shown before confirmation (preview only)
private var estimatedChangeDue: Double? {
guard let receivedCents = cashReceivedCents else { return nil }
let change = Double(receivedCents - orderTotalCents) / 100
return change >= 0 ? change : nil
}
private var isValid: Bool {
guard let receivedCents = cashReceivedCents else { return false }
return receivedCents >= orderTotalCents
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Order total
VStack(spacing: 4) {
Text("Amount Due")
.font(.subheadline)
.foregroundColor(.secondary)
Text(String(format: "$%.2f", orderTotalDollars))
.font(.system(size: 42, weight: .bold))
.foregroundColor(Color(red: 0.13, green: 0.55, blue: 0.13))
}
.padding(.top, 16)
// Cash received input
VStack(alignment: .leading, spacing: 8) {
Text("Cash Received")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
HStack {
Text("$")
.font(.title2.weight(.semibold))
.foregroundColor(.secondary)
TextField("0.00", text: $cashReceivedText)
.font(.title2.weight(.semibold))
.keyboardType(.decimalPad)
}
.padding(16)
.background(Color(.systemGray6))
.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 {
HStack {
Image(systemName: "arrow.uturn.left.circle.fill")
.foregroundColor(.payfritGreen)
Text("Change:")
.font(.body.weight(.medium))
Spacer()
Text(String(format: "$%.2f", change))
.font(.title3.bold())
.foregroundColor(.payfritGreen)
}
.padding(16)
.background(Color.payfritGreen.opacity(0.1))
.cornerRadius(12)
} else if cashReceivedCents != nil {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text("Amount received must be at least \(String(format: "$%.2f", orderTotalDollars))")
.font(.callout)
.foregroundColor(.red)
}
.padding(16)
.background(Color.red.opacity(0.1))
.cornerRadius(12)
}
// Role-based cash routing info
HStack(spacing: 8) {
Image(systemName: isAdmin ? "building.2.fill" : "person.fill")
.foregroundColor(.secondary)
Text(isAdmin ? "Cash will be deposited to the business register" : "Cash will be applied to your balance")
.font(.callout)
.foregroundColor(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(10)
if let err = errorMessage {
Text(err)
.font(.callout)
.foregroundColor(.red)
.padding(.horizontal)
}
Spacer()
// Confirm button
Button {
confirmCashCollection()
} label: {
if isProcessing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity, minHeight: 50)
} else {
Text("Confirm Cash Received")
.font(.headline)
.frame(maxWidth: .infinity, minHeight: 50)
}
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 0.13, green: 0.55, blue: 0.13))
.disabled(!isValid || isProcessing || completionResult != nil)
.padding(.bottom, 16)
}
.padding(.horizontal, 24)
.navigationTitle("Collect Cash")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func confirmCashCollection() {
guard let cents = cashReceivedCents, cents >= orderTotalCents else { return }
isProcessing = true
errorMessage = nil
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)
}
dismiss()
onComplete()
} catch {
errorMessage = error.localizedDescription
isProcessing = false
}
}
}
}
// MARK: - Auto-Complete Countdown
struct AutoCompleteCountdownView: View {
let taskId: Int
var cashReceivedCents: Int? = nil
let onResult: (String) -> Void
@State private var countdown = 3
@State private var completing = false
@State private var message = "Auto-completing in"
var body: some View {
VStack(spacing: 24) {
Image(systemName: completing ? "checkmark.circle.fill" : "timer")
.font(.system(size: 48))
.foregroundColor(completing ? .green : .payfritGreen)
Text(completing ? "Task Completed!" : "Auto-Complete")
.font(.title2.bold())
Text(completing ? message : "\(message) \(countdown)...")
.font(.body)
if !completing {
Button("Cancel") { onResult("cancelled") }
.buttonStyle(.bordered)
}
}
.padding(32)
.presentationDetents([.height(280)])
.task { await startCountdown() }
}
private func startCountdown() async {
for i in stride(from: 3, through: 1, by: -1) {
countdown = i
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
completing = true
message = "Completing task..."
do {
try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cashReceivedCents)
message = "Closing this window now"
try? await Task.sleep(nanoseconds: 1_000_000_000)
onResult("success")
} catch let apiError as APIError {
if case .ratingRequired = apiError {
onResult("rating_required")
} else {
onResult("error")
}
} catch {
onResult("error")
}
}
}
// MARK: - Flow Layout (for table members)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
proposal: .unspecified)
}
}
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var totalWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
totalWidth = max(totalWidth, currentX)
}
return (positions, CGSize(width: totalWidth, height: currentY + lineHeight))
}
}