payfrit-app/lib/screens/beacon_scan_screen.dart
John Mizerek 768b882ca7 Clean up debug statements and sanitize error messages
- Remove 60+ debug print statements from services and screens
- Sanitize error messages to not expose internal API details
- Remove stack trace exposure in beacon_scan_screen
- Delete unused order_home_screen.dart
- Remove unused ChatScreen route from app_router
- Fix widget_test.dart to compile
- Remove unused foundation.dart import from menu_browse_screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:44:58 -08:00

446 lines
14 KiB
Dart

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<BeaconScanScreen> createState() => _BeaconScanScreenState();
}
class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerProviderStateMixin {
String _status = 'Initializing...';
bool _permissionsGranted = false;
bool _scanning = false;
Map<String, int> _uuidToBeaconId = {};
final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
// Rotating scan messages
static const List<String> _scanMessages = [
'Looking for your table...',
'Scanning nearby...',
'Almost there...',
'Checking signal strength...',
'Finalizing...',
];
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.2).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_startScanFlow();
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Future<void> _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<void> _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<RangingResult>? 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 = <String, BeaconScore>{};
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<void> _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>();
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 = <String, double>{};
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<String, BeaconScore> 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,
});
}