payfrit-beacon-ios/PayfritBeacon/ScanView.swift
John Pinkyfloyd 962a767863 Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- Flatten project structure: remove Models/, Services/, ViewModels/, Views/ subdirs
- Replace APIService actor with simpler Api class, IS_DEV flag controls dev vs prod URL
- Rewrite BeaconScanner to use CoreLocation (CLBeaconRegion ranging) instead of
  CoreBluetooth — iOS blocks iBeacon data from CBCentralManager
- Add SVG logo on login page with proper scaling (was showing green square)
- Make login page scrollable, add "enter 6-digit code" OTP instruction
- Fix text input visibility (white on white) with .foregroundColor(.primary)
- Add diagonal orange DEV ribbon banner (lower-left corner), gated on Api.IS_DEV
- Update app icon: logo 10% larger, wifi icon closer
- Add en.lproj/InfoPlist.strings for display name localization
- Fix scan flash: keep isScanning=true until enrichment completes
- Add Podfile with SVGKit, Kingfisher, CocoaLumberjack dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:07:39 -08:00

779 lines
28 KiB
Swift

import SwiftUI
// MARK: - Data Types
enum BeaconStatus {
case new, thisBusiness, otherBusiness, banned
}
struct EnrichedBeacon: Identifiable {
var id: String { uuid }
let uuid: String
let rssi: Int
let samples: Int
let status: BeaconStatus
let assignedBusinessName: String?
let assignedBeaconName: String?
let assignedServicePointName: String?
let beaconId: Int
let banReason: String?
}
// MARK: - ScanView
struct ScanView: View {
let businessId: Int
let businessName: String
var onBack: () -> Void
@State private var nextTableNumber: Int = 1
@State private var hasScannedOnce = false
@State private var savedUuids: Set<String> = []
@State private var scanResults: [EnrichedBeacon] = []
@State private var knownAssignments: [String: BeaconLookupResult] = [:]
@State private var pendingQrMac: String?
@State private var pendingQrBeacon: EnrichedBeacon?
@State private var isScanning = false
@State private var snackMessage: String?
@State private var showQrScanner = false
@State private var qrScanForBeacon: EnrichedBeacon?
// Dialog state
@State private var showAssignSheet = false
@State private var assignBeacon: EnrichedBeacon?
@State private var assignName = ""
@State private var showBannedSheet = false
@State private var bannedBeacon: EnrichedBeacon?
@State private var generatedUuid: String?
@State private var showInfoAlert = false
@State private var infoBeacon: EnrichedBeacon?
@State private var showOptionsAlert = false
@State private var optionsBeacon: EnrichedBeacon?
@State private var showWipeAlert = false
@State private var wipeBeacon: EnrichedBeacon?
@State private var showMacLookupAlert = false
@State private var macLookupResult: MacLookupResult?
@State private var showBeaconPickerAlert = false
@State private var pickerMac: String?
@State private var showNoBeaconsScanAlert = false
@State private var noBeaconsMac: String?
@StateObject private var beaconScanner = BeaconScanner()
@State private var targetUUIDs: [UUID] = []
// Common fallback UUIDs for beacons that may not be registered yet
private static let fallbackUUIDs = [
"E2C56DB5DFFB48D2B060D0F5A71096E0",
"B9407F30F5F8466EAFF925556B57FE6D",
"F7826DA64FA24E988024BC5B71E0893E",
]
var body: some View {
VStack(spacing: 0) {
// Toolbar
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.title3)
}
Text("Beacon Scanner")
.font(.headline)
Spacer()
}
.padding()
.background(Color(.systemBackground))
// Info bar
HStack {
Text(businessName)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text("Next: Table \(nextTableNumber)")
.font(.subheadline.bold())
.foregroundColor(.payfritGreen)
}
.padding(.horizontal)
.padding(.vertical, 8)
Divider()
// Beacon list
if scanResults.isEmpty {
Spacer()
if isScanning || !hasScannedOnce {
ProgressView("Scanning for beacons...")
} else {
Text("No beacons detected nearby.")
.foregroundColor(.secondary)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(scanResults) { beacon in
beaconRow(beacon)
.onTapGesture { handleBeaconTap(beacon) }
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
// Bottom action bar
VStack(spacing: 8) {
HStack(spacing: 12) {
Button(action: requestPermissionsAndScan) {
Text(hasScannedOnce ? "Scan Next" : "Scan for Beacons")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.payfritGreen)
.foregroundColor(.white)
.cornerRadius(8)
}
.disabled(isScanning)
Button(action: { launchQrScan(forBeacon: nil) }) {
Text("QR")
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color(.systemGray5))
.foregroundColor(.primary)
.cornerRadius(8)
}
}
Button(action: onBack) {
Text("Done")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.payfritGreen, lineWidth: 1)
)
.foregroundColor(.payfritGreen)
}
}
.padding()
}
.modifier(DevBanner())
.overlay(snackOverlay, alignment: .bottom)
.onAppear { loadExistingBeacons() }
.sheet(isPresented: $showAssignSheet) { assignSheet }
.sheet(isPresented: $showBannedSheet) { bannedSheet }
.sheet(isPresented: $showQrScanner) {
QrScanView { value, type in
showQrScanner = false
handleQrScanResult(qrResult: value, qrType: type)
}
}
.alert("Beacon Info", isPresented: $showInfoAlert, presenting: infoBeacon) { _ in
Button("OK", role: .cancel) {}
} message: { beacon in
Text(infoMessage(beacon))
}
.confirmationDialog("Beacon Options", isPresented: $showOptionsAlert, presenting: optionsBeacon) { beacon in
Button("Reassign") {
openAssignDialog(beacon)
}
Button("Wipe", role: .destructive) {
wipeBeacon = beacon
showWipeAlert = true
}
Button("Cancel", role: .cancel) {}
} message: { beacon in
Text(optionsMessage(beacon))
}
.alert("Wipe Beacon?", isPresented: $showWipeAlert, presenting: wipeBeacon) { beacon in
Button("Wipe", role: .destructive) {
performWipe(beacon)
}
Button("Cancel", role: .cancel) {}
} message: { beacon in
let beaconName = beacon.assignedBeaconName ?? "unnamed"
let spName = beacon.assignedServicePointName ?? beaconName
let displayName = beaconName == spName ? beaconName : "\(beaconName) (\(spName))"
Text("This will unlink \"\(displayName)\" from its service point. The beacon will appear as NEW on the next scan.")
}
.alert("MAC Lookup Result", isPresented: $showMacLookupAlert, presenting: macLookupResult) { _ in
Button("OK", role: .cancel) {}
} message: { result in
Text("Beacon: \(result.beaconName)\nBusiness: \(result.businessName)\nService Point: \(result.servicePointName)\nMAC: \(result.macAddress)\nUUID: \(result.uuid.isEmpty ? "Not set" : result.uuid)")
}
.alert("MAC Scanned", isPresented: $showNoBeaconsScanAlert, presenting: noBeaconsMac) { _ in
Button("Scan") {
requestPermissionsAndScan()
}
Button("Cancel", role: .cancel) {
pendingQrMac = nil
}
} message: { mac in
Text("Scanned MAC: \(mac)\n\nNo beacons detected yet. Scan for beacons first to link this MAC address.")
}
.confirmationDialog("Link MAC to Beacon", isPresented: $showBeaconPickerAlert, presenting: pickerMac) { _ in
ForEach(Array(scanResults.enumerated()), id: \.element.uuid) { index, beacon in
let statusLabel: String = {
switch beacon.status {
case .new: return "NEW"
case .thisBusiness: return beacon.assignedBeaconName ?? "This business"
case .otherBusiness: return beacon.assignedBusinessName ?? "Other business"
case .banned: return "BANNED"
}
}()
Button("\(statusLabel) (\(beacon.rssi) dBm)") {
openAssignDialog(scanResults[index])
}
}
Button("Cancel", role: .cancel) {
pendingQrMac = nil
}
} message: { mac in
Text("Select a beacon to link with MAC \(mac)")
}
}
// MARK: - Beacon Row
private func beaconRow(_ beacon: EnrichedBeacon) -> some View {
HStack(spacing: 8) {
// Signal strength indicator
Rectangle()
.fill(signalColor(beacon.rssi))
.frame(width: 4)
.cornerRadius(2)
VStack(alignment: .leading, spacing: 4) {
Text(BeaconBanList.formatUuid(beacon.uuid))
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
statusBadge(beacon)
}
Spacer()
Text("\(beacon.rssi) dBm")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color(.systemBackground))
.cornerRadius(8)
.shadow(color: .black.opacity(0.05), radius: 2, y: 1)
}
private func signalColor(_ rssi: Int) -> Color {
if rssi >= -60 { return .signalStrong }
if rssi >= -75 { return .signalMedium }
return .signalWeak
}
private func statusBadge(_ beacon: EnrichedBeacon) -> some View {
let (text, textColor, bgColor): (String, Color, Color) = {
switch beacon.status {
case .new:
return ("NEW", .successGreen, .newBg)
case .thisBusiness:
let name = beacon.assignedBeaconName
let label = (name != nil && !name!.isEmpty) ? "\(name!) (this business)" : "This business"
return (label, .infoBlue, .assignedBg)
case .otherBusiness:
let label = beacon.assignedBusinessName ?? "Other business"
return (label, .warningOrange, .assignedBg)
case .banned:
return ("BANNED", .errorRed, .bannedBg)
}
}()
return Text(text)
.font(.caption2.weight(.medium))
.foregroundColor(textColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(bgColor)
.cornerRadius(4)
}
// MARK: - Snack Overlay
@ViewBuilder
private var snackOverlay: some View {
if let message = snackMessage {
Text(message)
.font(.callout)
.foregroundColor(.white)
.padding()
.background(Color(.darkGray))
.cornerRadius(8)
.padding(.bottom, 100)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { snackMessage = nil }
}
}
}
}
private func showSnack(_ message: String) {
withAnimation { snackMessage = message }
}
// MARK: - Actions
private func handleBeaconTap(_ beacon: EnrichedBeacon) {
switch beacon.status {
case .new:
openAssignDialog(beacon)
case .thisBusiness:
optionsBeacon = beacon
showOptionsAlert = true
case .otherBusiness:
infoBeacon = beacon
showInfoAlert = true
case .banned:
bannedBeacon = beacon
generatedUuid = nil
showBannedSheet = true
}
}
private func loadExistingBeacons() {
Task {
do {
let existing = try await Api.shared.listBeacons(businessId: businessId)
let maxNumber = existing.compactMap { beacon -> Int? in
guard let match = beacon.name.range(of: #"Table\s+(\d+)"#,
options: [.regularExpression, .caseInsensitive]) else {
return nil
}
let numberStr = beacon.name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
return Int(numberStr)
}.max() ?? 0
nextTableNumber = maxNumber + 1
} catch {
// Silently continue will use default table 1
}
requestPermissionsAndScan()
}
}
private func requestPermissionsAndScan() {
Task {
// Request permission if needed
if beaconScanner.authorizationStatus == .notDetermined {
beaconScanner.requestPermission()
for _ in 0..<20 {
try? await Task.sleep(nanoseconds: 500_000_000)
if beaconScanner.authorizationStatus != .notDetermined { break }
}
}
guard beaconScanner.hasPermissions() else {
showSnack("Location permission denied — required for beacon scanning")
return
}
await startScan()
}
}
private func startScan() async {
isScanning = true
scanResults = []
// Fetch known beacon UUIDs from server if we haven't yet
if targetUUIDs.isEmpty {
do {
let serverBeacons = try await Api.shared.listAllBeacons()
var uuids: [UUID] = []
for (rawUuid, _) in serverBeacons {
let formatted = formatUuidString(rawUuid)
if let uuid = UUID(uuidString: formatted) {
uuids.append(uuid)
}
}
// Add fallback UUIDs
for raw in ScanView.fallbackUUIDs {
let formatted = formatUuidString(raw)
if let uuid = UUID(uuidString: formatted), !uuids.contains(uuid) {
uuids.append(uuid)
}
}
targetUUIDs = uuids
} catch {
// Use fallbacks only
targetUUIDs = ScanView.fallbackUUIDs.compactMap { raw in
UUID(uuidString: formatUuidString(raw))
}
}
}
guard !targetUUIDs.isEmpty else {
isScanning = false
showSnack("No beacon UUIDs to scan for")
return
}
// Range for 3 seconds
beaconScanner.startRanging(uuids: targetUUIDs)
try? await Task.sleep(nanoseconds: 3_000_000_000)
let detected = beaconScanner.stopAndCollect()
onScanComplete(detected)
}
private func formatUuidString(_ raw: String) -> String {
let clean = raw.replacingOccurrences(of: "-", with: "").uppercased()
guard clean.count == 32 else { return raw }
if raw.count == 36 { return raw.uppercased() }
let chars = Array(clean)
return "\(String(chars[0..<8]))-\(String(chars[8..<12]))-\(String(chars[12..<16]))-\(String(chars[16..<20]))-\(String(chars[20..<32]))"
}
private func onScanComplete(_ detected: [DetectedBeacon]) {
let filtered = detected.filter { !savedUuids.contains($0.uuid) }
if filtered.isEmpty {
scanResults = []
hasScannedOnce = true
isScanning = false
return
}
Task {
do {
let lookupResults = try await Api.shared.lookupBeacons(uuids: filtered.map { $0.uuid })
knownAssignments.removeAll()
for result in lookupResults {
knownAssignments[result.uuid] = result
}
} catch {
// Continue without lookup data
}
scanResults = filtered.map { beacon in
let lookup = knownAssignments[beacon.uuid]
let isBanned = BeaconBanList.isBanned(beacon.uuid)
let banReason = BeaconBanList.getBanReason(beacon.uuid)
let status: BeaconStatus
if isBanned {
status = .banned
} else if let lookup = lookup, lookup.businessId == businessId {
status = .thisBusiness
} else if lookup != nil {
status = .otherBusiness
} else {
status = .new
}
return EnrichedBeacon(
uuid: beacon.uuid,
rssi: beacon.rssi,
samples: beacon.samples,
status: status,
assignedBusinessName: lookup?.businessName,
assignedBeaconName: lookup?.beaconName,
assignedServicePointName: lookup?.servicePointName,
beaconId: lookup?.beaconId ?? 0,
banReason: banReason
)
}
hasScannedOnce = true
isScanning = false
}
}
// MARK: - Assign Dialog
private func openAssignDialog(_ beacon: EnrichedBeacon) {
assignBeacon = beacon
assignName = "Table \(nextTableNumber)"
showAssignSheet = true
}
private var assignSheet: some View {
NavigationStack {
if let beacon = assignBeacon {
VStack(alignment: .leading, spacing: 16) {
Text("UUID: \(BeaconBanList.formatUuid(beacon.uuid))")
.font(.system(.caption, design: .monospaced))
Text("RSSI: \(beacon.rssi) dBm")
.font(.callout)
if let mac = pendingQrMac {
HStack {
Text("MAC: \(mac)")
.font(.callout)
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.successGreen)
}
}
if beacon.status == .otherBusiness {
Text("Warning: This beacon is assigned to \(beacon.assignedBusinessName ?? "another business"). Saving will reassign it.")
.font(.callout)
.foregroundColor(.warningOrange)
}
if beacon.status == .banned {
Text("Warning: This UUID is a known factory default.\n(\(beacon.banReason ?? "Banned"))")
.font(.callout)
.foregroundColor(.errorRed)
}
if beacon.status == .thisBusiness {
Text("Currently: \(beacon.assignedBeaconName ?? "unnamed")")
.font(.callout)
.foregroundColor(.infoBlue)
}
TextField("Beacon name", text: $assignName)
.textFieldStyle(.roundedBorder)
.padding(.top, 8)
Spacer()
}
.padding()
.navigationTitle("Assign Beacon")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
pendingQrMac = nil
showAssignSheet = false
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let name = assignName.trimmingCharacters(in: .whitespacesAndNewlines)
if !name.isEmpty {
showAssignSheet = false
saveBeacon(uuid: beacon.uuid, name: name, macAddress: pendingQrMac)
pendingQrMac = nil
}
}
}
ToolbarItem(placement: .bottomBar) {
Button {
showAssignSheet = false
launchQrScan(forBeacon: beacon)
} label: {
Label("Scan QR", systemImage: "qrcode.viewfinder")
}
}
}
}
}
.presentationDetents([.medium])
}
private func saveBeacon(uuid: String, name: String, macAddress: String? = nil) {
Task {
do {
let saved = try await Api.shared.saveBeacon(
businessId: businessId, name: name, uuid: uuid, macAddress: macAddress
)
savedUuids.insert(uuid)
// Increment table number
if let match = name.range(of: #"Table\s+(\d+)"#, options: [.regularExpression, .caseInsensitive]) {
let numberStr = name[match].split(separator: " ").last.flatMap { String($0) } ?? ""
if let savedNumber = Int(numberStr), savedNumber >= nextTableNumber {
nextTableNumber = savedNumber + 1
} else {
nextTableNumber += 1
}
} else {
nextTableNumber += 1
}
scanResults.removeAll { $0.uuid == uuid }
showSnack("Saved \"\(name)\"")
} catch {
showSnack(error.localizedDescription)
}
}
}
// MARK: - Banned Dialog
private var bannedSheet: some View {
NavigationStack {
if let beacon = bannedBeacon {
VStack(alignment: .leading, spacing: 16) {
Text("Current UUID:")
.font(.caption)
.foregroundColor(.secondary)
Text(BeaconBanList.formatUuid(beacon.uuid))
.font(.system(.caption, design: .monospaced))
Text(beacon.banReason ?? "This UUID is a known factory default")
.font(.callout)
.foregroundColor(.errorRed)
Divider()
Text("Suggested Replacement:")
.font(.caption)
.foregroundColor(.secondary)
if let uuid = generatedUuid {
Text(uuid)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
} else {
Text("Tap Generate to create a new UUID")
.font(.callout)
.foregroundColor(.secondary)
}
HStack(spacing: 12) {
Button {
generatedUuid = UUID().uuidString.uppercased()
} label: {
Label("Generate", systemImage: "arrow.triangle.2.circlepath")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button {
if let uuid = generatedUuid {
UIPasteboard.general.string = uuid
showSnack("UUID copied to clipboard")
}
} label: {
Label("Copy", systemImage: "doc.on.doc")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(generatedUuid == nil)
}
.padding(.top, 8)
Spacer()
}
.padding()
.navigationTitle("Banned UUID")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showBannedSheet = false }
}
}
}
}
.presentationDetents([.medium])
}
// MARK: - Info / Options Messages
private func infoMessage(_ beacon: EnrichedBeacon) -> String {
let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
return "UUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm\nName: \(beacon.assignedBeaconName ?? "unnamed")\n\nThis beacon is registered to \(beacon.assignedBusinessName ?? "another") business and cannot be reassigned from here."
}
private func optionsMessage(_ beacon: EnrichedBeacon) -> String {
let formattedUuid = BeaconBanList.formatUuid(beacon.uuid)
let beaconName = beacon.assignedBeaconName ?? "unnamed"
let spName = beacon.assignedServicePointName ?? beaconName
return "Beacon: \(beaconName)\nService Point: \(spName)\nUUID: \(formattedUuid)\nRSSI: \(beacon.rssi) dBm"
}
// MARK: - Wipe
private func performWipe(_ beacon: EnrichedBeacon) {
guard beacon.beaconId > 0 else {
showSnack("Cannot wipe: invalid beacon ID")
return
}
Task {
do {
try await Api.shared.wipeBeacon(businessId: businessId, beaconId: beacon.beaconId)
savedUuids.remove(beacon.uuid)
scanResults.removeAll { $0.uuid == beacon.uuid }
showSnack("Beacon wiped")
} catch {
showSnack(error.localizedDescription)
}
}
}
// MARK: - QR Scan
private func launchQrScan(forBeacon: EnrichedBeacon?) {
pendingQrBeacon = forBeacon
showQrScanner = true
}
private func handleQrScanResult(qrResult: String?, qrType: String?) {
guard let qrResult = qrResult, !qrResult.isEmpty else { return }
switch qrType {
case QrScanView.TYPE_MAC:
if let beacon = pendingQrBeacon {
pendingQrMac = qrResult
showSnack("MAC scanned: \(qrResult)")
openAssignDialog(beacon)
} else {
lookupByMac(qrResult)
}
case QrScanView.TYPE_UUID:
let matchingBeacon = scanResults.first {
$0.uuid.lowercased() == qrResult.replacingOccurrences(of: "-", with: "").lowercased()
}
if let beacon = matchingBeacon {
showSnack("UUID found in scan results")
openAssignDialog(beacon)
} else {
showSnack("UUID not found in nearby beacons")
}
default:
showSnack("Unknown QR format: \(qrResult)")
}
pendingQrBeacon = nil
}
private func lookupByMac(_ macAddress: String) {
Task {
do {
if let result = try await Api.shared.lookupByMac(macAddress: macAddress) {
macLookupResult = result
showMacLookupAlert = true
} else {
pendingQrMac = macAddress
showBeaconPickerForMac(macAddress)
}
} catch {
showSnack(error.localizedDescription)
}
}
}
private func showBeaconPickerForMac(_ macAddress: String) {
if scanResults.isEmpty {
noBeaconsMac = macAddress
showNoBeaconsScanAlert = true
return
}
pickerMac = macAddress
showBeaconPickerAlert = true
}
}