payfrit-beacon-ios/PayfritBeacon/Views/ScanView.swift
Schwifty 61862adfa8 fix: add real-time status updates to KBeacon provisioner + fix disconnect handler
KBeaconProvisioner had no onStatusUpdate callback, so the UI showed a static
"Connecting..." message during the entire auth cycle (5 passwords × 5s timeout
× 3 retries = 75s of dead silence). Now reports each phase: connecting,
discovering services, authenticating (with password attempt count), writing,
saving.

Also fixed ScanView disconnect handler to cover .writing and .verifying states —
previously only handled .connecting/.connected, so a mid-write disconnect left
the UI permanently stuck.

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

936 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
}
} else if let kbProvisioner = provisioner as? KBeaconProvisioner {
kbProvisioner.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 in any active provisioning state, show failure
if let self = self,
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")
// 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
}
}
}