payfrit-beacon-ios/PayfritBeacon/Views/ScanView.swift
Schwifty 2306c10d32 fix: sort discovered beacons by RSSI (closest first)
Sort the beacon list so strongest signal (closest beacon) appears at the
top. Sorting happens both in BLEManager as beacons are discovered and in
the ScanView list rendering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:03:18 +00:00

833 lines
28 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?
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: 24) {
Spacer()
Image(systemName: "xmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(Color.errorRed)
Text("Provisioning Failed")
.font(.title2.bold())
Text(errorMessage ?? "Unknown error")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
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)
Spacer()
}
}
// 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
do {
// Allocate minor for this service point
let minor = try await APIClient.shared.allocateMinor(
businessId: business.id, servicePointId: sp.id, token: token
)
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)"
try await provisioner.connect()
// 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)"
try await provisioner.writeConfig(config)
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 {
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 {
switch beacon.type {
case .kbeacon:
return KBeaconProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .dxsmart:
return DXSmartProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .bluecharm:
return BlueCharmProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
case .unknown:
// Try all provisioners in sequence (matches Android fallback behavior)
return FallbackProvisioner(peripheral: beacon.peripheral, centralManager: bleManager.centralManager)
}
}
}
// 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
}
}
}