import SwiftUI // MARK: - ScanView (Beacon Provisioning) struct ScanView: View { let businessId: Int let businessName: String var onBack: () -> Void @StateObject private var bleScanner = BLEBeaconScanner() @StateObject private var provisioner = BeaconProvisioner() @State private var servicePoints: [ServicePoint] = [] @State private var nextTableNumber: Int = 1 @State private var provisionedCount: Int = 0 // UI State @State private var snackMessage: String? @State private var showAssignSheet = false @State private var selectedBeacon: DiscoveredBeacon? @State private var assignName = "" @State private var isProvisioning = false @State private var provisioningProgress = "" var body: some View { VStack(spacing: 0) { // Toolbar HStack { Button(action: onBack) { Image(systemName: "chevron.left") .font(.title3) } Text("Beacon Setup") .font(.headline) Spacer() if provisionedCount > 0 { Text("\(provisionedCount) done") .font(.caption) .foregroundColor(.payfritGreen) } } .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() // Bluetooth status if bleScanner.bluetoothState != .poweredOn { bluetoothWarning } // Beacon list if bleScanner.discoveredBeacons.isEmpty { Spacer() if bleScanner.isScanning { VStack(spacing: 12) { ProgressView() Text("Scanning for beacons...") .foregroundColor(.secondary) } } else { VStack(spacing: 12) { Image(systemName: "antenna.radiowaves.left.and.right") .font(.largeTitle) .foregroundColor(.secondary) Text("No beacons found") .foregroundColor(.secondary) Text("Make sure beacons are powered on\nand in configuration mode") .font(.caption) .foregroundColor(.secondary) .multilineTextAlignment(.center) } } Spacer() } else { ScrollView { LazyVStack(spacing: 8) { ForEach(bleScanner.discoveredBeacons) { beacon in beaconRow(beacon) .onTapGesture { selectBeacon(beacon) } } } .padding(.horizontal) .padding(.top, 8) } } // Bottom action bar VStack(spacing: 8) { Button(action: startScan) { HStack { if bleScanner.isScanning { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) .scaleEffect(0.8) } Text(bleScanner.isScanning ? "Scanning..." : "Scan for Beacons") } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(bleScanner.isScanning ? Color.gray : Color.payfritGreen) .foregroundColor(.white) .cornerRadius(8) } .disabled(bleScanner.isScanning || bleScanner.bluetoothState != .poweredOn) 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) .sheet(isPresented: $showAssignSheet) { assignSheet } .onAppear { loadServicePoints() } } // MARK: - Bluetooth Warning private var bluetoothWarning: some View { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.warningOrange) Text(bluetoothMessage) .font(.caption) Spacer() } .padding() .background(Color.warningOrange.opacity(0.1)) } private var bluetoothMessage: String { switch bleScanner.bluetoothState { case .poweredOff: return "Bluetooth is turned off" case .unauthorized: return "Bluetooth permission denied" case .unsupported: return "Bluetooth not supported" default: return "Bluetooth not ready" } } // MARK: - Beacon Row private func beaconRow(_ beacon: DiscoveredBeacon) -> some View { HStack(spacing: 12) { // Signal strength indicator Rectangle() .fill(signalColor(beacon.rssi)) .frame(width: 4) .cornerRadius(2) VStack(alignment: .leading, spacing: 4) { Text(beacon.displayName) .font(.system(.body, design: .default)) .lineLimit(1) HStack(spacing: 8) { Text(beacon.type.rawValue) .font(.caption2.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(beacon.type == .kbeacon ? Color.blue : Color.purple) .cornerRadius(4) Text("\(beacon.rssi) dBm") .font(.caption) .foregroundColor(.secondary) } } Spacer() Image(systemName: "chevron.right") .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 } // MARK: - Assignment Sheet private var assignSheet: some View { NavigationStack { VStack(alignment: .leading, spacing: 16) { if let beacon = selectedBeacon { // Beacon info VStack(alignment: .leading, spacing: 8) { Text("Beacon") .font(.caption) .foregroundColor(.secondary) HStack { Text(beacon.displayName) .font(.headline) Spacer() Text(beacon.type.rawValue) .font(.caption2.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(beacon.type == .kbeacon ? Color.blue : Color.purple) .cornerRadius(4) } Text("Signal: \(beacon.rssi) dBm") .font(.caption) .foregroundColor(.secondary) } .padding() .background(Color(.systemGray6)) .cornerRadius(8) // Service point name VStack(alignment: .leading, spacing: 8) { Text("Service Point Name") .font(.caption) .foregroundColor(.secondary) TextField("e.g., Table 1", text: $assignName) .textFieldStyle(.roundedBorder) .font(.title3) } // KBeacon warning if beacon.type == .kbeacon { HStack { Image(systemName: "info.circle.fill") .foregroundColor(.infoBlue) Text("KBeacon requires their app for provisioning. Config will be copied to clipboard.") .font(.caption) } .padding() .background(Color.infoBlue.opacity(0.1)) .cornerRadius(8) } // Provisioning progress if isProvisioning { HStack { ProgressView() Text(provisioningProgress) .font(.callout) } .padding() .frame(maxWidth: .infinity) .background(Color(.systemGray6)) .cornerRadius(8) } Spacer() } } .padding() .navigationTitle("Assign Beacon") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showAssignSheet = false selectedBeacon = nil } .disabled(isProvisioning) } ToolbarItem(placement: .confirmationAction) { Button("Save Beacon") { saveBeacon() } .disabled(assignName.trimmingCharacters(in: .whitespaces).isEmpty || isProvisioning) } } } .presentationDetents([.medium]) .interactiveDismissDisabled(isProvisioning) } // 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 loadServicePoints() { Task { do { servicePoints = try await Api.shared.listServicePoints(businessId: businessId) // Find next table number let maxNumber = servicePoints.compactMap { sp -> Int? in guard let match = sp.name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) else { return nil } let numberStr = sp.name[match].split(separator: " ").last.flatMap { String($0) } ?? "" return Int(numberStr) }.max() ?? 0 nextTableNumber = maxNumber + 1 } catch { // Silently continue } // Auto-start scan startScan() } } private func startScan() { guard bleScanner.isBluetoothReady else { showSnack("Bluetooth not available") return } bleScanner.startScanning() } private func selectBeacon(_ beacon: DiscoveredBeacon) { selectedBeacon = beacon assignName = "Table \(nextTableNumber)" showAssignSheet = true } private func saveBeacon() { guard let beacon = selectedBeacon else { return } let name = assignName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } isProvisioning = true provisioningProgress = "Creating service point..." Task { do { // 1. Create or get service point let servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) provisioningProgress = "Getting beacon config..." // 2. Get beacon config from backend let config = try await Api.shared.getBeaconConfig(businessId: businessId, servicePointId: servicePoint.servicePointId) provisioningProgress = "Provisioning beacon..." // 3. Provision the beacon let beaconConfig = BeaconConfig( uuid: config.uuid, major: config.major, minor: config.minor, txPower: Int8(config.txPower), interval: UInt16(config.interval) ) if beacon.type == .kbeacon { // Copy config to clipboard for KBeacon app let clipboardText = """ UUID: \(formatUuidWithDashes(config.uuid)) Major: \(config.major) Minor: \(config.minor) TxPower: \(config.txPower) """ UIPasteboard.general.string = clipboardText showSnack("Config copied! Use KBeacon app to program.") // Register in backend try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: servicePoint.servicePointId, uuid: config.uuid, major: config.major, minor: config.minor, macAddress: nil ) finishProvisioning(name: name) } else { // BlueCharm - provision directly via GATT provisioner.provision(beacon: beacon, config: beaconConfig) { result in Task { @MainActor in switch result { case .success: // Register in backend do { try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: servicePoint.servicePointId, uuid: config.uuid, major: config.major, minor: config.minor, macAddress: nil ) finishProvisioning(name: name) } catch { failProvisioning(error.localizedDescription) } case .failure(let error): failProvisioning(error) } } } } } catch { failProvisioning(error.localizedDescription) } } } private func finishProvisioning(name: String) { isProvisioning = false provisioningProgress = "" showAssignSheet = false selectedBeacon = nil // Update 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 } provisionedCount += 1 showSnack("Saved \"\(name)\"") // Remove beacon from list if let beacon = selectedBeacon { bleScanner.discoveredBeacons.removeAll { $0.id == beacon.id } } } private func failProvisioning(_ error: String) { isProvisioning = false provisioningProgress = "" showSnack("Error: \(error)") } private func formatUuidWithDashes(_ raw: String) -> String { let clean = raw.replacingOccurrences(of: "-", with: "").uppercased() guard clean.count == 32 else { return raw } 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]))" } }