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("Beacon is Flashing") .font(.title2.bold()) Text("Confirm the beacon LED is flashing, then tap Write Config to program it.") .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) 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) Spacer() } } 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 { VStack(alignment: .leading, spacing: 4) { HStack { Text("Diagnostic Log (\(provisionLog.elapsed))") .font(.caption.bold()) Spacer() ShareLink(item: provisionLog.fullText) { Label("Share", systemImage: "square.and.arrow.up") .font(.caption) } } .padding(.horizontal, 16) 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) } } .padding(.horizontal, 16) } .frame(maxHeight: 200) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 16) } } 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 = "Connecting to \(beacon.displayName)…" 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) statusMessage = "Authenticating with \(beacon.type.rawValue)…" 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 { Task { @MainActor in provisionLog?.log("disconnect", "Unexpected disconnect: \(error?.localizedDescription ?? "no error")", isError: true) } } } try await provisioner.connect() provisionLog.log("connect", "Connected successfully") // DXSmart: stop at connected state, wait for user to confirm flashing if beacon.type == .dxsmart { provisioningState = .connected // Store config and provisioner for later use pendingConfig = config pendingProvisioner = provisioner return } // KBeacon / BlueCharm: write immediately provisioningState = .writing statusMessage = "Writing \(config.formattedUUID.prefix(8))… Major:\(config.major) Minor:\(config.minor)" provisionLog.log("write", "Writing config…") try await provisioner.writeConfig(config) provisionLog.log("write", "Config written — disconnecting") provisioner.disconnect() // Register with backend try await APIClient.shared.registerBeaconHardware( businessId: business.id, servicePointId: sp.id, uuid: ns.uuid, major: ns.major, minor: minor, macAddress: nil, beaconType: beacon.type.rawValue, token: token ) // Verify broadcast provisioningState = .verifying statusMessage = "Waiting for beacon to restart…" try await Task.sleep(nanoseconds: UInt64(GATTConstants.postFlashDelay * 1_000_000_000)) statusMessage = "Scanning for broadcast…" let verifyResult = await bleManager.verifyBroadcast( uuid: ns.uuid, major: config.major, minor: config.minor ) if verifyResult.found { try await APIClient.shared.verifyBeaconBroadcast( uuid: ns.uuid, major: ns.major, minor: minor, token: token ) } provisioningState = .done statusMessage = "\(sp.name) — \(beacon.type.rawValue)\nUUID: \(config.formattedUUID.prefix(13))…\nMajor: \(config.major) Minor: \(config.minor)" } 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 { 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 switch beacon.type { case .kbeacon: provisioner = KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) case .dxsmart: provisioner = DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) case .bluecharm: provisioner = BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager) case .unknown: provisioner = FallbackProvisioner(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 { switch beacon.type { case .kbeacon: return .payfritGreen case .dxsmart: return .warningOrange case .bluecharm: return .infoBlue case .unknown: return .gray } } } // 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 } } }