import SwiftUI import CoreBluetooth /// Main provisioning screen (matches Android ScanActivity) /// Flow: Select/create service point → Scan for beacons → Connect → Provision → Verify struct ScanView: View { @EnvironmentObject var appState: AppState let business: Business @StateObject private var bleManager = BLEManager() // MARK: - State @State private var servicePoints: [ServicePoint] = [] @State private var selectedServicePoint: ServicePoint? @State private var showCreateServicePoint = false @State private var newServicePointName = "" @State private var namespace: (uuid: String, major: Int)? @State private var isLoadingNamespace = false // Provisioning flow @State private var selectedBeacon: DiscoveredBeacon? @State private var provisioningState: ProvisioningState = .idle @State private var statusMessage = "" @State private var errorMessage: String? @State private var showQRScanner = false @State private var scannedMAC: String? @StateObject private var provisionLog = ProvisionLog() enum ProvisioningState { case idle case scanning case connecting case connected // DXSmart: beacon flashing, waiting for user to tap "Write" case writing case verifying case done case failed } var body: some View { NavigationStack { VStack(spacing: 0) { // Service Point Picker servicePointSection Divider() // Beacon Scanner / Provisioning if selectedServicePoint != nil { if namespace != nil { beaconSection } else { namespaceLoadingView } } else { selectServicePointPrompt } } .navigationTitle(business.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Back") { appState.backToBusinessList() } } } .sheet(isPresented: $showCreateServicePoint) { createServicePointSheet } .sheet(isPresented: $showQRScanner) { QRScannerView { code in handleQRScan(code) } } } .task { await loadServicePoints() await loadNamespace() } } // MARK: - Service Point Section private var servicePointSection: some View { VStack(spacing: 12) { HStack { Text("Service Point") .font(.headline) Spacer() Button { suggestServicePointName() showCreateServicePoint = true } label: { Label("Add", systemImage: "plus") .font(.subheadline) } } if servicePoints.isEmpty { Text("No service points — create one to get started") .font(.caption) .foregroundStyle(.secondary) } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(servicePoints) { sp in Button { selectedServicePoint = sp resetProvisioningState() } label: { Text(sp.name) .font(.subheadline.weight(selectedServicePoint?.id == sp.id ? .semibold : .regular)) .padding(.horizontal, 14) .padding(.vertical, 8) .background( selectedServicePoint?.id == sp.id ? Color.payfritGreen : Color(.systemGray5), in: Capsule() ) .foregroundStyle(selectedServicePoint?.id == sp.id ? .white : .primary) } } } } } } .padding() } @ViewBuilder private var selectServicePointPrompt: some View { if #available(iOS 17.0, *) { ContentUnavailableView( "Select a Service Point", systemImage: "mappin.and.ellipse", description: Text("Choose or create a service point (table) to provision a beacon for.") ) } else { VStack(spacing: 12) { Image(systemName: "mappin.and.ellipse") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("Select a Service Point") .font(.headline) Text("Choose or create a service point (table) to provision a beacon for.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxHeight: .infinity) .padding() } } // MARK: - Namespace Loading @ViewBuilder private var namespaceLoadingView: some View { VStack(spacing: 16) { if isLoadingNamespace { ProgressView("Allocating beacon namespace…") } else { if #available(iOS 17.0, *) { ContentUnavailableView { Label("Namespace Error", systemImage: "exclamationmark.triangle") } description: { Text(errorMessage ?? "Failed to allocate beacon namespace") } actions: { Button("Retry") { Task { await loadNamespace() } } } } else { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 48)) .foregroundStyle(Color.warningOrange) Text("Namespace Error") .font(.headline) Text(errorMessage ?? "Failed to allocate beacon namespace") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Retry") { Task { await loadNamespace() } } .buttonStyle(.borderedProminent) } .padding() } } } .frame(maxHeight: .infinity) } // MARK: - Beacon Scanner Section private var beaconSection: some View { VStack(spacing: 0) { // Status bar statusBar // Beacon list or provisioning progress switch provisioningState { case .idle, .scanning: beaconList case .connecting: progressView(title: "Connecting…", message: statusMessage) case .connected: // DXSmart: beacon is flashing, show write button dxsmartConnectedView case .writing: progressView(title: "Writing Config…", message: statusMessage) case .verifying: progressView(title: "Verifying…", message: statusMessage) case .done: successView case .failed: failedView } } } private var statusBar: some View { HStack { if let ns = namespace { VStack(alignment: .leading, spacing: 2) { Text("Shard: \(ns.uuid.prefix(8))… / Major: \(ns.major)") .font(.caption2.monospaced()) .foregroundStyle(.secondary) } } Spacer() if bleManager.bluetoothState != .poweredOn { Label("Bluetooth Off", systemImage: "bluetooth.slash") .font(.caption) .foregroundStyle(Color.errorRed) } } .padding(.horizontal) .padding(.vertical, 8) .background(.ultraThinMaterial) } // MARK: - Beacon List private var beaconList: some View { VStack(spacing: 0) { // Scan buttons HStack(spacing: 12) { Button { Task { await startScan() } } label: { HStack { Image(systemName: bleManager.isScanning ? "antenna.radiowaves.left.and.right" : "magnifyingglass") Text(bleManager.isScanning ? "Scanning…" : "BLE Scan") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .disabled(bleManager.isScanning || bleManager.bluetoothState != .poweredOn) Button { showQRScanner = true } label: { HStack { Image(systemName: "qrcode.viewfinder") Text("QR Scan") } .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } .padding() if bleManager.discoveredBeacons.isEmpty && !bleManager.isScanning { if #available(iOS 17.0, *) { ContentUnavailableView( "No Beacons Found", systemImage: "antenna.radiowaves.left.and.right.slash", description: Text("Tap Scan to search for nearby beacons") ) } else { VStack(spacing: 12) { Image(systemName: "antenna.radiowaves.left.and.right.slash") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("No Beacons Found") .font(.headline) Text("Tap Scan to search for nearby beacons") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxHeight: .infinity) .padding() } } else { List(bleManager.discoveredBeacons.sorted { $0.rssi > $1.rssi }) { beacon in Button { selectedBeacon = beacon Task { await startProvisioning(beacon) } } label: { BeaconRow(beacon: beacon) } .disabled(provisioningState != .idle) } .listStyle(.plain) } } } // MARK: - DXSmart Connected View private var dxsmartConnectedView: some View { VStack(spacing: 24) { Spacer() Image(systemName: "light.beacon.max") .font(.system(size: 64)) .foregroundStyle(Color.payfritGreen) .modifier(PulseEffectModifier()) Text("Connected — Beacon is Flashing") .font(.title2.bold()) Text("Confirm the beacon LED is flashing, then tap Write Config to program it.\n\nThe beacon will timeout if you wait too long.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) Button { Task { await writeConfigToConnectedBeacon() } } label: { HStack { Image(systemName: "arrow.down.doc") Text("Write Config") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(Color.payfritGreen) .controlSize(.large) .padding(.horizontal, 32) Button("Cancel") { cancelProvisioning() } .foregroundStyle(.secondary) // Show diagnostic log if !provisionLog.entries.isEmpty { diagnosticLogView } Spacer() } } // MARK: - Progress / Success / Failed Views private func progressView(title: String, message: String) -> some View { VStack(spacing: 16) { Spacer() ProgressView() .scaleEffect(1.5) Text(title) .font(.headline) Text(message) .font(.subheadline) .foregroundStyle(.secondary) // Show live diagnostic log during connecting/writing if !provisionLog.entries.isEmpty { diagnosticLogView } Spacer() Button("Cancel") { cancelProvisioning() } .foregroundStyle(.secondary) .padding(.bottom, 16) } } /// Reusable diagnostic log view private var diagnosticLogView: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text("Log (\(provisionLog.elapsed))") .font(.caption.bold()) Spacer() ShareLink(item: provisionLog.fullText) { Label("Share", systemImage: "square.and.arrow.up") .font(.caption) } } .padding(.horizontal, 16) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { ForEach(provisionLog.entries) { entry in Text(entry.formatted) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(entry.isError ? Color.errorRed : .primary) .id(entry.id) } } .padding(.horizontal, 16) } .onChange(of: provisionLog.entries.count) { _ in if let last = provisionLog.entries.last { proxy.scrollTo(last.id, anchor: .bottom) } } } .frame(maxHeight: 160) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 16) } } private var successView: some View { VStack(spacing: 24) { Spacer() Image(systemName: "checkmark.circle.fill") .font(.system(size: 64)) .foregroundStyle(Color.successGreen) Text("Beacon Provisioned!") .font(.title2.bold()) Text(statusMessage) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) Button("Provision Another") { resetProvisioningState() } .buttonStyle(.borderedProminent) Spacer() } } private var failedView: some View { VStack(spacing: 16) { Image(systemName: "xmark.circle.fill") .font(.system(size: 48)) .foregroundStyle(Color.errorRed) Text("Provisioning Failed") .font(.title2.bold()) Text(errorMessage ?? "Unknown error") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 32) // Diagnostic log if !provisionLog.entries.isEmpty { diagnosticLogView } HStack(spacing: 16) { Button("Try Again") { if let beacon = selectedBeacon { Task { await startProvisioning(beacon) } } } .buttonStyle(.borderedProminent) Button("Register Anyway") { Task { await registerAnywayAfterFailure() } } .buttonStyle(.bordered) } Button("Cancel") { resetProvisioningState() } .foregroundStyle(.secondary) } .padding(.vertical, 8) } // MARK: - Create Service Point Sheet private var createServicePointSheet: some View { NavigationStack { Form { TextField("Name (e.g. Table 1)", text: $newServicePointName) } .navigationTitle("New Service Point") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { showCreateServicePoint = false } } ToolbarItem(placement: .confirmationAction) { Button("Create") { Task { await createServicePoint() } } .disabled(newServicePointName.trimmingCharacters(in: .whitespaces).isEmpty) } } } .presentationDetents([.medium]) } // MARK: - Actions private func loadServicePoints() async { guard let token = appState.token else { return } do { servicePoints = try await APIClient.shared.listServicePoints( businessId: business.id, token: token ) } catch { errorMessage = error.localizedDescription } } private func loadNamespace() async { guard let token = appState.token else { return } isLoadingNamespace = true do { let ns = try await APIClient.shared.allocateBusinessNamespace( businessId: business.id, token: token ) namespace = ns } catch { errorMessage = error.localizedDescription } isLoadingNamespace = false } private func createServicePoint() async { guard let token = appState.token else { return } let name = newServicePointName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } do { let sp = try await APIClient.shared.createServicePoint( name: name, businessId: business.id, token: token ) servicePoints.append(sp) selectedServicePoint = sp showCreateServicePoint = false newServicePointName = "" } catch { errorMessage = error.localizedDescription } } /// Smart-increment: parse existing names, suggest "Table N+1" private func suggestServicePointName() { let numbers = servicePoints.compactMap { sp -> Int? in let parts = sp.name.split(separator: " ") guard parts.count >= 2, parts[0].lowercased() == "table" else { return nil } return Int(parts[1]) } let next = (numbers.max() ?? 0) + 1 newServicePointName = "Table \(next)" } private func startScan() async { provisioningState = .scanning let _ = await bleManager.scan() if provisioningState == .scanning { provisioningState = .idle } } private func startProvisioning(_ beacon: DiscoveredBeacon) async { guard let sp = selectedServicePoint, let ns = namespace, let token = appState.token else { return } provisioningState = .connecting statusMessage = "Allocating beacon config…" errorMessage = nil provisionLog.reset() provisionLog.log("init", "Starting provisioning: \(beacon.displayName) (\(beacon.type.rawValue)) RSSI:\(beacon.rssi)") provisionLog.log("init", "Service point: \(sp.name), Business: \(business.name)") do { // Allocate minor for this service point provisionLog.log("api", "Allocating minor for service point \(sp.id)…") let minor = try await APIClient.shared.allocateMinor( businessId: business.id, servicePointId: sp.id, token: token ) provisionLog.log("api", "Minor allocated: \(minor)") let config = BeaconConfig( uuid: ns.uuid.normalizedUUID, major: UInt16(ns.major), minor: UInt16(minor), measuredPower: BeaconConfig.defaultMeasuredPower, advInterval: BeaconConfig.defaultAdvInterval, txPower: BeaconConfig.defaultTxPower, servicePointName: sp.name, businessName: business.name ) // Create appropriate provisioner let provisioner = makeProvisioner(for: beacon) // Wire up real-time status updates from provisioner if let dxProvisioner = provisioner as? DXSmartProvisioner { dxProvisioner.onStatusUpdate = { [weak self] status in self?.statusMessage = status } } statusMessage = "Connecting to \(beacon.displayName)…" provisionLog.log("connect", "Connecting to \(beacon.type.rawValue) provisioner…") // Monitor for unexpected disconnects during provisioning bleManager.onPeripheralDisconnected = { [weak provisionLog] peripheral, error in if peripheral.identifier == beacon.peripheral.identifier { DispatchQueue.main.async { [weak self] in let reason = error?.localizedDescription ?? "beacon timed out" provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true) guard let self = self else { return } // CP-28: disconnect during .connected is expected — beacon keeps // flashing after BLE drops. We'll reconnect when user taps Write Config. if self.provisioningState == .connected { provisionLog?.log("disconnect", "CP-28 idle disconnect — beacon still flashing, ignoring") return } // For all other active states, treat disconnect as failure if self.provisioningState == .connecting || self.provisioningState == .connected || self.provisioningState == .writing || self.provisioningState == .verifying { self.provisioningState = .failed self.errorMessage = "Beacon disconnected: \(reason)" } } } } try await provisioner.connect() provisionLog.log("connect", "Connected and authenticated successfully") // CP-28: stop at connected state, wait for user to confirm flashing provisioningState = .connected pendingConfig = config pendingProvisioner = provisioner } catch { provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true) provisioningState = .failed errorMessage = error.localizedDescription } } // Store for DXSmart two-phase flow @State private var pendingConfig: BeaconConfig? @State private var pendingProvisioner: (any BeaconProvisioner)? private func writeConfigToConnectedBeacon() async { guard let config = pendingConfig, let provisioner = pendingProvisioner, let sp = selectedServicePoint, let ns = namespace, let token = appState.token else { return } provisioningState = .writing statusMessage = "Writing config to DX-Smart…" do { // Reconnect if the beacon dropped BLE during the "confirm flashing" wait if !provisioner.isConnected { provisionLog.log("write", "Beacon disconnected while waiting — reconnecting…") statusMessage = "Reconnecting to beacon…" try await provisioner.connect() provisionLog.log("write", "Reconnected — writing config…") statusMessage = "Writing config to DX-Smart…" } try await provisioner.writeConfig(config) provisioner.disconnect() try await APIClient.shared.registerBeaconHardware( businessId: business.id, servicePointId: sp.id, uuid: ns.uuid, major: ns.major, minor: Int(config.minor), macAddress: nil, beaconType: BeaconType.dxsmart.rawValue, token: token ) provisioningState = .done statusMessage = "\(sp.name) — DX-Smart\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" } catch { provisioningState = .failed errorMessage = error.localizedDescription } pendingConfig = nil pendingProvisioner = nil } private func registerAnywayAfterFailure() async { guard let sp = selectedServicePoint, let ns = namespace, let config = pendingConfig ?? makeCurrentConfig(), let token = appState.token else { resetProvisioningState() return } do { try await APIClient.shared.registerBeaconHardware( businessId: business.id, servicePointId: sp.id, uuid: ns.uuid, major: ns.major, minor: Int(config.minor), macAddress: nil, beaconType: selectedBeacon?.type.rawValue ?? "Unknown", token: token ) provisioningState = .done statusMessage = "Registered (broadcast unverified)\n\(sp.name) — Minor: \(config.minor)" } catch { errorMessage = "Registration failed: \(error.localizedDescription)" } } private func makeCurrentConfig() -> BeaconConfig? { // Only used for "Register Anyway" fallback return nil } private func cancelProvisioning() { pendingProvisioner?.disconnect() pendingProvisioner = nil pendingConfig = nil resetProvisioningState() } /// Handle QR/barcode scan result — could be MAC address, UUID, or other identifier private func handleQRScan(_ code: String) { let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines) // Check if it looks like a MAC address (AA:BB:CC:DD:EE:FF or AABBCCDDEEFF) let macPattern = #"^([0-9A-Fa-f]{2}[:-]?){5}[0-9A-Fa-f]{2}$"# if cleaned.range(of: macPattern, options: .regularExpression) != nil { scannedMAC = cleaned.replacingOccurrences(of: "-", with: ":").uppercased() statusMessage = "Scanned MAC: \(scannedMAC ?? cleaned)" // Look up the beacon by MAC Task { await lookupScannedMAC() } return } // Check if it looks like a UUID let uuidPattern = #"^[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}$"# if cleaned.range(of: uuidPattern, options: .regularExpression) != nil { statusMessage = "Scanned UUID: \(cleaned)" return } // Generic code — just show it statusMessage = "Scanned: \(cleaned)" } private func lookupScannedMAC() async { guard let mac = scannedMAC, let token = appState.token else { return } do { if let existing = try await APIClient.shared.lookupByMac(macAddress: mac, token: token) { let beaconType = existing.beaconType ?? existing.BeaconType ?? "Unknown" statusMessage = "MAC \(mac) — already registered as \(beaconType)" } else { statusMessage = "MAC \(mac) — not yet registered. Scan BLE to find and provision." } } catch { statusMessage = "MAC \(mac) — lookup failed: \(error.localizedDescription)" } } private func resetProvisioningState() { provisioningState = .idle statusMessage = "" errorMessage = nil selectedBeacon = nil pendingConfig = nil pendingProvisioner = nil scannedMAC = nil } private func makeProvisioner(for beacon: DiscoveredBeacon) -> any BeaconProvisioner { var provisioner: any BeaconProvisioner = DXSmartProvisioner( peripheral: beacon.peripheral, centralManager: bleManager.centralManager ) provisioner.bleManager = bleManager provisioner.diagnosticLog = provisionLog return provisioner } } // MARK: - Beacon Row struct BeaconRow: View { let beacon: DiscoveredBeacon var body: some View { HStack(spacing: 12) { // Signal strength indicator Image(systemName: signalIcon) .font(.title2) .foregroundStyle(signalColor) .frame(width: 32) VStack(alignment: .leading, spacing: 4) { Text(beacon.displayName) .font(.headline) HStack(spacing: 8) { Text(beacon.type.rawValue) .font(.caption.bold()) .padding(.horizontal, 6) .padding(.vertical, 2) .background(typeColor.opacity(0.15), in: Capsule()) .foregroundStyle(typeColor) Text("\(beacon.rssi) dBm") .font(.caption) .foregroundStyle(.secondary) Text(beacon.signalDescription) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.tertiary) } .padding(.vertical, 4) } private var signalIcon: String { switch beacon.rssi { case -50...0: return "wifi" case -65 ... -51: return "wifi" case -80 ... -66: return "wifi.exclamationmark" default: return "wifi.slash" } } private var signalColor: Color { switch beacon.rssi { case -50...0: return .signalStrong case -65 ... -51: return .payfritGreen case -80 ... -66: return .signalMedium default: return .signalWeak } } private var typeColor: Color { return .payfritGreen } } // MARK: - iOS 16/17 Compatibility /// Applies `.symbolEffect(.pulse)` on iOS 17+, no-op on iOS 16 private struct PulseEffectModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 17.0, *) { content.symbolEffect(.pulse) } else { content } } }