payfrit-works-ios/PayfritWorks/Views/TaskDetailScreen.swift
2026-02-01 23:38:34 -08:00

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