import SwiftUI // MARK: - Data Types enum BeaconStatus { case new, thisBusiness, otherBusiness, banned } struct EnrichedBeacon: Identifiable { var id: String { uuid } let uuid: String let rssi: Int let samples: Int let status: BeaconStatus let assignedBusinessName: String? let assignedBeaconName: String? let assignedServicePointName: String? let beaconId: Int let banReason: String? } // MARK: - ScanView struct ScanView: View { let businessId: Int let businessName: String var onBack: () -> Void @State private var nextTableNumber: Int = 1 @State private var hasScannedOnce = false @State private var savedUuids: Set = [] @State private var scanResults: [EnrichedBeacon] = [] @State private var knownAssignments: [String: BeaconLookupResult] = [:] @State private var pendingQrMac: String? @State private var pendingQrBeacon: EnrichedBeacon? @State private var isScanning = false @State private var snackMessage: String? @State private var showQrScanner = false @State private var qrScanForBeacon: EnrichedBeacon? // Dialog state @State private var showAssignSheet = false @State private var assignBeacon: EnrichedBeacon? @State private var assignName = "" @State private var showBannedSheet = false @State private var bannedBeacon: EnrichedBeacon? @State private var generatedUuid: String? @State private var showInfoAlert = false @State private var infoBeacon: EnrichedBeacon? @State private var showOptionsAlert = false @State private var optionsBeacon: EnrichedBeacon? @State private var showWipeAlert = false @State private var wipeBeacon: EnrichedBeacon? @State private var showMacLookupAlert = false @State private var macLookupResult: MacLookupResult? @State private var showBeaconPickerAlert = false @State private var pickerMac: String? @State private var showNoBeaconsScanAlert = false @State private var noBeaconsMac: String? @StateObject private var beaconScanner = BeaconScanner() @State private var targetUUIDs: [UUID] = [] // Common fallback UUIDs for beacons that may not be registered yet private static let fallbackUUIDs = [ "E2C56DB5DFFB48D2B060D0F5A71096E0", "B9407F30F5F8466EAFF925556B57FE6D", "F7826DA64FA24E988024BC5B71E0893E", ] var body: some View { VStack(spacing: 0) { // Toolbar HStack { Button(action: onBack) { Image(systemName: "chevron.left") .font(.title3) } Text("Beacon Scanner") .font(.headline) Spacer() } .padding() .background(Color(.systemBackground)) // Info bar HStack { Text(businessName) .font(.subheadline) .foregroundColor(.secondary) Spacer() Text("Next: Table \(nextTableNumber)") .font(.subheadline.bold()) .foregroundColor(.payfritGreen) } .padding(.horizontal) .padding(.vertical, 8) Divider() // Beacon list if scanResults.isEmpty { Spacer() if isScanning || !hasScannedOnce { ProgressView("Scanning for beacons...") } else { Text("No beacons detected nearby.") .foregroundColor(.secondary) } Spacer() } else { ScrollView { LazyVStack(spacing: 8) { ForEach(scanResults) { beacon in beaconRow(beacon) .onTapGesture { handleBeaconTap(beacon) } } } .padding(.horizontal) .padding(.top, 8) } } // Bottom action bar VStack(spacing: 8) { HStack(spacing: 12) { Button(action: requestPermissionsAndScan) { Text(hasScannedOnce ? "Scan Next" : "Scan for Beacons") .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.payfritGreen) .foregroundColor(.white) .cornerRadius(8) } .disabled(isScanning) Button(action: { launchQrScan(forBeacon: nil) }) { Text("QR") .padding(.horizontal, 20) .padding(.vertical, 12) .background(Color(.systemGray5)) .foregroundColor(.primary) .cornerRadius(8) } } Button(action: onBack) { Text("Done") .frame(maxWidth: .infinity) .padding(.vertical, 12) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.payfritGreen, lineWidth: 1) ) .foregroundColor(.payfritGreen) } } .padding() } .modifier(DevBanner()) .overlay(snackOverlay, alignment: .bottom) .onAppear { loadExistingBeacons() } .sheet(isPresented: $showAssignSheet) { assignSheet } .sheet(isPresented: $showBannedSheet) { bannedSheet } .sheet(isPresented: $showQrScanner) { QrScanView { value, type in showQrScanner = false handleQrScanResult(qrResult: value, qrType: type) } } .alert("Beacon Info", isPresented: $showInfoAlert, presenting: infoBeacon) { _ in Button("OK", role: .cancel) {} } message: { beacon in Text(infoMessage(beacon)) } .confirmationDialog("Beacon Options", isPresented: $showOptionsAlert, presenting: optionsBeacon) { beacon in Button("Reassign") { openAssignDialog(beacon) } Button("Wipe", role: .destructive) { wipeBeacon = beacon showWipeAlert = true } Button("Cancel", role: .cancel) {} } message: { beacon in Text(optionsMessage(beacon)) } .alert("Wipe Beacon?", isPresented: $showWipeAlert, presenting: wipeBeacon) { beacon in Button("Wipe", role: .destructive) { performWipe(beacon) } Button("Cancel", role: .cancel) {} } message: { beacon in let beaconName = beacon.assignedBeaconName ?? "unnamed" let spName = beacon.assignedServicePointName ?? beaconName let displayName = beaconName == spName ? beaconName : "\(beaconName) (\(spName))" Text("This will unlink \"\(displayName)\" from its service point. The beacon will appear as NEW on the next scan.") } .alert("MAC Lookup Result", isPresented: $showMacLookupAlert, presenting: macLookupResult) { _ in Button("OK", role: .cancel) {} } message: { result in Text("Beacon: \(result.beaconName)\nBusiness: \(result.businessName)\nService Point: \(result.servicePointName)\nMAC: \(result.macAddress)\nUUID: \(result.uuid.isEmpty ? "Not set" : result.uuid)") } .alert("MAC Scanned", isPresented: $showNoBeaconsScanAlert, presenting: noBeaconsMac) { _ in Button("Scan") { requestPermissionsAndScan() } Button("Cancel", role: .cancel) { pendingQrMac = nil } } message: { mac in Text("Scanned MAC: \(mac)\n\nNo beacons detected yet. Scan for beacons first to link this MAC address.") } .confirmationDialog("Link MAC to Beacon", isPresented: $showBeaconPickerAlert, presenting: pickerMac) { _ in ForEach(Array(scanResults.enumerated()), id: \.element.uuid) { index, beacon in let statusLabel: String = { switch beacon.status { case .new: return "NEW" case .thisBusiness: return beacon.assignedBeaconName ?? "This business" case .otherBusiness: return beacon.assignedBusinessName ?? "Other business" case .banned: return "BANNED" } }() Button("\(statusLabel) (\(beacon.rssi) dBm)") { openAssignDialog(scanResults[index]) } } Button("Cancel", role: .cancel) { pendingQrMac = nil } } message: { mac in Text("Select a beacon to link with MAC \(mac)") } } // MARK: - Beacon Row private func beaconRow(_ beacon: EnrichedBeacon) -> some View { HStack(spacing: 8) { // Signal strength indicator Rectangle() .fill(signalColor(beacon.rssi)) .frame(width: 4) .cornerRadius(2) VStack(alignment: .leading, spacing: 4) { Text(BeaconBanList.formatUuid(beacon.uuid)) .font(.system(.caption, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) statusBadge(beacon) } Spacer() Text("\(beacon.rssi) dBm") .font(.caption) .foregroundColor(.secondary) } .padding(12) .background(Color(.systemBackground)) .cornerRadius(8) .shadow(color: .black.opacity(0.05), radius: 2, y: 1) } private func signalColor(_ rssi: Int) -> Color { if rssi >= -60 { return .signalStrong } if rssi >= -75 { return .signalMedium } return .signalWeak } private func statusBadge(_ beacon: EnrichedBeacon) -> some View { let (text, textColor, bgColor): (String, Color, Color) = { switch beacon.status { case .new: return ("NEW", .successGreen, .newBg) case .thisBusiness: let name = beacon.assignedBeaconName let label = (name != nil && !name!.isEmpty) ? "\(name!) (this business)" : "This business" return (label, .infoBlue, .assignedBg) case .otherBusiness: let label = beacon.assignedBusinessName ?? "Other business" return (label, .warningOrange, .assignedBg) case .banned: return ("BANNED", .errorRed, .bannedBg) } }() return Text(text) .font(.caption2.weight(.medium)) .foregroundColor(textColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background(bgColor) .cornerRadius(4) } // MARK: - Snack Overlay @ViewBuilder private var snackOverlay: some View { if let message = snackMessage { Text(message) .font(.callout) .foregroundColor(.white) .padding() .background(Color(.darkGray)) .cornerRadius(8) .padding(.bottom, 100) .transition(.move(edge: .bottom).combined(with: .opacity)) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { withAnimation { snackMessage = nil } } } } } private func showSnack(_ message: String) { withAnimation { snackMessage = message } } // MARK: - Actions private func handleBeaconTap(_ beacon: EnrichedBeacon) { switch beacon.status { case .new: openAssignDialog(beacon) case .thisBusiness: optionsBeacon = beacon showOptionsAlert = true case .otherBusiness: infoBeacon = beacon showInfoAlert = true case .banned: bannedBeacon = beacon generatedUuid = nil showBannedSheet = true } } private func loadExistingBeacons() { Task { do { let existing = try await Api.shared.listBeacons(businessId: businessId) let maxNumber = existing.compactMap { beacon -> Int? in guard let match = beacon.name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) else { return nil } let numberStr = beacon.name[match].split(separator: " ").last.flatMap { String($0) } ?? "" return Int(numberStr) }.max() ?? 0 nextTableNumber = maxNumber + 1 } catch { // Silently continue — will use default table 1 } requestPermissionsAndScan() } } private func requestPermissionsAndScan() { Task { // Request permission if needed if beaconScanner.authorizationStatus == .notDetermined { beaconScanner.requestPermission() for _ in 0..<20 { try? await Task.sleep(nanoseconds: 500_000_000) if beaconScanner.authorizationStatus != .notDetermined { break } } } guard beaconScanner.hasPermissions() else { showSnack("Location permission denied — required for beacon scanning") return } await startScan() } } private func startScan() async { isScanning = true scanResults = [] // Fetch known beacon UUIDs from server if we haven't yet if targetUUIDs.isEmpty { do { let serverBeacons = try await Api.shared.listAllBeacons() var uuids: [UUID] = [] for (rawUuid, _) in serverBeacons { let formatted = formatUuidString(rawUuid) if let uuid = UUID(uuidString: formatted) { uuids.append(uuid) } } // Add fallback UUIDs for raw in ScanView.fallbackUUIDs { let formatted = formatUuidString(raw) if let uuid = UUID(uuidString: formatted), !uuids.contains(uuid) { uuids.append(uuid) } } targetUUIDs = uuids } catch { // Use fallbacks only targetUUIDs = ScanView.fallbackUUIDs.compactMap { raw in UUID(uuidString: formatUuidString(raw)) } } } guard !targetUUIDs.isEmpty else { isScanning = false showSnack("No beacon UUIDs to scan for") return } // Range for 3 seconds beaconScanner.startRanging(uuids: targetUUIDs) try? await Task.sleep(nanoseconds: 3_000_000_000) let detected = beaconScanner.stopAndCollect() onScanComplete(detected) } private func formatUuidString(_ raw: String) -> String { let clean = raw.replacingOccurrences(of: "-", with: "").uppercased() guard clean.count == 32 else { return raw } if raw.count == 36 { return raw.uppercased() } let chars = Array(clean) return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))" } private func onScanComplete(_ detected: [DetectedBeacon]) { let filtered = detected.filter { !savedUuids.contains($0.uuid) } if filtered.isEmpty { scanResults = [] hasScannedOnce = true isScanning = false return } Task { do { let lookupResults = try await Api.shared.lookupBeacons(uuids: filtered.map { $0.uuid }) knownAssignments.removeAll() for result in lookupResults { knownAssignments[result.uuid] = result } } catch { // Continue without lookup data } scanResults = filtered.map { beacon in let lookup = knownAssignments[beacon.uuid] let isBanned = BeaconBanList.isBanned(beacon.uuid) let banReason = BeaconBanList.getBanReason(beacon.uuid) let status: BeaconStatus if isBanned { status = .banned } else if let lookup = lookup, lookup.businessId == businessId { status = .thisBusiness } else if lookup != nil { status = .otherBusiness } else { status = .new } return EnrichedBeacon( uuid: beacon.uuid, rssi: beacon.rssi, samples: beacon.samples, status: status, assignedBusinessName: lookup?.businessName, assignedBeaconName: lookup?.beaconName, assignedServicePointName: lookup?.servicePointName, beaconId: lookup?.beaconId ?? 0, banReason: banReason ) } hasScannedOnce = true isScanning = false } } // MARK: - Assign Dialog private func openAssignDialog(_ beacon: EnrichedBeacon) { assignBeacon = beacon assignName = "Table \(nextTableNumber)" showAssignSheet = true } private var assignSheet: some View { NavigationStack { if let beacon = assignBeacon { VStack(alignment: .leading, spacing: 16) { Text("UUID: \(BeaconBanList.formatUuid(beacon.uuid))") .font(.system(.caption, design: .monospaced)) Text("RSSI: \(beacon.rssi) dBm") .font(.callout) if let mac = pendingQrMac { HStack { Text("MAC: \(mac)") .font(.callout) Image(systemName: "checkmark.circle.fill") .foregroundColor(.successGreen) } } if beacon.status == .otherBusiness { Text("Warning: This beacon is assigned to \(beacon.assignedBusinessName ?? "another business"). Saving will reassign it.") .font(.callout) .foregroundColor(.warningOrange) } if beacon.status == .banned { Text("Warning: This UUID is a known factory default.\n(\(beacon.banReason ?? "Banned"))") .font(.callout) .foregroundColor(.errorRed) } if beacon.status == .thisBusiness { Text("Currently: \(beacon.assignedBeaconName ?? "unnamed")") .font(.callout) .foregroundColor(.infoBlue) } TextField("Beacon name", text: $assignName) .textFieldStyle(.roundedBorder) .padding(.top, 8) Spacer() } .padding() .navigationTitle("Assign Beacon") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { pendingQrMac = nil showAssignSheet = false } } ToolbarItem(placement: .confirmationAction) { Button("Save") { let name = assignName.trimmingCharacters(in: .whitespacesAndNewlines) if !name.isEmpty { showAssignSheet = false saveBeacon(uuid: beacon.uuid, name: name, macAddress: pendingQrMac) pendingQrMac = nil } } } ToolbarItem(placement: .bottomBar) { Button { showAssignSheet = false launchQrScan(forBeacon: beacon) } label: { Label("Scan QR", systemImage: "qrcode.viewfinder") } } } } } .presentationDetents([.medium]) } private func saveBeacon(uuid: String, name: String, macAddress: String? = nil) { Task { do { let saved = try await Api.shared.saveBeacon( businessId: businessId, name: name, uuid: uuid, macAddress: macAddress ) savedUuids.insert(uuid) // Increment table number if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) { let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? "" if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber { nextTableNumber = savedNumber + 1 } else { nextTableNumber += 1 } } else { nextTableNumber += 1 } scanResults.removeAll { $0.uuid == uuid } showSnack("Saved \"\(name)\"") } catch { showSnack(error.localizedDescription) } } } // MARK: - Banned Dialog private var bannedSheet: some View { NavigationStack { if let beacon = bannedBeacon { VStack(alignment: .leading, spacing: 16) { Text("Current UUID:") .font(.caption) .foregroundColor(.secondary) Text(BeaconBanList.formatUuid(beacon.uuid)) .font(.system(.caption, design: .monospaced)) Text(beacon.banReason ?? "This UUID is a known factory default") .font(.callout) .foregroundColor(.errorRed) Divider() Text("Suggested Replacement:") .font(.caption) .foregroundColor(.secondary) if let uuid = generatedUuid { Text(uuid) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) } else { Text("Tap Generate to create a new UUID") .font(.callout) .foregroundColor(.secondary) } HStack(spacing: 12) { Button { generatedUuid = UUID().uuidString.uppercased() } label: { Label("Generate", systemImage: "arrow.triangle.2.circlepath") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) Button { if let uuid = generatedUuid { UIPasteboard.general.string = uuid showSnack("UUID copied to clipboard") } } label: { Label("Copy", systemImage: "doc.on.doc") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .disabled(generatedUuid == nil) } .padding(.top, 8) Spacer() } .padding() .navigationTitle("Banned UUID") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showBannedSheet = false } } } } } .presentationDetents([.medium]) } // MARK: - Info / Options Messages private func infoMessage(_ beacon: EnrichedBeacon) -> String { let formattedUuid = BeaconBanList.formatUuid(beacon.uuid) return "UUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm\nName: \(beacon.assignedBeaconName ?? "unnamed")\n\nThis beacon is registered to \(beacon.assignedBusinessName ?? "another") business and cannot be reassigned from here." } private func optionsMessage(_ beacon: EnrichedBeacon) -> String { let formattedUuid = BeaconBanList.formatUuid(beacon.uuid) let beaconName = beacon.assignedBeaconName ?? "unnamed" let spName = beacon.assignedServicePointName ?? beaconName return "Beacon: \(beaconName)\nService Point: \(spName)\nUUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm" } // MARK: - Wipe private func performWipe(_ beacon: EnrichedBeacon) { guard beacon.beaconId > 0 else { showSnack("Cannot wipe: invalid beacon ID") return } Task { do { try await Api.shared.wipeBeacon(businessId: businessId, beaconId: beacon.beaconId) savedUuids.remove(beacon.uuid) scanResults.removeAll { $0.uuid == beacon.uuid } showSnack("Beacon wiped") } catch { showSnack(error.localizedDescription) } } } // MARK: - QR Scan private func launchQrScan(forBeacon: EnrichedBeacon?) { pendingQrBeacon = forBeacon showQrScanner = true } private func handleQrScanResult(qrResult: String?, qrType: String?) { guard let qrResult = qrResult, !qrResult.isEmpty else { return } switch qrType { case QrScanView.TYPE_MAC: if let beacon = pendingQrBeacon { pendingQrMac = qrResult showSnack("MAC scanned: \(qrResult)") openAssignDialog(beacon) } else { lookupByMac(qrResult) } case QrScanView.TYPE_UUID: let matchingBeacon = scanResults.first { $0.uuid.lowercased() == qrResult.replacingOccurrences(of: "-", with: "").lowercased() } if let beacon = matchingBeacon { showSnack("UUID found in scan results") openAssignDialog(beacon) } else { showSnack("UUID not found in nearby beacons") } default: showSnack("Unknown QR format: \(qrResult)") } pendingQrBeacon = nil } private func lookupByMac(_ macAddress: String) { Task { do { if let result = try await Api.shared.lookupByMac(macAddress: macAddress) { macLookupResult = result showMacLookupAlert = true } else { pendingQrMac = macAddress showBeaconPickerForMac(macAddress) } } catch { showSnack(error.localizedDescription) } } } private func showBeaconPickerForMac(_ macAddress: String) { if scanResults.isEmpty { noBeaconsMac = macAddress showNoBeaconsScanAlert = true return } pickerMac = macAddress showBeaconPickerAlert = true } }