625 lines
21 KiB
Swift
625 lines
21 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
|
|
|
|
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)
|
|
|
|
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)
|
|
.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?")
|
|
}
|
|
.sheet(isPresented: $showAutoCompleteDialog) {
|
|
AutoCompleteCountdownView(taskId: task.taskId) { result in
|
|
showAutoCompleteDialog = false
|
|
if result == "success" {
|
|
dismiss()
|
|
} 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)
|
|
}
|
|
.task { await loadDetails() }
|
|
.onDisappear { beaconScanner?.dispose() }
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
@ViewBuilder
|
|
private func contentView(_ details: TaskDetails) -> some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
customerSection(details)
|
|
if task.isChat {
|
|
chatButton(details)
|
|
}
|
|
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 showAcceptButton || showCompleteButton {
|
|
bottomBar
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Customer
|
|
|
|
private func customerSection(_ d: TaskDetails) -> some View {
|
|
HStack(spacing: 16) {
|
|
// Avatar
|
|
ZStack {
|
|
Circle()
|
|
.fill(task.color.opacity(0.2))
|
|
.frame(width: 64, height: 64)
|
|
|
|
if !d.customerPhotoUrl.isEmpty, let url = URL(string: d.customerPhotoUrl) {
|
|
AsyncImage(url: url) { image in
|
|
image.resizable().scaledToFill()
|
|
} placeholder: {
|
|
initialsView(d)
|
|
}
|
|
.frame(width: 64, height: 64)
|
|
.clipShape(Circle())
|
|
} else {
|
|
initialsView(d)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(d.customerFullName)
|
|
.font(.title3.bold())
|
|
if !d.customerPhone.isEmpty {
|
|
Text(d.customerPhone)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !d.customerPhone.isEmpty {
|
|
Button { callCustomer(d.customerPhone) } label: {
|
|
Image(systemName: "phone.fill")
|
|
.foregroundColor(.green)
|
|
.font(.title2)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
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(task.color)
|
|
}
|
|
|
|
// MARK: - Chat Button
|
|
|
|
@ViewBuilder
|
|
private func chatButton(_ d: TaskDetails) -> some View {
|
|
Button { showingChat = true } label: {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color.payfritGreen.opacity(0.1))
|
|
.frame(width: 48, height: 48)
|
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
|
.foregroundColor(.payfritGreen)
|
|
.font(.title2)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Chat with Customer")
|
|
.font(.callout.weight(.medium))
|
|
.foregroundColor(.primary)
|
|
Text(d.customerFullName)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
// 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.beaconUUID.isEmpty && showCompleteButton {
|
|
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 {
|
|
HStack {
|
|
if showAcceptButton {
|
|
Button { showAcceptAlert = true } label: {
|
|
Label("Accept Task", systemImage: "plus.circle.fill")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.payfritGreen)
|
|
}
|
|
|
|
if showCompleteButton {
|
|
Button { showCompleteAlert = true } label: {
|
|
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.green)
|
|
}
|
|
}
|
|
.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
|
|
|
|
if showCompleteButton && !d.beaconUUID.isEmpty {
|
|
startBeaconScanning(d.beaconUUID)
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private func startBeaconScanning(_ uuid: String) {
|
|
let scanner = BeaconScanner(
|
|
targetUUID: uuid,
|
|
onBeaconDetected: { [self] _ in
|
|
if !beaconDetected && !autoCompleting {
|
|
beaconDetected = true
|
|
autoCompleting = true
|
|
beaconScanner?.stopScanning()
|
|
showAutoCompleteDialog = true
|
|
}
|
|
},
|
|
onBluetoothOff: { bluetoothOff = true },
|
|
onPermissionDenied: { self.error = "Bluetooth permission is required for auto-complete. 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 {
|
|
showingChat = true
|
|
} else {
|
|
dismiss()
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private func completeTask() {
|
|
Task {
|
|
do {
|
|
try await APIService.shared.completeTask(taskId: task.taskId)
|
|
dismiss()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
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: - Auto-Complete Countdown
|
|
|
|
struct AutoCompleteCountdownView: View {
|
|
let taskId: Int
|
|
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)
|
|
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))
|
|
}
|
|
}
|