- 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>
128 lines
4.4 KiB
Swift
128 lines
4.4 KiB
Swift
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Native beacon scanner using CoreLocation for iBeacon detection.
|
|
/// Based on the proven BeaconManager from payfrit-user-ios.
|
|
class BeaconScanner: NSObject, ObservableObject, CLLocationManagerDelegate {
|
|
|
|
private static let TAG = "BeaconScanner"
|
|
private static let MIN_RSSI: Int = -90
|
|
|
|
@Published var isScanning = false
|
|
@Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
|
|
|
private var locationManager: CLLocationManager!
|
|
private var activeRegions: [CLBeaconRegion] = []
|
|
private var beaconSamples: [String: [Int]] = [:]
|
|
|
|
override init() {
|
|
super.init()
|
|
if Thread.isMainThread {
|
|
setupLocationManager()
|
|
} else {
|
|
DispatchQueue.main.sync {
|
|
setupLocationManager()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupLocationManager() {
|
|
locationManager = CLLocationManager()
|
|
locationManager.delegate = self
|
|
authorizationStatus = locationManager.authorizationStatus
|
|
}
|
|
|
|
func requestPermission() {
|
|
locationManager.requestWhenInUseAuthorization()
|
|
}
|
|
|
|
func hasPermissions() -> Bool {
|
|
let status = locationManager.authorizationStatus
|
|
return status == .authorizedWhenInUse || status == .authorizedAlways
|
|
}
|
|
|
|
/// Start ranging for the given UUIDs. Call stopAndCollect() after your desired duration.
|
|
func startRanging(uuids: [UUID]) {
|
|
NSLog("\(BeaconScanner.TAG): startRanging called with \(uuids.count) UUIDs")
|
|
|
|
stopRanging()
|
|
beaconSamples.removeAll()
|
|
|
|
guard !uuids.isEmpty else {
|
|
NSLog("\(BeaconScanner.TAG): No target UUIDs provided")
|
|
return
|
|
}
|
|
|
|
isScanning = true
|
|
|
|
for uuid in uuids {
|
|
let constraint = CLBeaconIdentityConstraint(uuid: uuid)
|
|
let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: uuid.uuidString)
|
|
activeRegions.append(region)
|
|
|
|
NSLog("\(BeaconScanner.TAG): Starting ranging for UUID: \(uuid.uuidString)")
|
|
locationManager.startRangingBeacons(satisfying: constraint)
|
|
}
|
|
|
|
NSLog("\(BeaconScanner.TAG): Ranging started for \(activeRegions.count) regions")
|
|
}
|
|
|
|
/// Stop ranging and return collected results sorted by signal strength.
|
|
func stopAndCollect() -> [DetectedBeacon] {
|
|
NSLog("\(BeaconScanner.TAG): stopAndCollect - beaconSamples has \(beaconSamples.count) entries")
|
|
|
|
stopRanging()
|
|
|
|
var results: [DetectedBeacon] = []
|
|
for (uuid, samples) in beaconSamples {
|
|
let avgRssi = samples.reduce(0, +) / max(samples.count, 1)
|
|
NSLog("\(BeaconScanner.TAG): Beacon \(uuid) - avgRssi=\(avgRssi), samples=\(samples.count)")
|
|
results.append(DetectedBeacon(uuid: uuid, rssi: avgRssi, samples: samples.count))
|
|
}
|
|
|
|
results.sort { $0.rssi > $1.rssi }
|
|
return results
|
|
}
|
|
|
|
private func stopRanging() {
|
|
for region in activeRegions {
|
|
let constraint = CLBeaconIdentityConstraint(uuid: region.uuid)
|
|
locationManager.stopRangingBeacons(satisfying: constraint)
|
|
}
|
|
activeRegions.removeAll()
|
|
isScanning = false
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
let status = manager.authorizationStatus
|
|
NSLog("\(BeaconScanner.TAG): Authorization changed to \(status.rawValue)")
|
|
authorizationStatus = status
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon],
|
|
satisfying constraint: CLBeaconIdentityConstraint) {
|
|
for beacon in beacons {
|
|
let rssiValue = beacon.rssi
|
|
guard rssiValue >= BeaconScanner.MIN_RSSI && rssiValue < 0 else { continue }
|
|
|
|
let uuid = beacon.uuid.uuidString.replacingOccurrences(of: "-", with: "").uppercased()
|
|
|
|
if beaconSamples[uuid] == nil {
|
|
beaconSamples[uuid] = []
|
|
}
|
|
beaconSamples[uuid]?.append(rssiValue)
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailRangingFor constraint: CLBeaconIdentityConstraint, error: Error) {
|
|
NSLog("\(BeaconScanner.TAG): Ranging FAILED for \(constraint.uuid): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
struct DetectedBeacon {
|
|
let uuid: String // 32-char uppercase hex, no dashes
|
|
let rssi: Int
|
|
let samples: Int
|
|
}
|