payfrit-beacon-ios/PayfritBeacon/Views/ScanView.swift
Schwifty 2496cab7f3 fix: wrap iOS 17+ APIs in @available checks for iOS 16 compat
ContentUnavailableView and .symbolEffect(.pulse) require iOS 17+
but deployment target is iOS 16. Wrapped all usages in
if #available(iOS 17.0, *) with VStack-based fallbacks for iOS 16.

Files fixed:
- ScanView.swift (4 ContentUnavailableView + 1 symbolEffect)
- QRScannerView.swift (1 ContentUnavailableView)
- BusinessListView.swift (2 ContentUnavailableView)
2026-03-22 19:20:46 +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.blue : 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(.orange)
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(.red)
}
}
.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) { 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(.orange)
.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(.orange)
.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(.green)
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(.red)
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 .green
case -65 ... -51: return .blue
case -80 ... -66: return .orange
default: return .red
}
}
private var typeColor: Color {
switch beacon.type {
case .kbeacon: return .blue
case .dxsmart: return .orange
case .bluecharm: return .purple
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
}
}
}