987 lines
36 KiB
Swift
987 lines
36 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 customerAvatarUrl: String? // Fetched separately if not in task details
|
|
|
|
// 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 == "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,
|
|
onComplete: { appState.popToRoot() }
|
|
)
|
|
}
|
|
.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 cents = fromDetails > 0 ? fromDetails : fromTask
|
|
NSLog("[Cash] orderTotal fromDetails=%.2f, fromTask=%.2f, using=%.2f, asInt=%d", fromDetails, fromTask, cents, Int(cents))
|
|
return Int(cents)
|
|
}
|
|
|
|
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()
|
|
|
|
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))
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.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))
|
|
}
|
|
|
|
ForEach(d.mainItems) { item in
|
|
orderItemRow(item, details: d)
|
|
}
|
|
}
|
|
.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
|
|
let children = d.lineItems.filter { $0.parentLineItemId == mod.lineItemId }
|
|
if !children.isEmpty {
|
|
ForEach(children) { child in
|
|
Text("- \(mod.itemName): \(child.itemName)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
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)
|
|
taskAccepted = true
|
|
|
|
if task.isChat {
|
|
// Go directly to chat for chat tasks
|
|
showingChat = true
|
|
} else {
|
|
// Stay on detail screen, start beacon scanning if applicable
|
|
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 {
|
|
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 onComplete: () -> Void
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
@State private var cashReceivedText = ""
|
|
@State private var isProcessing = false
|
|
@State private var errorMessage: String?
|
|
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
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))
|
|
}
|
|
}
|