From 982152383acdc95a4a4658383cb3605d2fa73ba3 Mon Sep 17 00:00:00 2001 From: John Mizerek Date: Tue, 27 Jan 2026 01:28:16 -0800 Subject: [PATCH] Use native scanner across all beacon services, simplify splash - Update beacon_scan_screen and beacon_scanner_service to use native Android scanner with Flutter plugin fallback for iOS - Add native permission checking for fast path startup - Simplify splash screen: remove bouncing logo animation, show only spinner on black background for clean transition - Native splash is now black-only to match Flutter splash Co-Authored-By: Claude Opus 4.5 --- .../res/drawable-v21/launch_background.xml | 1 - .../main/res/drawable/launch_background.xml | 1 - lib/screens/beacon_scan_screen.dart | 240 +++++++---- lib/screens/splash_screen.dart | 371 +++++------------- lib/services/beacon_permissions.dart | 42 +- lib/services/beacon_scanner_service.dart | 137 ++++--- 6 files changed, 345 insertions(+), 447 deletions(-) diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index 5f7a7df..3e082a6 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,5 +1,4 @@ - diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 5f7a7df..3e082a6 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,5 +1,4 @@ - diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart index 0f6290b..970e0aa 100644 --- a/lib/screens/beacon_scan_screen.dart +++ b/lib/screens/beacon_scan_screen.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; import 'package:provider/provider.dart'; import '../app/app_router.dart'; import '../app/app_state.dart'; +import '../services/beacon_channel.dart'; import '../services/beacon_permissions.dart'; import '../services/api.dart'; @@ -112,100 +114,19 @@ class _BeaconScanScreenState extends State with SingleTickerPr Future _performInitialScan() async { try { - // Initialize beacon monitoring - await flutterBeacon.initializeScanning; - - // Brief delay to let Bluetooth subsystem fully initialize - await Future.delayed(const Duration(milliseconds: 1500)); - - // Create regions for all known UUIDs - final regions = _uuidToBeaconId.keys.map((uuid) { - // Format UUID with dashes for the plugin - 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(); - - if (regions.isEmpty) { + if (_uuidToBeaconId.isEmpty) { if (mounted) _navigateToRestaurantSelect(); return; } - // Perform scan cycles - for (int scanCycle = 1; scanCycle <= 3; scanCycle++) { - if (mounted) { - setState(() => _status = _scanMessages[scanCycle - 1]); - } - - StreamSubscription? subscription; - - subscription = flutterBeacon.ranging(regions).listen((result) { - for (var beacon in result.beacons) { - final rawUUID = beacon.proximityUUID; - final uuid = rawUUID.toUpperCase().replaceAll('-', ''); - final rssi = beacon.rssi; - - if (_uuidToBeaconId.containsKey(uuid)) { - _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); - _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; - } - } - }); - - await Future.delayed(const Duration(milliseconds: 2000)); - await subscription.cancel(); - - if (scanCycle < 3) { - await Future.delayed(const Duration(milliseconds: 500)); - } - } - - if (!mounted) return; - - // Analyze results and select best beacon - final beaconScores = {}; - - for (final uuid in _beaconRssiSamples.keys) { - final samples = _beaconRssiSamples[uuid]!; - final detections = _beaconDetectionCount[uuid]!; - final beaconId = _uuidToBeaconId[uuid]!; - - final avgRssi = samples.reduce((a, b) => a + b) / samples.length; - final variance = samples.map((r) => (r - avgRssi) * (r - avgRssi)).reduce((a, b) => a + b) / samples.length; - - beaconScores[uuid] = BeaconScore( - uuid: uuid, - beaconId: beaconId, - avgRssi: avgRssi, - minRssi: samples.reduce((a, b) => a < b ? a : b), - maxRssi: samples.reduce((a, b) => a > b ? a : b), - detectionCount: detections, - variance: variance, - ); - } - - if (beaconScores.isEmpty) { - setState(() { - _scanning = false; - _status = 'No nearby tables detected'; - }); - await Future.delayed(const Duration(milliseconds: 500)); - if (mounted) _navigateToRestaurantSelect(); - return; + // Use native scanner on Android, Flutter plugin on iOS + if (Platform.isAndroid) { + await _scanWithNativeScanner(); } else { - final best = _findBestBeacon(beaconScores); - if (best != null) { - setState(() => _status = 'Beacon detected! Loading business...'); - await _autoSelectBusinessFromBeacon(best.beaconId); - } else { - setState(() { - _scanning = false; - _status = 'No strong beacon signal'; - }); - await Future.delayed(const Duration(milliseconds: 500)); - if (mounted) _navigateToRestaurantSelect(); - } + await _scanWithFlutterPlugin(); } } catch (e) { + debugPrint('[BeaconScan] Scan error: $e'); if (mounted) { setState(() { _scanning = false; @@ -217,6 +138,151 @@ class _BeaconScanScreenState extends State with SingleTickerPr } } + /// Native Android scanner - fast and accurate + Future _scanWithNativeScanner() async { + if (mounted) { + setState(() => _status = _scanMessages[0]); + } + + debugPrint('[BeaconScan] Using native Android scanner...'); + final detectedBeacons = await BeaconChannel.startScan( + regions: _uuidToBeaconId.keys.toList(), + ); + + if (!mounted) return; + + if (detectedBeacons.isEmpty) { + setState(() { + _scanning = false; + _status = 'No nearby tables detected'; + }); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) _navigateToRestaurantSelect(); + return; + } + + // Filter to known beacons and build scores + final beaconScores = {}; + for (final beacon in detectedBeacons) { + if (_uuidToBeaconId.containsKey(beacon.uuid)) { + _beaconRssiSamples[beacon.uuid] = [beacon.rssi]; + _beaconDetectionCount[beacon.uuid] = beacon.samples; + + beaconScores[beacon.uuid] = BeaconScore( + uuid: beacon.uuid, + beaconId: _uuidToBeaconId[beacon.uuid]!, + avgRssi: beacon.rssi.toDouble(), + minRssi: beacon.rssi, + maxRssi: beacon.rssi, + detectionCount: beacon.samples, + variance: 0, + ); + } + } + + await _processBeaconScores(beaconScores); + } + + /// iOS fallback using Flutter plugin + Future _scanWithFlutterPlugin() async { + // Initialize beacon monitoring + await flutterBeacon.initializeScanning; + + // Brief delay to let Bluetooth subsystem fully initialize + await Future.delayed(const Duration(milliseconds: 1500)); + + // Create regions for all known UUIDs + final regions = _uuidToBeaconId.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(); + + if (regions.isEmpty) { + if (mounted) _navigateToRestaurantSelect(); + return; + } + + // Perform scan cycles (fewer cycles needed) + for (int scanCycle = 1; scanCycle <= 2; scanCycle++) { + if (mounted) { + setState(() => _status = _scanMessages[scanCycle - 1]); + } + + StreamSubscription? subscription; + subscription = flutterBeacon.ranging(regions).listen((result) { + for (var beacon in result.beacons) { + final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); + final rssi = beacon.rssi; + + if (_uuidToBeaconId.containsKey(uuid)) { + _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); + _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; + } + } + }); + + await Future.delayed(const Duration(milliseconds: 2000)); + await subscription.cancel(); + + if (scanCycle < 2) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + if (!mounted) return; + + // Analyze results + final beaconScores = {}; + for (final uuid in _beaconRssiSamples.keys) { + final samples = _beaconRssiSamples[uuid]!; + final detections = _beaconDetectionCount[uuid]!; + final beaconId = _uuidToBeaconId[uuid]!; + + final avgRssi = samples.reduce((a, b) => a + b) / samples.length; + final variance = samples.map((r) => (r - avgRssi) * (r - avgRssi)).reduce((a, b) => a + b) / samples.length; + + beaconScores[uuid] = BeaconScore( + uuid: uuid, + beaconId: beaconId, + avgRssi: avgRssi, + minRssi: samples.reduce((a, b) => a < b ? a : b), + maxRssi: samples.reduce((a, b) => a > b ? a : b), + detectionCount: detections, + variance: variance, + ); + } + + await _processBeaconScores(beaconScores); + } + + /// Process beacon scores and navigate + Future _processBeaconScores(Map beaconScores) async { + if (!mounted) return; + + if (beaconScores.isEmpty) { + setState(() { + _scanning = false; + _status = 'No nearby tables detected'; + }); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) _navigateToRestaurantSelect(); + return; + } + + final best = _findBestBeacon(beaconScores); + if (best != null) { + setState(() => _status = 'Beacon detected! Loading business...'); + await _autoSelectBusinessFromBeacon(best.beaconId); + } else { + setState(() { + _scanning = false; + _status = 'No strong beacon signal'; + }); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) _navigateToRestaurantSelect(); + } + } + Future _autoSelectBusinessFromBeacon(int beaconId) async { try { final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index e05b703..a2e93cb 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,5 +1,4 @@ import "dart:async"; -import "dart:math"; import "package:flutter/material.dart"; import "package:provider/provider.dart"; @@ -10,7 +9,6 @@ import "../services/auth_storage.dart"; import "../services/beacon_cache.dart"; import "../services/beacon_channel.dart"; import "../services/beacon_permissions.dart"; -import "../services/preload_cache.dart"; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -19,142 +17,28 @@ class SplashScreen extends StatefulWidget { State createState() => _SplashScreenState(); } -class _SplashScreenState extends State with TickerProviderStateMixin { - // Bouncing logo animation - late AnimationController _bounceController; - double _x = 100; - double _y = 100; - double _dx = 2.5; - double _dy = 2.0; - Color _logoColor = Colors.white; - final Random _random = Random(); - - // Rotating status text - Timer? _statusTimer; - int _statusIndex = 0; - static const List _statusPhrases = [ - "scanning...", - "listening...", - "searching...", - "locating...", - "analyzing...", - "connecting...", - ]; - +class _SplashScreenState extends State { // Beacon scanning state - bool _scanComplete = false; BeaconLookupResult? _bestBeacon; - static const List _colors = [ - Colors.white, - Colors.red, - Colors.green, - Colors.blue, - Colors.yellow, - Colors.purple, - Colors.cyan, - Colors.orange, - ]; - @override void initState() { super.initState(); - print('[Splash] Starting with native beacon scanner'); - - // Start bouncing animation - _bounceController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 16), // ~60fps - )..addListener(_updatePosition)..repeat(); - - // Start rotating status text (randomized) - _statusTimer = Timer.periodic(const Duration(milliseconds: 1600), (_) { - if (mounted) { - setState(() { - int newIndex; - do { - newIndex = _random.nextInt(_statusPhrases.length); - } while (newIndex == _statusIndex && _statusPhrases.length > 1); - _statusIndex = newIndex; - }); - } - }); - - // Start the initialization flow + debugPrint('[Splash] Starting...'); _initializeApp(); } - void _updatePosition() { - if (!mounted) return; - - final size = MediaQuery.of(context).size; - const logoWidth = 180.0; - const logoHeight = 60.0; - - // Skip if screen size not yet available - if (size.width <= logoWidth || size.height <= logoHeight) return; - - final maxX = size.width - logoWidth; - final maxY = size.height - logoHeight; - - setState(() { - _x += _dx; - _y += _dy; - - // Bounce off edges and change color - if (_x <= 0 || _x >= maxX) { - _dx = -_dx; - _changeColor(); - } - if (_y <= 0 || _y >= maxY) { - _dy = -_dy; - _changeColor(); - } - - // Keep in bounds - _x = _x.clamp(0.0, maxX); - _y = _y.clamp(0.0, maxY); - }); - } - - void _changeColor() { - final newColor = _colors[_random.nextInt(_colors.length)]; - if (newColor != _logoColor) { - _logoColor = newColor; - } - } - Future _initializeApp() async { - // Run auth check and preloading in parallel for faster startup - print('[Splash] Starting parallel initialization...'); + debugPrint('[Splash] Initializing...'); - // Start preloading data in background (fire and forget for non-critical data) - PreloadCache.preloadAll(); + // Run auth check and beacon prep in parallel + final authFuture = _checkAuth(); + final beaconPrepFuture = _prepareBeaconScan(); - // Check for saved auth credentials - print('[Splash] Checking for saved auth credentials...'); - final credentials = await AuthStorage.loadAuth(); - if (credentials != null && mounted) { - print('[Splash] Found saved credentials: UserID=${credentials.userId}, token=${credentials.token.substring(0, 8)}...'); - Api.setAuthToken(credentials.token); + // Wait for both to complete + await Future.wait([authFuture, beaconPrepFuture]); - // Validate token is still valid by calling profile endpoint - print('[Splash] Validating token with server...'); - final isValid = await _validateToken(); - if (isValid && mounted) { - print('[Splash] Token is valid'); - final appState = context.read(); - appState.setUserId(credentials.userId); - } else { - print('[Splash] Token is invalid or expired, clearing saved auth'); - await AuthStorage.clearAuth(); - Api.clearAuthToken(); - } - } else { - print('[Splash] No saved credentials found'); - } - - // Start beacon scanning + // Now do the beacon scan (needs permissions from prep) await _performBeaconScan(); // Navigate based on results @@ -162,152 +46,117 @@ class _SplashScreenState extends State with TickerProviderStateMix _navigateToNextScreen(); } - /// Validates the stored token by making a profile API call + Future _checkAuth() async { + final credentials = await AuthStorage.loadAuth(); + if (credentials != null && mounted) { + debugPrint('[Splash] Found saved credentials'); + Api.setAuthToken(credentials.token); + + // Validate token in background - don't block startup + _validateToken().then((isValid) { + if (isValid && mounted) { + final appState = context.read(); + appState.setUserId(credentials.userId); + } else { + AuthStorage.clearAuth(); + Api.clearAuthToken(); + } + }); + } + } + + Future _prepareBeaconScan() async { + // Request permissions (this is the slow part) + await BeaconPermissions.requestPermissions(); + } + Future _validateToken() async { try { final profile = await Api.getProfile(); return profile.userId > 0; } catch (e) { - print('[Splash] Token validation failed: $e'); + debugPrint('[Splash] Token validation failed: $e'); return false; } } Future _performBeaconScan() async { - print('[Splash] Starting beacon scan...'); - - // Request permissions if needed - final granted = await BeaconPermissions.requestPermissions(); - if (!granted) { - print('[Splash] Permissions denied'); - _scanComplete = true; + // Check permissions (already requested in parallel) + final hasPerms = await BeaconPermissions.checkPermissions(); + if (!hasPerms) { + debugPrint('[Splash] Permissions not granted'); return; } - // Check if Bluetooth is enabled - print('[Splash] Checking Bluetooth state...'); - final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); + // Check Bluetooth + final bluetoothOn = await BeaconPermissions.isBluetoothEnabled(); if (!bluetoothOn) { - print('[Splash] Bluetooth is OFF - cannot scan for beacons'); - _scanComplete = true; + debugPrint('[Splash] Bluetooth is OFF'); return; } - print('[Splash] Bluetooth is ON'); - // Load known beacons from cache or server (for UUID filtering) - print('[Splash] Loading beacon list...'); + // Load known beacons from cache or server Map knownBeacons = {}; - - // Try cache first final cached = await BeaconCache.load(); if (cached != null && cached.isNotEmpty) { - print('[Splash] Got ${cached.length} beacon UUIDs from cache'); knownBeacons = cached; - // Refresh cache in background (fire and forget) - Api.listAllBeacons().then((fresh) { - BeaconCache.save(fresh); - print('[Splash] Background refresh: saved ${fresh.length} beacons to cache'); - }).catchError((e) { - print('[Splash] Background refresh failed: $e'); - }); + // Refresh cache in background + Api.listAllBeacons().then((fresh) => BeaconCache.save(fresh)); } else { - // No cache - must fetch from server try { knownBeacons = await Api.listAllBeacons(); - print('[Splash] Got ${knownBeacons.length} beacon UUIDs from server'); - // Save to cache await BeaconCache.save(knownBeacons); } catch (e) { - print('[Splash] Failed to fetch beacons: $e'); - _scanComplete = true; + debugPrint('[Splash] Failed to fetch beacons: $e'); return; } } - if (knownBeacons.isEmpty) { - print('[Splash] No beacons configured'); - _scanComplete = true; + if (knownBeacons.isEmpty) return; + + // Scan for beacons + debugPrint('[Splash] Scanning...'); + final detectedBeacons = await BeaconChannel.startScan( + regions: knownBeacons.keys.toList(), + ); + + if (detectedBeacons.isEmpty) { + debugPrint('[Splash] No beacons detected'); return; } - // Use native beacon scanner + debugPrint('[Splash] Found ${detectedBeacons.length} beacons'); + + // Filter to known beacons + final validBeacons = detectedBeacons + .where((b) => knownBeacons.containsKey(b.uuid)) + .toList(); + + if (validBeacons.isEmpty) return; + + // Look up business info + final uuids = validBeacons.map((b) => b.uuid).toList(); try { - print('[Splash] Starting native beacon scan...'); + final lookupResults = await Api.lookupBeacons(uuids); + if (lookupResults.isNotEmpty) { + final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi}; + BeaconLookupResult? best; + int bestRssi = -999; - // Scan using native channel - final detectedBeacons = await BeaconChannel.startScan( - regions: knownBeacons.keys.toList(), - ); - - if (detectedBeacons.isEmpty) { - print('[Splash] No beacons detected'); - _scanComplete = true; - return; - } - - print('[Splash] Detected ${detectedBeacons.length} beacons'); - - // Filter to only known beacons with good RSSI - final validBeacons = detectedBeacons - .where((b) => knownBeacons.containsKey(b.uuid) && b.rssi >= -85) - .toList(); - - if (validBeacons.isEmpty) { - print('[Splash] No valid beacons (known + RSSI >= -85)'); - - // Fall back to strongest detected beacon that's known - final knownDetected = detectedBeacons - .where((b) => knownBeacons.containsKey(b.uuid)) - .toList(); - - if (knownDetected.isNotEmpty) { - validBeacons.add(knownDetected.first); - print('[Splash] Using fallback: ${knownDetected.first.uuid} RSSI=${knownDetected.first.rssi}'); - } - } - - if (validBeacons.isEmpty) { - print('[Splash] No known beacons found'); - _scanComplete = true; - return; - } - - // Look up business info for detected beacons - final uuids = validBeacons.map((b) => b.uuid).toList(); - print('[Splash] Looking up ${uuids.length} beacons...'); - - try { - final lookupResults = await Api.lookupBeacons(uuids); - print('[Splash] Server returned ${lookupResults.length} registered beacons'); - - // Find the best beacon (strongest RSSI that's registered) - if (lookupResults.isNotEmpty) { - // Build a map of UUID -> RSSI from detected beacons - final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi}; - - // Find the best registered beacon based on RSSI - BeaconLookupResult? best; - int bestRssi = -999; - - for (final result in lookupResults) { - final rssi = rssiMap[result.uuid] ?? -100; - if (rssi > bestRssi) { - bestRssi = rssi; - best = result; - } + for (final result in lookupResults) { + final rssi = rssiMap[result.uuid] ?? -100; + if (rssi > bestRssi) { + bestRssi = rssi; + best = result; } - - _bestBeacon = best; - print('[Splash] Best beacon: ${_bestBeacon?.beaconName ?? "none"} (RSSI=$bestRssi)'); } - } catch (e) { - print('[Splash] Error looking up beacons: $e'); + + _bestBeacon = best; + debugPrint('[Splash] Best: ${_bestBeacon?.beaconName} (RSSI=$bestRssi)'); } } catch (e) { - print('[Splash] Scan error: $e'); + debugPrint('[Splash] Lookup error: $e'); } - - _scanComplete = true; } Future _navigateToNextScreen() async { @@ -374,61 +223,19 @@ class _SplashScreenState extends State with TickerProviderStateMix Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } - @override - void dispose() { - _bounceController.dispose(); - _statusTimer?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return Scaffold( + return const Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - // Centered static status text - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - "site survey", - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w300, - letterSpacing: 1, - ), - ), - const SizedBox(height: 6), - Text( - _statusPhrases[_statusIndex], - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w300, - letterSpacing: 1, - ), - ), - ], - ), + body: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white54, ), - // Bouncing logo - Positioned( - left: _x, - top: _y, - child: Text( - "PAYFRIT", - style: TextStyle( - color: _logoColor, - fontSize: 38, - fontWeight: FontWeight.w800, - letterSpacing: 3, - ), - ), - ), - ], + ), ), ); } diff --git a/lib/services/beacon_permissions.dart b/lib/services/beacon_permissions.dart index 161f8f6..839da1b 100644 --- a/lib/services/beacon_permissions.dart +++ b/lib/services/beacon_permissions.dart @@ -8,19 +8,27 @@ import 'beacon_channel.dart'; class BeaconPermissions { static Future requestPermissions() async { try { - // Request location permission (required for Bluetooth scanning) + // On Android, check native first (fast path) + if (Platform.isAndroid) { + final hasPerms = await BeaconChannel.hasPermissions(); + if (hasPerms) { + debugPrint('[BeaconPermissions] Native check: granted'); + return true; + } + debugPrint('[BeaconPermissions] Native check: not granted, requesting...'); + } + + // Request via Flutter plugin (slow but shows system dialogs) final locationStatus = await Permission.locationWhenInUse.request(); debugPrint('[BeaconPermissions] Location: $locationStatus'); bool bluetoothGranted = true; if (Platform.isIOS) { - // iOS uses a single Bluetooth permission final bluetoothStatus = await Permission.bluetooth.request(); debugPrint('[BeaconPermissions] Bluetooth (iOS): $bluetoothStatus'); bluetoothGranted = bluetoothStatus.isGranted; } else { - // Android 12+ requires separate scan/connect permissions final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); debugPrint('[BeaconPermissions] BluetoothScan: $bluetoothScan, BluetoothConnect: $bluetoothConnect'); @@ -28,16 +36,10 @@ class BeaconPermissions { } final allGranted = locationStatus.isGranted && bluetoothGranted; - - if (allGranted) { - debugPrint('[BeaconPermissions] All permissions granted'); - } else { - debugPrint('[BeaconPermissions] Permissions denied'); - } - + debugPrint('[BeaconPermissions] All granted: $allGranted'); return allGranted; } catch (e) { - debugPrint('[BeaconPermissions] Error requesting permissions: $e'); + debugPrint('[BeaconPermissions] Error: $e'); return false; } } @@ -137,19 +139,15 @@ class BeaconPermissions { } static Future checkPermissions() async { - final locationStatus = await Permission.locationWhenInUse.status; - - bool bluetoothGranted = true; - if (Platform.isIOS) { - final bluetoothStatus = await Permission.bluetooth.status; - bluetoothGranted = bluetoothStatus.isGranted; - } else { - final bluetoothScan = await Permission.bluetoothScan.status; - final bluetoothConnect = await Permission.bluetoothConnect.status; - bluetoothGranted = bluetoothScan.isGranted && bluetoothConnect.isGranted; + // On Android, use native check (much faster) + if (Platform.isAndroid) { + return await BeaconChannel.hasPermissions(); } - return locationStatus.isGranted && bluetoothGranted; + // iOS: use Flutter plugin + final locationStatus = await Permission.locationWhenInUse.status; + final bluetoothStatus = await Permission.bluetooth.status; + return locationStatus.isGranted && bluetoothStatus.isGranted; } static Future openSettings() async { diff --git a/lib/services/beacon_scanner_service.dart b/lib/services/beacon_scanner_service.dart index 550e90d..2fab70a 100644 --- a/lib/services/beacon_scanner_service.dart +++ b/lib/services/beacon_scanner_service.dart @@ -1,8 +1,10 @@ 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 @@ -88,64 +90,62 @@ class BeaconScannerService { return const BeaconScanResult(error: "No beacons configured"); } - // Initialize scanning - await flutterBeacon.initializeScanning; + // Use native scanner on Android, Flutter plugin on iOS + List detectedBeacons = []; - // 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(); + if (Platform.isAndroid) { + debugPrint('[BeaconScanner] Using native Android scanner...'); + detectedBeacons = await BeaconChannel.startScan( + regions: knownBeacons.keys.toList(), + ); + } else { + // iOS: use Flutter plugin + debugPrint('[BeaconScanner] Using Flutter plugin for iOS...'); + detectedBeacons = await _scanWithFlutterPlugin(knownBeacons); + } - // Collect RSSI samples - final Map> rssiSamples = {}; - final Map detectionCounts = {}; - - debugPrint('[BeaconScanner] 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); - detectionCounts[uuid] = (detectionCounts[uuid] ?? 0) + 1; - debugPrint('[BeaconScanner] Found $uuid RSSI=$rssi'); - } - }); - - await Future.delayed(const Duration(milliseconds: 2000)); - await subscription.cancel(); - - if (rssiSamples.isEmpty) { + if (detectedBeacons.isEmpty) { debugPrint('[BeaconScanner] No beacons detected'); return const BeaconScanResult(beaconsFound: 0); } - // Lookup found beacons - debugPrint('[BeaconScanner] Looking up ${rssiSamples.length} beacons...'); - final uuids = rssiSamples.keys.toList(); - List lookupResults = []; + 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: rssiSamples.length, + beaconsFound: validBeacons.length, error: "Failed to lookup beacons", ); } - // Find the best registered beacon - final bestBeacon = _findBestBeacon(lookupResults, rssiSamples, detectionCounts); + // 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: rssiSamples.length, + beaconsFound: validBeacons.length, ); } catch (e) { debugPrint('[BeaconScanner] Scan error: $e'); @@ -156,27 +156,59 @@ class BeaconScannerService { } } + /// 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> rssiSamples, - Map detectionCounts, + Map rssiMap, ) { if (registeredBeacons.isEmpty) return null; BeaconLookupResult? best; - double bestAvgRssi = -999; + int bestRssi = -999; + // First pass: find beacon with RSSI >= -85 for (final beacon in registeredBeacons) { - final samples = rssiSamples[beacon.uuid]; - if (samples == null || samples.isEmpty) continue; - - final detections = detectionCounts[beacon.uuid] ?? 0; - if (detections < 2) continue; // Need at least 2 detections - - final avgRssi = samples.reduce((a, b) => a + b) / samples.length; - if (avgRssi > bestAvgRssi && avgRssi >= -85) { - bestAvgRssi = avgRssi; + final rssi = rssiMap[beacon.uuid] ?? -100; + if (rssi > bestRssi && rssi >= -85) { + bestRssi = rssi; best = beacon; } } @@ -184,12 +216,9 @@ class BeaconScannerService { // Fall back to strongest if none meet threshold if (best == null) { for (final beacon in registeredBeacons) { - final samples = rssiSamples[beacon.uuid]; - if (samples == null || samples.isEmpty) continue; - - final avgRssi = samples.reduce((a, b) => a + b) / samples.length; - if (avgRssi > bestAvgRssi) { - bestAvgRssi = avgRssi; + final rssi = rssiMap[beacon.uuid] ?? -100; + if (rssi > bestRssi) { + bestRssi = rssi; best = beacon; } }