payfrit-beacon-ios/PayfritBeacon/Views/ScanView.swift
Schwifty 66cf65f803 fix: trim auth and post-write delays from 600ms down to 100ms total
- Auth trigger settle: 100ms → 50ms
- Auth password settle: 500ms → 50ms
- Post-write reboot settle: 200ms → 50ms

Beacon handles 50ms inter-command just fine, no reason for the
beginning and end to be slower.

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

829 lines
29 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 writesCompleted = false
@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:
// Legacy auto-write skips this state now
progressView(title: "Connected…", message: statusMessage)
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
// dxsmartConnectedView removed auto-write skips the manual confirmation step
// 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)
pendingProvisioner = provisioner
pendingConfig = config
// 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
guard let self = self else { return }
let reason = error?.localizedDescription ?? "beacon timed out"
// Writes already finished beacon rebooted after SaveConfig, this is expected
if self.writesCompleted {
provisionLog?.log("disconnect", "Post-write disconnect (expected — beacon rebooted after save)")
return
}
provisionLog?.log("disconnect", "Unexpected disconnect: \(reason)", isError: true)
// For all active states, treat disconnect as failure
if self.provisioningState == .connecting ||
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")
// Auto-fire write immediately no pause needed
provisioningState = .writing
writesCompleted = false
statusMessage = "Writing config to DX-Smart…"
provisionLog.log("write", "Auto-writing config (no user tap needed)…")
try await provisioner.writeConfig(config)
writesCompleted = true
// Brief settle after SaveConfig before dropping the BLE link.
try? await Task.sleep(nanoseconds: 50_000_000)
provisioner.disconnect()
try await APIClient.shared.registerBeaconHardware(
businessId: business.id,
servicePointId: sp.id,
uuid: ns.uuid,
major: ns.major,
minor: Int(config.minor),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
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 {
provisionLog.log("error", "Provisioning failed after \(provisionLog.elapsed): \(error.localizedDescription)", isError: true)
provisioningState = .failed
errorMessage = error.localizedDescription
}
}
// Kept for cancel/reset and registerAnywayAfterFailure fallback
@State private var pendingConfig: BeaconConfig?
@State private var pendingProvisioner: (any BeaconProvisioner)?
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),
hardwareId: selectedBeacon?.id.uuidString ?? "unknown-ios",
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
}
}
}