- Remove neverForLocation flag from BLUETOOTH_SCAN (was blocking beacon detection) - Add Bluetooth state check before scanning with prompt to enable - Add iOS Bluetooth/Location permission descriptions and UIBackgroundModes - Fix exclusive modifier selection (deselect siblings when max=1) - Update cart service point when existing cart found - Add delivery fee support to cart and stripe service 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
518 lines
18 KiB
Dart
518 lines
18 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...');
|
|
print('[BeaconScan] 🔐 Requesting permissions...');
|
|
|
|
final granted = await BeaconPermissions.requestPermissions();
|
|
|
|
if (!granted) {
|
|
print('[BeaconScan] ❌ Permissions DENIED');
|
|
setState(() {
|
|
_status = 'Permissions denied - Please enable Location & Bluetooth';
|
|
_permissionsGranted = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
print('[BeaconScan] ✅ Permissions GRANTED');
|
|
setState(() => _permissionsGranted = true);
|
|
|
|
// Step 1.5: Check if Bluetooth is ON
|
|
setState(() => _status = 'Checking Bluetooth...');
|
|
print('[BeaconScan] 📶 Checking Bluetooth state...');
|
|
|
|
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
|
|
|
|
if (!bluetoothOn) {
|
|
print('[BeaconScan] ❌ Bluetooth is OFF');
|
|
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;
|
|
}
|
|
|
|
print('[BeaconScan] ✅ Bluetooth is ON');
|
|
|
|
// Step 2: Fetch all active beacons from server
|
|
setState(() => _status = 'Loading beacon data...');
|
|
|
|
try {
|
|
_uuidToBeaconId = await Api.listAllBeacons();
|
|
print('[BeaconScan] ========================================');
|
|
print('[BeaconScan] Loaded ${_uuidToBeaconId.length} beacons from database:');
|
|
_uuidToBeaconId.forEach((uuid, beaconId) {
|
|
print('[BeaconScan] BeaconID=$beaconId');
|
|
print('[BeaconScan] UUID=$uuid');
|
|
print('[BeaconScan] ---');
|
|
});
|
|
print('[BeaconScan] ========================================');
|
|
} catch (e) {
|
|
debugPrint('[BeaconScan] Error loading beacons: $e');
|
|
_uuidToBeaconId = {};
|
|
}
|
|
|
|
if (_uuidToBeaconId.isEmpty) {
|
|
print('[BeaconScan] ⚠️ No beacons in database, going to restaurant select');
|
|
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
|
|
// Without this, the first scan cycle may complete immediately with no results
|
|
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)}';
|
|
print('[BeaconScan] 🔍 Creating region for UUID: $uuid -> $formattedUUID');
|
|
return Region(identifier: uuid, proximityUUID: formattedUUID);
|
|
}).toList();
|
|
|
|
print('[BeaconScan] 📡 Created ${regions.length} regions for scanning');
|
|
|
|
if (regions.isEmpty) {
|
|
if (mounted) _navigateToRestaurantSelect();
|
|
return;
|
|
}
|
|
|
|
// Perform scan cycles - always complete all cycles for dine-in beacon detection
|
|
print('[BeaconScan] 🔄 Starting scan cycles');
|
|
|
|
for (int scanCycle = 1; scanCycle <= 3; scanCycle++) {
|
|
// Update status message for each cycle
|
|
if (mounted) {
|
|
setState(() => _status = _scanMessages[scanCycle - 1]);
|
|
}
|
|
print('[BeaconScan] ----- Scan cycle $scanCycle/3 -----');
|
|
|
|
StreamSubscription<RangingResult>? subscription;
|
|
|
|
subscription = flutterBeacon.ranging(regions).listen((result) {
|
|
print('[BeaconScan] 📶 Ranging result: ${result.beacons.length} beacons in range');
|
|
for (var beacon in result.beacons) {
|
|
final rawUUID = beacon.proximityUUID;
|
|
final uuid = rawUUID.toUpperCase().replaceAll('-', '');
|
|
final rssi = beacon.rssi;
|
|
|
|
print('[BeaconScan] 📍 Raw beacon detected: UUID=$rawUUID (normalized: $uuid), RSSI=$rssi, Major=${beacon.major}, Minor=${beacon.minor}');
|
|
|
|
if (_uuidToBeaconId.containsKey(uuid)) {
|
|
// Collect RSSI samples for averaging
|
|
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
|
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
|
|
|
|
print('[BeaconScan] ✅ MATCHED! BeaconID=${_uuidToBeaconId[uuid]}, Sample #${_beaconDetectionCount[uuid]}, RSSI=$rssi');
|
|
} else {
|
|
print('[BeaconScan] ⚠️ UUID not in database, ignoring');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wait for this scan cycle to collect beacon data
|
|
await Future.delayed(const Duration(milliseconds: 2000));
|
|
await subscription.cancel();
|
|
|
|
// Short pause between scan cycles
|
|
if (scanCycle < 3) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
}
|
|
}
|
|
|
|
print('[BeaconScan] ✔️ Scan complete');
|
|
|
|
if (!mounted) return;
|
|
|
|
// Analyze results and select best beacon
|
|
print('[BeaconScan] ===== SCAN COMPLETE =====');
|
|
print('[BeaconScan] 📊 Total beacons detected: ${_beaconRssiSamples.length}');
|
|
|
|
final beaconScores = <String, BeaconScore>{};
|
|
|
|
for (final uuid in _beaconRssiSamples.keys) {
|
|
final samples = _beaconRssiSamples[uuid]!;
|
|
final detections = _beaconDetectionCount[uuid]!;
|
|
final beaconId = _uuidToBeaconId[uuid]!;
|
|
|
|
// Calculate average RSSI
|
|
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
|
|
|
// Calculate RSSI variance for stability metric
|
|
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.isNotEmpty) {
|
|
print('[BeaconScan] Beacon analysis results:');
|
|
final sorted = beaconScores.values.toList()
|
|
..sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); // Sort by avg RSSI descending
|
|
|
|
for (final score in sorted) {
|
|
print('[BeaconScan] - BeaconID=${score.beaconId}:');
|
|
print('[BeaconScan] Avg RSSI: ${score.avgRssi.toStringAsFixed(1)}');
|
|
print('[BeaconScan] Range: ${score.minRssi} to ${score.maxRssi}');
|
|
print('[BeaconScan] Detections: ${score.detectionCount}');
|
|
print('[BeaconScan] Variance: ${score.variance.toStringAsFixed(2)}');
|
|
}
|
|
}
|
|
print('[BeaconScan] ==========================');
|
|
|
|
if (beaconScores.isEmpty) {
|
|
// No Payfrit beacons found - stop scanning and go to business list
|
|
print('[BeaconScan] 🚫 No Payfrit beacons found, navigating to restaurant select');
|
|
setState(() {
|
|
_scanning = false;
|
|
_status = 'No nearby tables detected';
|
|
});
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
if (mounted) _navigateToRestaurantSelect();
|
|
return;
|
|
} else {
|
|
// Find beacon with highest average RSSI and minimum detections
|
|
final best = _findBestBeacon(beaconScores);
|
|
if (best != null) {
|
|
print('[BeaconScan] 🎯 Selected beacon: BeaconID=${best.beaconId} (avg RSSI: ${best.avgRssi.toStringAsFixed(1)})');
|
|
setState(() => _status = 'Beacon detected! Loading business...');
|
|
await _autoSelectBusinessFromBeacon(best.beaconId);
|
|
} else {
|
|
print('[BeaconScan] ⚠️ No beacon met minimum confidence threshold');
|
|
setState(() {
|
|
_scanning = false;
|
|
_status = 'No strong beacon signal';
|
|
});
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
if (mounted) _navigateToRestaurantSelect();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[BeaconScan] ❌ ERROR during scan: $e');
|
|
print('[BeaconScan] Stack trace: ${StackTrace.current}');
|
|
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 (one beacon significantly stronger than others)
|
|
// or if all beacons have low variance in their readings
|
|
for (final entry in _beaconRssiSamples.entries) {
|
|
final samples = entry.value;
|
|
if (samples.length < 3) continue;
|
|
|
|
// Calculate variance
|
|
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 (readings fluctuating a lot), keep scanning
|
|
// Variance > 50 means RSSI is jumping around too much
|
|
if (variance > 50) {
|
|
print('[BeaconScan] ⏳ Beacon ${_uuidToBeaconId[entry.key]} has high variance (${variance.toStringAsFixed(1)}), continuing scan');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If we have 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;
|
|
}
|
|
}
|
|
|
|
// Sort by RSSI descending
|
|
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) {
|
|
print('[BeaconScan] ⏳ Top 2 beacons are close (diff=${diff.toStringAsFixed(1)} dB), continuing scan');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
print('[BeaconScan] ✓ Readings are stable, can exit early');
|
|
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,
|
|
});
|
|
}
|