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) } } } }