- DXSmartProvisioner now reports each phase (connecting, discovering services, authenticating, retrying) via onStatusUpdate callback - ScanView shows live diagnostic log during connecting/writing states, not just on failure — so you can see exactly where it stalls - Unexpected BLE disconnects now properly update provisioningState to .failed instead of silently logging - Added cancel button to connecting progress view - "Connected" screen title changed to "Connected — Beacon is Flashing" for clearer status indication Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
931 lines
33 KiB
Swift
931 lines
33 KiB
Swift
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 {
|
|
Task { @MainActor [weak self] in
|
|
let reason = error?.localizedDescription ?? "beacon timed out"
|
|
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
|
|
// If we're still in connecting or connected state, show failure
|
|
if let self = self,
|
|
self.provisioningState == .connecting || self.provisioningState == .connected {
|
|
self.provisioningState = .failed
|
|
self.errorMessage = "Beacon disconnected: \(reason)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try await provisioner.connect()
|
|
provisionLog.log("connect", "Connected and authenticated 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
|
|
}
|
|
}
|
|
}
|
|
|