import SwiftUI // MARK: - ScanView (Beacon Provisioning) struct ScanView: View { let businessId: Int let businessName: String var reprovisionServicePoint: ServicePoint? = nil // If set, we're re-provisioning an existing SP var onBack: () -> Void @StateObject private var bleScanner = BLEBeaconScanner() @StateObject private var provisioner = BeaconProvisioner() @State private var namespace: BusinessNamespace? @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 = "" @State private var provisioningError: String? // Action sheet + check config @State private var showBeaconActionSheet = false @State private var showCheckConfigSheet = false @State private var checkConfigData: BeaconCheckResult? @State private var isCheckingConfig = false @State private var checkConfigError: String? // Debug log @State private var showDebugLog = false @ObservedObject private var debugLog = DebugLog.shared 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) } Button { showDebugLog = true } label: { Image(systemName: "ladybug") .font(.caption) .foregroundColor(.secondary) } } .padding() .background(Color(.systemBackground)) // Info bar HStack { Text(businessName) .font(.subheadline) .foregroundColor(.secondary) Spacer() if let sp = reprovisionServicePoint { Text("Re-provision: \(sp.name)") .font(.subheadline.bold()) .foregroundColor(.orange) } else { 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 { selectedBeacon = beacon showBeaconActionSheet = true } } } .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 } .sheet(isPresented: $showCheckConfigSheet) { checkConfigSheet } .sheet(isPresented: $showDebugLog) { debugLogSheet } .confirmationDialog( selectedBeacon?.displayName ?? "Beacon", isPresented: $showBeaconActionSheet, titleVisibility: .visible ) { if let sp = reprovisionServicePoint { Button("Provision for \(sp.name)") { guard selectedBeacon != nil else { return } reprovisionBeacon() } } else { Button("Configure (Assign & Provision)") { guard selectedBeacon != nil else { return } assignName = "Table \(nextTableNumber)" showAssignSheet = true } } Button("Check Current Config") { guard let beacon = selectedBeacon else { return } checkConfig(beacon) } Button("Cancel", role: .cancel) { selectedBeacon = nil } } .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) { if beacon.type != .unknown { Text(beacon.type.rawValue) .font(.caption2.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : 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 : beacon.type == .dxsmart ? Color.orange : 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) } // Provisioning progress if isProvisioning { HStack { ProgressView() Text(provisioningProgress) .font(.callout) } .padding() .frame(maxWidth: .infinity) .background(Color(.systemGray6)) .cornerRadius(8) } // Provisioning error if let error = provisioningError { HStack(alignment: .top) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.red) Text(error) .font(.callout) .foregroundColor(.red) } .padding() .background(Color.red.opacity(0.1)) .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: - Check Config Sheet private var checkConfigSheet: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { if let beacon = selectedBeacon { // Beacon identity VStack(alignment: .leading, spacing: 8) { Text("Beacon") .font(.caption) .foregroundColor(.secondary) HStack { Text(beacon.displayName) .font(.headline) Spacer() if beacon.type != .unknown { Text(beacon.type.rawValue) .font(.caption2.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(beacon.type == .kbeacon ? Color.blue : beacon.type == .dxsmart ? Color.orange : Color.purple) .cornerRadius(4) } } Text("Signal: \(beacon.rssi) dBm") .font(.caption) .foregroundColor(.secondary) } .padding() .background(Color(.systemGray6)) .cornerRadius(8) // Loading state if isCheckingConfig { HStack { ProgressView() Text(provisioner.progress.isEmpty ? "Connecting..." : provisioner.progress) .font(.callout) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemGray6)) .cornerRadius(8) } // Error state if let error = checkConfigError { HStack(alignment: .top) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(error) .font(.callout) } .padding() .background(Color.orange.opacity(0.1)) .cornerRadius(8) } // Parsed config data if let data = checkConfigData { // iBeacon configuration if data.hasConfig { VStack(alignment: .leading, spacing: 10) { Text("iBeacon Configuration") .font(.subheadline.weight(.semibold)) if let uuid = data.uuid { configRow("UUID", uuid) } if let major = data.major { configRow("Major", "\(major)") } if let minor = data.minor { configRow("Minor", "\(minor)") } if let name = data.deviceName { configRow("Name", name) } if let rssi = data.rssiAt1m { configRow("RSSI@1m", "\(rssi) dBm") } if let interval = data.advInterval { configRow("Interval", "\(interval)00 ms") } if let tx = data.txPower { configRow("TX Power", "\(tx)") } } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } // Device info if data.battery != nil || data.macAddress != nil { VStack(alignment: .leading, spacing: 10) { Text("Device Info") .font(.subheadline.weight(.semibold)) if let battery = data.battery { configRow("Battery", "\(battery)%") } if let mac = data.macAddress { configRow("MAC", mac) } if let slots = data.frameSlots { let slotStr = slots.enumerated().map { i, s in "Slot\(i): 0x\(String(format: "%02X", s))" }.joined(separator: " ") configRow("Frames", slotStr) } } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } // No config found if !data.hasConfig && data.battery == nil && data.macAddress == nil && data.rawResponses.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "info.circle") .foregroundColor(.secondary) Text("No DX-Smart config data received") .font(.callout) .foregroundColor(.secondary) } if !data.servicesFound.isEmpty { Text("Services: \(data.servicesFound.joined(separator: ", "))") .font(.caption) .foregroundColor(.secondary) } } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } // Raw responses (debug section) if !data.rawResponses.isEmpty { VStack(alignment: .leading, spacing: 4) { HStack { Text("Raw Responses") .font(.caption) .foregroundColor(.secondary) Spacer() Button { let raw = data.rawResponses.joined(separator: "\n") UIPasteboard.general.string = raw showSnack("Copied to clipboard") } label: { Image(systemName: "doc.on.doc") .font(.caption) } } Text(data.rawResponses.joined(separator: "\n")) .font(.system(.caption2, design: .monospaced)) .textSelection(.enabled) } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } // Services/chars discovery info if !data.characteristicsFound.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("BLE Discovery") .font(.caption) .foregroundColor(.secondary) Text(data.characteristicsFound.joined(separator: "\n")) .font(.system(.caption2, design: .monospaced)) .textSelection(.enabled) } .padding() .background(Color(.systemGray6)) .cornerRadius(8) } } } else { Text("No beacon selected") .foregroundColor(.secondary) } } .padding() } .navigationTitle("Check Config") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showCheckConfigSheet = false selectedBeacon = nil checkConfigData = nil checkConfigError = nil } .disabled(isCheckingConfig) } } } .presentationDetents([.medium, .large]) .interactiveDismissDisabled(isCheckingConfig) } private func configRow(_ label: String, _ value: String) -> some View { HStack(alignment: .top) { Text(label) .font(.caption) .foregroundColor(.secondary) .frame(width: 80, alignment: .leading) Text(value) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) } } // MARK: - Debug Log Sheet private var debugLogSheet: some View { NavigationStack { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(Array(debugLog.entries.enumerated()), id: \.offset) { idx, entry in Text(entry) .font(.system(.caption2, design: .monospaced)) .textSelection(.enabled) .id(idx) } } .padding(.horizontal, 8) .padding(.vertical, 4) } .onAppear { if let last = debugLog.entries.indices.last { proxy.scrollTo(last, anchor: .bottom) } } } .navigationTitle("Debug Log") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showDebugLog = false } } ToolbarItemGroup(placement: .navigationBarTrailing) { Button { UIPasteboard.general.string = debugLog.allText } label: { Image(systemName: "doc.on.doc") } Button { debugLog.clear() } label: { Image(systemName: "trash") } } } } } // 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 { // Load namespace (UUID + Major) and service points in parallel async let nsTask = Api.shared.allocateBusinessNamespace(businessId: businessId) async let spTask = Api.shared.listServicePoints(businessId: businessId) namespace = try await nsTask servicePoints = try await spTask DebugLog.shared.log("[ScanView] Loaded namespace: uuid=\(namespace?.uuid ?? "nil") major=\(namespace?.major ?? 0)") DebugLog.shared.log("[ScanView] Loaded \(servicePoints.count) service points") // 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 { DebugLog.shared.log("[ScanView] loadServicePoints error: \(error)") } // Auto-start scan startScan() } } private func startScan() { guard bleScanner.isBluetoothReady else { showSnack("Bluetooth not available") return } bleScanner.startScanning() } private func checkConfig(_ beacon: DiscoveredBeacon) { isCheckingConfig = true checkConfigError = nil checkConfigData = nil showCheckConfigSheet = true // Stop scanning to avoid BLE interference bleScanner.stopScanning() provisioner.readConfig(beacon: beacon) { data, error in Task { @MainActor in isCheckingConfig = false if let data = data { checkConfigData = data } if let error = error { checkConfigError = error } } } } private func reprovisionBeacon() { guard let beacon = selectedBeacon, let sp = reprovisionServicePoint else { return } guard let ns = namespace else { failProvisioning("Namespace not loaded — go back and try again") return } // Stop scanning bleScanner.stopScanning() isProvisioning = true provisioningProgress = "Preparing..." provisioningError = nil showAssignSheet = true // Reuse assign sheet to show progress assignName = sp.name DebugLog.shared.log("[ScanView] reprovisionBeacon: sp=\(sp.name) spId=\(sp.servicePointId) minor=\(String(describing: sp.beaconMinor)) beacon=\(beacon.displayName)") Task { do { // If SP has no minor, re-fetch to get it var minor = sp.beaconMinor if minor == nil { DebugLog.shared.log("[ScanView] reprovisionBeacon: SP has no minor, re-fetching...") let refreshed = try await Api.shared.listServicePoints(businessId: businessId) minor = refreshed.first(where: { $0.servicePointId == sp.servicePointId })?.beaconMinor } guard let beaconMinor = minor else { failProvisioning("Service point has no beacon minor assigned") return } // Build config from namespace + service point (uuidClean for BLE) let deviceName = "PF-\(sp.name)" let beaconConfig = BeaconConfig( uuid: ns.uuidClean, major: ns.major, minor: beaconMinor, txPower: -59, interval: 350, deviceName: deviceName ) DebugLog.shared.log("[ScanView] reprovisionBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(beaconMinor)") provisioningProgress = "Provisioning beacon..." let hardwareId = beacon.id.uuidString // BLE peripheral identifier provisioner.provision(beacon: beacon, config: beaconConfig) { result in Task { @MainActor in switch result { case .success: do { try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: sp.servicePointId, uuid: ns.uuid, major: ns.major, minor: beaconMinor, hardwareId: hardwareId ) finishProvisioning(name: sp.name) } catch { failProvisioning(error.localizedDescription) } case .failure(let error): failProvisioning(error) } } } } catch { failProvisioning(error.localizedDescription) } } } private func saveBeacon() { guard let beacon = selectedBeacon else { return } let name = assignName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } guard let ns = namespace else { failProvisioning("Namespace not loaded — go back and try again") return } isProvisioning = true provisioningProgress = "Preparing..." provisioningError = nil // Stop scanning to avoid BLE interference bleScanner.stopScanning() DebugLog.shared.log("[ScanView] saveBeacon: name=\(name) beacon=\(beacon.displayName) businessId=\(businessId)") Task { do { // 1. Reuse existing service point if name matches, otherwise create new var servicePoint: ServicePoint if let existing = servicePoints.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) { DebugLog.shared.log("[ScanView] saveBeacon: found existing SP id=\(existing.servicePointId) minor=\(String(describing: existing.beaconMinor))") servicePoint = existing } else { provisioningProgress = "Creating service point..." DebugLog.shared.log("[ScanView] saveBeacon: creating new SP...") servicePoint = try await Api.shared.createServicePoint(businessId: businessId, name: name) DebugLog.shared.log("[ScanView] saveBeacon: created SP id=\(servicePoint.servicePointId) minor=\(String(describing: servicePoint.beaconMinor))") } // If SP has no minor yet, re-fetch service points to get the allocated minor if servicePoint.beaconMinor == nil { DebugLog.shared.log("[ScanView] saveBeacon: SP has no minor, re-fetching service points...") let refreshed = try await Api.shared.listServicePoints(businessId: businessId) if let updated = refreshed.first(where: { $0.servicePointId == servicePoint.servicePointId }) { servicePoint = updated DebugLog.shared.log("[ScanView] saveBeacon: refreshed SP minor=\(String(describing: servicePoint.beaconMinor))") } } guard let minor = servicePoint.beaconMinor else { failProvisioning("Service point has no beacon minor assigned") return } // 2. Build config from namespace + service point (uuidClean = no dashes, for BLE) let deviceName = "PF-\(name)" let beaconConfig = BeaconConfig( uuid: ns.uuidClean, major: ns.major, minor: minor, txPower: -59, interval: 350, deviceName: deviceName ) DebugLog.shared.log("[ScanView] saveBeacon: BLE uuid=\(ns.uuidClean) API uuid=\(ns.uuid) major=\(ns.major) minor=\(minor)") provisioningProgress = "Provisioning beacon..." // 3. Provision the beacon via GATT let hardwareId = beacon.id.uuidString // BLE peripheral identifier provisioner.provision(beacon: beacon, config: beaconConfig) { result in Task { @MainActor in switch result { case .success: // Register in backend (use original UUID from API, not cleaned) do { try await Api.shared.registerBeaconHardware( businessId: businessId, servicePointId: servicePoint.servicePointId, uuid: ns.uuid, major: ns.major, minor: minor, hardwareId: hardwareId ) 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) { DebugLog.shared.log("[ScanView] Provisioning failed: \(error)") isProvisioning = false provisioningProgress = "" provisioningError = 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]))" } }