payfrit-user/lib/services/beacon_scanner_service.dart
John Mizerek 56f1e1cf63 Add native iOS beacon scanner with CoreBluetooth
- BeaconScanner.swift: Native scanner using CBCentralManager
- AppDelegate.swift: Wire up MethodChannel (same API as Android)
- beacon_channel.dart: Support iOS in isSupported check
- beacon_scanner_service.dart: Use native scanner on both platforms

iOS now gets the same fast 2-second scan as Android.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:36:40 -08:00

233 lines
7.6 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
import 'api.dart';
import 'beacon_channel.dart';
import 'beacon_permissions.dart';
/// Result of a beacon scan
class BeaconScanResult {
final BeaconLookupResult? bestBeacon;
final int beaconsFound;
final String? error;
const BeaconScanResult({
this.bestBeacon,
this.beaconsFound = 0,
this.error,
});
bool get success => error == null;
bool get foundBeacon => bestBeacon != null;
}
/// Global beacon scanner service for rescanning from anywhere in the app
class BeaconScannerService {
static final BeaconScannerService _instance = BeaconScannerService._internal();
factory BeaconScannerService() => _instance;
BeaconScannerService._internal();
bool _isScanning = false;
bool get isScanning => _isScanning;
// Callbacks for UI updates
final _scanStateController = StreamController<bool>.broadcast();
Stream<bool> get scanStateStream => _scanStateController.stream;
/// Perform a beacon scan
/// If [businessId] is provided, only scans for that business's beacons (optimized)
/// Otherwise, scans for all beacons and looks them up
Future<BeaconScanResult> scan({int? businessId}) async {
if (_isScanning) {
return const BeaconScanResult(error: "Scan already in progress");
}
_isScanning = true;
_scanStateController.add(true);
try {
// Request permissions
final granted = await BeaconPermissions.requestPermissions();
if (!granted) {
return const BeaconScanResult(error: "Permissions denied");
}
// Check Bluetooth
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
if (!bluetoothOn) {
return const BeaconScanResult(error: "Bluetooth is off");
}
// Get beacon UUIDs to scan for
Map<String, int> knownBeacons = {};
if (businessId != null && businessId > 0) {
// Optimized: only get beacons for this business
debugPrint('[BeaconScanner] Scanning for business $businessId beacons only');
try {
knownBeacons = await Api.listBeaconsByBusiness(businessId: businessId);
debugPrint('[BeaconScanner] Got ${knownBeacons.length} beacons for business');
} catch (e) {
debugPrint('[BeaconScanner] Failed to get business beacons: $e');
}
}
// Fall back to all beacons if business-specific didn't work
if (knownBeacons.isEmpty) {
debugPrint('[BeaconScanner] Fetching all beacon UUIDs');
try {
knownBeacons = await Api.listAllBeacons();
debugPrint('[BeaconScanner] Got ${knownBeacons.length} total beacons');
} catch (e) {
debugPrint('[BeaconScanner] Failed to fetch beacons: $e');
return BeaconScanResult(error: "Failed to fetch beacons: $e");
}
}
if (knownBeacons.isEmpty) {
return const BeaconScanResult(error: "No beacons configured");
}
// Use native scanner on both Android and iOS
List<DetectedBeacon> detectedBeacons = [];
if (BeaconChannel.isSupported) {
debugPrint('[BeaconScanner] Using native scanner...');
detectedBeacons = await BeaconChannel.startScan(
regions: knownBeacons.keys.toList(),
);
} else {
// Fallback: use Flutter plugin
debugPrint('[BeaconScanner] Using Flutter plugin fallback...');
detectedBeacons = await _scanWithFlutterPlugin(knownBeacons);
}
if (detectedBeacons.isEmpty) {
debugPrint('[BeaconScanner] No beacons detected');
return const BeaconScanResult(beaconsFound: 0);
}
debugPrint('[BeaconScanner] Detected ${detectedBeacons.length} beacons');
// Filter to only known beacons
final validBeacons = detectedBeacons
.where((b) => knownBeacons.containsKey(b.uuid))
.toList();
if (validBeacons.isEmpty) {
debugPrint('[BeaconScanner] No known beacons found');
return BeaconScanResult(beaconsFound: detectedBeacons.length);
}
// Lookup found beacons
final uuids = validBeacons.map((b) => b.uuid).toList();
debugPrint('[BeaconScanner] Looking up ${uuids.length} beacons...');
List<BeaconLookupResult> lookupResults = [];
try {
lookupResults = await Api.lookupBeacons(uuids);
debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons');
} catch (e) {
debugPrint('[BeaconScanner] Lookup error: $e');
return BeaconScanResult(
beaconsFound: validBeacons.length,
error: "Failed to lookup beacons",
);
}
// Find the best registered beacon based on RSSI
final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi};
final bestBeacon = _findBestBeacon(lookupResults, rssiMap);
debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}');
return BeaconScanResult(
bestBeacon: bestBeacon,
beaconsFound: validBeacons.length,
);
} catch (e) {
debugPrint('[BeaconScanner] Scan error: $e');
return BeaconScanResult(error: e.toString());
} finally {
_isScanning = false;
_scanStateController.add(false);
}
}
/// iOS fallback: scan with Flutter plugin
Future<List<DetectedBeacon>> _scanWithFlutterPlugin(Map<String, int> knownBeacons) async {
await flutterBeacon.initializeScanning;
// Create regions for all known UUIDs
final regions = knownBeacons.keys.map((uuid) {
final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}';
return Region(identifier: uuid, proximityUUID: formattedUUID);
}).toList();
// Collect RSSI samples
final Map<String, List<int>> rssiSamples = {};
debugPrint('[BeaconScanner] iOS: Starting 2-second scan...');
StreamSubscription<RangingResult>? subscription;
subscription = flutterBeacon.ranging(regions).listen((result) {
for (var beacon in result.beacons) {
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
final rssi = beacon.rssi;
rssiSamples.putIfAbsent(uuid, () => []).add(rssi);
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
// Convert to DetectedBeacon format
return rssiSamples.entries.map((entry) {
final samples = entry.value;
final avgRssi = samples.reduce((a, b) => a + b) ~/ samples.length;
return DetectedBeacon(
uuid: entry.key,
rssi: avgRssi,
samples: samples.length,
);
}).toList()..sort((a, b) => b.rssi.compareTo(a.rssi));
}
/// Find the best registered beacon based on RSSI
BeaconLookupResult? _findBestBeacon(
List<BeaconLookupResult> registeredBeacons,
Map<String, int> rssiMap,
) {
if (registeredBeacons.isEmpty) return null;
BeaconLookupResult? best;
int bestRssi = -999;
// First pass: find beacon with RSSI >= -85
for (final beacon in registeredBeacons) {
final rssi = rssiMap[beacon.uuid] ?? -100;
if (rssi > bestRssi && rssi >= -85) {
bestRssi = rssi;
best = beacon;
}
}
// Fall back to strongest if none meet threshold
if (best == null) {
for (final beacon in registeredBeacons) {
final rssi = rssiMap[beacon.uuid] ?? -100;
if (rssi > bestRssi) {
bestRssi = rssi;
best = beacon;
}
}
}
return best;
}
void dispose() {
_scanStateController.close();
}
}