import UIKit import CoreBluetooth import CoreLocation /// Beacon scanner for task auto-completion. /// Scans for a specific UUID and triggers callback after dwell time is met. final class BeaconScanner: NSObject, ObservableObject { private let targetUUID: String private let onBeaconDetected: (Double) -> Void private let onBluetoothOff: (() -> Void)? private let onPermissionDenied: (() -> Void)? @Published var isScanning = false private var locationManager: CLLocationManager? private var activeConstraint: CLBeaconIdentityConstraint? private var checkTimer: Timer? // RSSI samples for dwell time enforcement private var rssiSamples: [Int] = [] private let minSamplesToConfirm = 5 // ~5 seconds private let rssiThreshold = -75 init(targetUUID: String, onBeaconDetected: @escaping (Double) -> Void, onBluetoothOff: (() -> Void)? = nil, onPermissionDenied: (() -> Void)? = nil) { self.targetUUID = targetUUID self.onBeaconDetected = onBeaconDetected self.onBluetoothOff = onBluetoothOff self.onPermissionDenied = onPermissionDenied super.init() } // MARK: - UUID formatting private func formatUUID(_ uuid: String) -> String { let clean = uuid.replacingOccurrences(of: "-", with: "").uppercased() guard clean.count == 32 else { return uuid } let i = clean.startIndex let p1 = clean[i..= rssiThreshold { rssiSamples.append(rssi) if rssiSamples.count >= minSamplesToConfirm { let avg = Double(rssiSamples.reduce(0, +)) / Double(rssiSamples.count) DispatchQueue.main.async { [weak self] in self?.onBeaconDetected(avg) } } } else { // Signal too weak, reset if !rssiSamples.isEmpty { rssiSamples.removeAll() } } } // Beacon lost this cycle if !foundThisCycle && !rssiSamples.isEmpty { rssiSamples.removeAll() } } func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) { // Ranging failed - could be Bluetooth off } }