payfrit-beacon-ios/PayfritBeacon/BeaconScanner.swift
John Pinkyfloyd 962a767863 Rewrite app: simplified architecture, CoreLocation beacon scanning, UI fixes
- 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>
2026-02-04 22:07:39 -08:00

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
}