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.broadcast(); Stream 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 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 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 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 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> _scanWithFlutterPlugin(Map 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> rssiSamples = {}; debugPrint('[BeaconScanner] iOS: Starting 2-second scan...'); StreamSubscription? 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 registeredBeacons, Map 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(); } }