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 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2026-01-27 01:28:16 -08:00
parent d5f0721215
commit 982152383a
6 changed files with 345 additions and 447 deletions

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black" />
</layer-list>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black" />
</layer-list>

View file

@ -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,6 +114,77 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
Future<void> _performInitialScan() async {
try {
if (_uuidToBeaconId.isEmpty) {
if (mounted) _navigateToRestaurantSelect();
return;
}
// Use native scanner on Android, Flutter plugin on iOS
if (Platform.isAndroid) {
await _scanWithNativeScanner();
} else {
await _scanWithFlutterPlugin();
}
} catch (e) {
debugPrint('[BeaconScan] Scan error: $e');
if (mounted) {
setState(() {
_scanning = false;
_status = 'Scan error - continuing to manual selection';
});
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) _navigateToRestaurantSelect();
}
}
}
/// Native Android scanner - fast and accurate
Future<void> _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 = <String, BeaconScore>{};
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<void> _scanWithFlutterPlugin() async {
// Initialize beacon monitoring
await flutterBeacon.initializeScanning;
@ -120,7 +193,6 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
// 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();
@ -130,18 +202,16 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
return;
}
// Perform scan cycles
for (int scanCycle = 1; scanCycle <= 3; scanCycle++) {
// Perform scan cycles (fewer cycles needed)
for (int scanCycle = 1; scanCycle <= 2; 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 uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
final rssi = beacon.rssi;
if (_uuidToBeaconId.containsKey(uuid)) {
@ -154,16 +224,15 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
if (scanCycle < 3) {
if (scanCycle < 2) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
if (!mounted) return;
// Analyze results and select best beacon
// Analyze results
final beaconScores = <String, BeaconScore>{};
for (final uuid in _beaconRssiSamples.keys) {
final samples = _beaconRssiSamples[uuid]!;
final detections = _beaconDetectionCount[uuid]!;
@ -183,6 +252,13 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
);
}
await _processBeaconScores(beaconScores);
}
/// Process beacon scores and navigate
Future<void> _processBeaconScores(Map<String, BeaconScore> beaconScores) async {
if (!mounted) return;
if (beaconScores.isEmpty) {
setState(() {
_scanning = false;
@ -191,7 +267,8 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
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...');
@ -205,17 +282,6 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
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 {

View file

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> 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<String> _statusPhrases = [
"scanning...",
"listening...",
"searching...",
"locating...",
"analyzing...",
"connecting...",
];
class _SplashScreenState extends State<SplashScreen> {
// Beacon scanning state
bool _scanComplete = false;
BeaconLookupResult? _bestBeacon;
static const List<Color> _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<void> _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>();
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,130 +46,100 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
_navigateToNextScreen();
}
/// Validates the stored token by making a profile API call
Future<void> _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>();
appState.setUserId(credentials.userId);
} else {
AuthStorage.clearAuth();
Api.clearAuthToken();
}
});
}
}
Future<void> _prepareBeaconScan() async {
// Request permissions (this is the slow part)
await BeaconPermissions.requestPermissions();
}
Future<bool> _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<void> _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<String, int> 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;
return;
}
if (knownBeacons.isEmpty) return;
// Use native beacon scanner
try {
print('[Splash] Starting native beacon scan...');
// Scan using native channel
// Scan for beacons
debugPrint('[Splash] Scanning...');
final detectedBeacons = await BeaconChannel.startScan(
regions: knownBeacons.keys.toList(),
);
if (detectedBeacons.isEmpty) {
print('[Splash] No beacons detected');
_scanComplete = true;
debugPrint('[Splash] No beacons detected');
return;
}
print('[Splash] Detected ${detectedBeacons.length} beacons');
debugPrint('[Splash] Found ${detectedBeacons.length} beacons');
// Filter to only known beacons with good RSSI
// Filter to known beacons
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) return;
if (validBeacons.isEmpty) {
print('[Splash] No known beacons found');
_scanComplete = true;
return;
}
// Look up business info for detected beacons
// Look up business info
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;
@ -298,16 +152,11 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
}
_bestBeacon = best;
print('[Splash] Best beacon: ${_bestBeacon?.beaconName ?? "none"} (RSSI=$bestRssi)');
debugPrint('[Splash] Best: ${_bestBeacon?.beaconName} (RSSI=$bestRssi)');
}
} catch (e) {
print('[Splash] Error looking up beacons: $e');
debugPrint('[Splash] Lookup error: $e');
}
} catch (e) {
print('[Splash] Scan error: $e');
}
_scanComplete = true;
}
Future<void> _navigateToNextScreen() async {
@ -374,61 +223,19 @@ class _SplashScreenState extends State<SplashScreen> 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,
body: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white54,
),
),
const SizedBox(height: 6),
Text(
_statusPhrases[_statusIndex],
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w300,
letterSpacing: 1,
),
),
],
),
),
// Bouncing logo
Positioned(
left: _x,
top: _y,
child: Text(
"PAYFRIT",
style: TextStyle(
color: _logoColor,
fontSize: 38,
fontWeight: FontWeight.w800,
letterSpacing: 3,
),
),
),
],
),
);
}

View file

@ -8,19 +8,27 @@ import 'beacon_channel.dart';
class BeaconPermissions {
static Future<bool> 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<bool> 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<void> openSettings() async {

View file

@ -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<DetectedBeacon> 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();
// Collect RSSI samples
final Map<String, List<int>> rssiSamples = {};
final Map<String, int> detectionCounts = {};
debugPrint('[BeaconScanner] Starting 2-second scan...');
StreamSubscription<RangingResult>? 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');
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);
}
});
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<BeaconLookupResult> 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<BeaconLookupResult> 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<List<DetectedBeacon>> _scanWithFlutterPlugin(Map<String, int> 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<String, List<int>> rssiSamples = {};
debugPrint('[BeaconScanner] iOS: Starting 2-second scan...');
StreamSubscription<RangingResult>? 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<BeaconLookupResult> registeredBeacons,
Map<String, List<int>> rssiSamples,
Map<String, int> detectionCounts,
Map<String, int> 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;
}
}