import 'dart:async'; 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_permissions.dart'; import '../services/api.dart'; class BeaconScanScreen extends StatefulWidget { const BeaconScanScreen({super.key}); @override State createState() => _BeaconScanScreenState(); } class _BeaconScanScreenState extends State with SingleTickerProviderStateMixin { String _status = 'Initializing...'; bool _permissionsGranted = false; bool _scanning = false; Map _uuidToBeaconId = {}; final Map> _beaconRssiSamples = {}; // UUID -> List of RSSI values final Map _beaconDetectionCount = {}; // UUID -> detection count // Rotating scan messages static const List _scanMessages = [ 'Looking for your table...', 'Scanning nearby...', 'Almost there...', 'Checking signal strength...', 'Finalizing...', ]; late AnimationController _pulseController; late Animation _pulseAnimation; @override void initState() { super.initState(); _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(reverse: true); _pulseAnimation = Tween(begin: 0.8, end: 1.2).animate( CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), ); _startScanFlow(); } @override void dispose() { _pulseController.dispose(); super.dispose(); } Future _startScanFlow() async { // Step 1: Request permissions setState(() => _status = 'Requesting permissions...'); final granted = await BeaconPermissions.requestPermissions(); if (!granted) { setState(() { _status = 'Permissions denied - Please enable Location & Bluetooth'; _permissionsGranted = false; }); return; } setState(() => _permissionsGranted = true); // Step 1.5: Check if Bluetooth is ON setState(() => _status = 'Checking Bluetooth...'); final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); if (!bluetoothOn) { setState(() { _status = 'Please turn on Bluetooth to scan for tables'; _scanning = false; }); // Wait and retry, or let user manually proceed await Future.delayed(const Duration(seconds: 2)); if (mounted) _navigateToRestaurantSelect(); return; } // Step 2: Fetch all active beacons from server setState(() => _status = 'Loading beacon data...'); try { _uuidToBeaconId = await Api.listAllBeacons(); } catch (e) { _uuidToBeaconId = {}; } if (_uuidToBeaconId.isEmpty) { if (mounted) _navigateToRestaurantSelect(); return; } // Step 3: Perform initial scan setState(() { _status = 'Scanning for nearby beacons...'; _scanning = true; }); await _performInitialScan(); } 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 (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; } 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(); } } } catch (e) { if (mounted) { setState(() { _scanning = false; _status = 'Scan error - continuing to manual selection'; }); await Future.delayed(const Duration(milliseconds: 500)); if (mounted) _navigateToRestaurantSelect(); } } } Future _autoSelectBusinessFromBeacon(int beaconId) async { try { final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId); if (!mounted) return; // Update app state with selected business and service point final appState = context.read(); appState.setBusinessAndServicePoint( mapping.businessId, mapping.servicePointId, businessName: mapping.businessName, servicePointName: mapping.servicePointName, ); // Set order type to dine-in since beacon was detected appState.setOrderType(OrderType.dineIn); // Update API business ID for headers Api.setBusinessId(mapping.businessId); setState(() => _status = 'Welcome to ${mapping.businessName}!'); await Future.delayed(const Duration(milliseconds: 800)); if (!mounted) return; // Navigate directly to menu (user can browse without logging in) Navigator.of(context).pushReplacementNamed( AppRoutes.menuBrowse, arguments: { 'businessId': mapping.businessId, 'servicePointId': mapping.servicePointId, }, ); } catch (e) { debugPrint('[BeaconScan] Error fetching business from beacon: $e'); if (mounted) { setState(() => _status = 'Beacon not assigned - selecting manually'); await Future.delayed(const Duration(seconds: 1)); if (mounted) _navigateToRestaurantSelect(); } } } /// Check if we can exit early based on stable readings /// Returns true if all detected beacons have consistent RSSI across scans bool _canExitEarly() { if (_beaconRssiSamples.isEmpty) return false; // Need at least one beacon with 3+ readings bool hasEnoughSamples = _beaconRssiSamples.values.any((samples) => samples.length >= 2); if (!hasEnoughSamples) return false; // Check if there's a clear winner or if all beacons have low variance for (final entry in _beaconRssiSamples.entries) { final samples = entry.value; if (samples.length < 3) continue; final avg = samples.reduce((a, b) => a + b) / samples.length; final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length; // If variance is high, keep scanning if (variance > 50) { return false; } } // If multiple beacons, check if there's a clear strongest one if (_beaconRssiSamples.length > 1) { final avgRssis = {}; for (final entry in _beaconRssiSamples.entries) { final samples = entry.value; if (samples.isNotEmpty) { avgRssis[entry.key] = samples.reduce((a, b) => a + b) / samples.length; } } final sorted = avgRssis.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); // If top two beacons are within 5 dB, keep scanning for more clarity if (sorted.length >= 2) { final diff = sorted[0].value - sorted[1].value; if (diff < 5) { return false; } } } return true; } BeaconScore? _findBestBeacon(Map scores) { if (scores.isEmpty) return null; // Filter beacons that meet minimum requirements const minDetections = 2; // Must be seen at least 3 times const minRssi = -85; // Minimum average RSSI (beacons further than ~10m will be weaker) final qualified = scores.values.where((score) { return score.detectionCount >= minDetections && score.avgRssi >= minRssi; }).toList(); if (qualified.isEmpty) { debugPrint('[BeaconScan] No beacons met minimum requirements (detections>=$minDetections, avgRSSI>=$minRssi)'); // Fall back to best available beacon if none meet threshold final allSorted = scores.values.toList() ..sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); return allSorted.isNotEmpty ? allSorted.first : null; } // Sort by average RSSI (higher is better/closer) qualified.sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); return qualified.first; } void _navigateToRestaurantSelect() { Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } void _retryPermissions() async { await BeaconPermissions.openSettings(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_scanning) ScaleTransition( scale: _pulseAnimation, child: Container( width: 100, height: 100, decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ Colors.blue.withAlpha(102), Colors.blue.withAlpha(26), ], ), ), child: const Icon( Icons.bluetooth_searching, color: Colors.white, size: 48, ), ), ) else if (_permissionsGranted) const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64) else const Icon(Icons.bluetooth_disabled, color: Colors.white70, size: 64), const SizedBox(height: 24), Text( _status, style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), if (_beaconRssiSamples.isNotEmpty) ...[ const SizedBox(height: 16), Text( 'Found ${_beaconRssiSamples.length} beacon(s)', style: const TextStyle(color: Colors.white70, fontSize: 12), ), ], if (!_permissionsGranted && _status.contains('denied')) ...[ const SizedBox(height: 24), FilledButton( onPressed: _retryPermissions, child: const Text('Open Settings'), ), ], // Always show manual selection option during or after scan if (_permissionsGranted) ...[ const SizedBox(height: 32), TextButton( onPressed: _navigateToRestaurantSelect, child: const Text( 'Select Restaurant Manually', style: TextStyle( color: Colors.white70, fontSize: 14, ), ), ), ], ], ), ), ); } } class BeaconScore { final String uuid; final int beaconId; final double avgRssi; final int minRssi; final int maxRssi; final int detectionCount; final double variance; const BeaconScore({ required this.uuid, required this.beaconId, required this.avgRssi, required this.minRssi, required this.maxRssi, required this.detectionCount, required this.variance, }); }