225 lines
7.7 KiB
Swift
225 lines
7.7 KiB
Swift
import SwiftUI
|
|
import CoreLocation
|
|
|
|
struct ScannerScreen: View {
|
|
@State private var beacons: [Beacon] = []
|
|
@State private var selectedBeacon: Beacon?
|
|
@State private var isLoading = true
|
|
|
|
// Scanner state
|
|
@StateObject private var scanner = ScannerViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Beacon selector
|
|
if isLoading {
|
|
ProgressView()
|
|
.padding()
|
|
} else {
|
|
Picker("Select Beacon", selection: $selectedBeacon) {
|
|
Text("Choose a beacon...").tag(nil as Beacon?)
|
|
ForEach(beacons) { beacon in
|
|
Text(beacon.name).tag(beacon as Beacon?)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.padding()
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Scanner display
|
|
VStack(spacing: 24) {
|
|
Spacer()
|
|
|
|
// Status indicator
|
|
ZStack {
|
|
Circle()
|
|
.fill(scanner.statusColor.opacity(0.15))
|
|
.frame(width: 160, height: 160)
|
|
|
|
Circle()
|
|
.fill(scanner.statusColor.opacity(0.3))
|
|
.frame(width: 120, height: 120)
|
|
|
|
Image(systemName: scanner.statusIcon)
|
|
.font(.system(size: 48))
|
|
.foregroundColor(scanner.statusColor)
|
|
}
|
|
|
|
Text(scanner.statusText)
|
|
.font(.title3.bold())
|
|
|
|
if scanner.isScanning {
|
|
VStack(spacing: 8) {
|
|
if scanner.rssi != 0 {
|
|
HStack {
|
|
Text("RSSI:")
|
|
.foregroundColor(.secondary)
|
|
Text("\(scanner.rssi) dBm")
|
|
.font(.system(.body, design: .monospaced))
|
|
.bold()
|
|
}
|
|
HStack {
|
|
Text("Samples:")
|
|
.foregroundColor(.secondary)
|
|
Text("\(scanner.sampleCount)/\(scanner.requiredSamples)")
|
|
.font(.system(.body, design: .monospaced))
|
|
}
|
|
// Signal strength bar
|
|
SignalStrengthBar(rssi: scanner.rssi)
|
|
.frame(height: 20)
|
|
.padding(.horizontal, 40)
|
|
} else {
|
|
Text("Searching for beacon signal...")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Start/Stop button
|
|
Button {
|
|
if scanner.isScanning {
|
|
scanner.stop()
|
|
} else if let beacon = selectedBeacon {
|
|
scanner.start(uuid: beacon.uuid)
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: scanner.isScanning ? "stop.fill" : "play.fill")
|
|
Text(scanner.isScanning ? "Stop Scanning" : "Start Scanning")
|
|
}
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(scanner.isScanning ? .red : .payfritGreen)
|
|
.disabled(selectedBeacon == nil && !scanner.isScanning)
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 24)
|
|
}
|
|
}
|
|
.navigationTitle("Beacon Scanner")
|
|
}
|
|
.task {
|
|
do {
|
|
beacons = try await APIService.shared.listBeacons()
|
|
} catch {
|
|
// Silently fail — user can still see the scanner
|
|
}
|
|
isLoading = false
|
|
}
|
|
.onChange(of: selectedBeacon) { _ in
|
|
if scanner.isScanning {
|
|
scanner.stop()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Scanner ViewModel
|
|
|
|
@MainActor
|
|
final class ScannerViewModel: ObservableObject {
|
|
@Published var isScanning = false
|
|
@Published var statusText = "Select a beacon to scan"
|
|
@Published var statusColor: Color = .secondary
|
|
@Published var statusIcon = "sensor.tag.radiowaves.forward.fill"
|
|
@Published var rssi: Int = 0
|
|
@Published var sampleCount = 0
|
|
let requiredSamples = 5
|
|
|
|
private var beaconScanner: BeaconScanner?
|
|
|
|
func start(uuid: String) {
|
|
beaconScanner?.dispose()
|
|
|
|
beaconScanner = BeaconScanner(
|
|
targetUUID: uuid,
|
|
onBeaconDetected: { [weak self] avgRssi in
|
|
self?.statusText = "Beacon Detected! (avg \(Int(avgRssi)) dBm)"
|
|
self?.statusColor = .green
|
|
self?.statusIcon = "checkmark.circle.fill"
|
|
},
|
|
onRSSIUpdate: { [weak self] currentRssi, samples in
|
|
self?.rssi = currentRssi
|
|
self?.sampleCount = samples
|
|
},
|
|
onBluetoothOff: { [weak self] in
|
|
self?.statusText = "Bluetooth is OFF"
|
|
self?.statusColor = .orange
|
|
self?.statusIcon = "bluetooth.slash"
|
|
},
|
|
onPermissionDenied: { [weak self] in
|
|
self?.statusText = "Location Permission Denied"
|
|
self?.statusColor = .red
|
|
self?.statusIcon = "location.slash.fill"
|
|
self?.isScanning = false
|
|
},
|
|
onError: { [weak self] message in
|
|
self?.statusText = message
|
|
self?.statusColor = .red
|
|
self?.statusIcon = "exclamationmark.triangle.fill"
|
|
self?.isScanning = false
|
|
}
|
|
)
|
|
|
|
beaconScanner?.startScanning()
|
|
isScanning = true
|
|
statusText = "Scanning..."
|
|
statusColor = .blue
|
|
statusIcon = "antenna.radiowaves.left.and.right"
|
|
rssi = 0
|
|
sampleCount = 0
|
|
}
|
|
|
|
func stop() {
|
|
beaconScanner?.dispose()
|
|
beaconScanner = nil
|
|
isScanning = false
|
|
statusText = "Select a beacon to scan"
|
|
statusColor = .secondary
|
|
statusIcon = "sensor.tag.radiowaves.forward.fill"
|
|
rssi = 0
|
|
sampleCount = 0
|
|
}
|
|
|
|
deinit {
|
|
// Ensure cleanup if view is removed while scanning
|
|
// Note: deinit runs on main actor since class is @MainActor
|
|
}
|
|
}
|
|
|
|
// MARK: - Signal Strength Bar
|
|
|
|
struct SignalStrengthBar: View {
|
|
let rssi: Int
|
|
|
|
private var strength: Double {
|
|
// Map RSSI from -100..-30 to 0..1
|
|
let clamped = max(-100, min(-30, rssi))
|
|
return Double(clamped + 100) / 70.0
|
|
}
|
|
|
|
private var barColor: Color {
|
|
if strength > 0.7 { return .green }
|
|
if strength > 0.4 { return .yellow }
|
|
return .red
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.secondary.opacity(0.2))
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(barColor)
|
|
.frame(width: geo.size.width * strength)
|
|
}
|
|
}
|
|
}
|
|
}
|