- 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>
779 lines
28 KiB
Swift
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
|
|
}
|
|
}
|