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 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 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)) } else { Button { showCompleteAlert = true } label: { Label("Complete Task", systemImage: "checkmark.circle.fill") .frame(maxWidth: .infinity) .padding(.vertical, 14) } .buttonStyle(.borderedProminent) .tint(.green) } } } // 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 { beaconDetected = true autoCompleting = 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() { Task { do { try await APIService.shared.completeTask(taskId: task.taskId) appState.popToRoot() } catch let apiError as APIError { if case .ratingRequired = apiError { showRatingDialog = true } else { self.error = apiError.localizedDescription } } catch { 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? 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)) } private var changeDue: 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 if let change = changeDue { 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) .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 { try await APIService.shared.completeTask(taskId: taskId, cashReceivedCents: cents) 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)) } }