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"?> <?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"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black" /> <item android:drawable="@android:color/black" />
</layer-list> </layer-list>

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/black" /> <item android:drawable="@android:color/black" />
</layer-list> </layer-list>

View file

@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../app/app_router.dart'; import '../app/app_router.dart';
import '../app/app_state.dart'; import '../app/app_state.dart';
import '../services/beacon_channel.dart';
import '../services/beacon_permissions.dart'; import '../services/beacon_permissions.dart';
import '../services/api.dart'; import '../services/api.dart';
@ -112,100 +114,19 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
Future<void> _performInitialScan() async { Future<void> _performInitialScan() async {
try { try {
// Initialize beacon monitoring if (_uuidToBeaconId.isEmpty) {
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(); if (mounted) _navigateToRestaurantSelect();
return; return;
} }
// Perform scan cycles // Use native scanner on Android, Flutter plugin on iOS
for (int scanCycle = 1; scanCycle <= 3; scanCycle++) { if (Platform.isAndroid) {
if (mounted) { await _scanWithNativeScanner();
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 { } else {
final best = _findBestBeacon(beaconScores); await _scanWithFlutterPlugin();
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) { } catch (e) {
debugPrint('[BeaconScan] Scan error: $e');
if (mounted) { if (mounted) {
setState(() { setState(() {
_scanning = false; _scanning = false;
@ -217,6 +138,151 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerPr
} }
} }
/// 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;
// 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<RangingResult>? 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 = <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,
);
}
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;
_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<void> _autoSelectBusinessFromBeacon(int beaconId) async { Future<void> _autoSelectBusinessFromBeacon(int beaconId) async {
try { try {
final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId); final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId);

View file

@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "dart:math";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:provider/provider.dart"; import "package:provider/provider.dart";
@ -10,7 +9,6 @@ import "../services/auth_storage.dart";
import "../services/beacon_cache.dart"; import "../services/beacon_cache.dart";
import "../services/beacon_channel.dart"; import "../services/beacon_channel.dart";
import "../services/beacon_permissions.dart"; import "../services/beacon_permissions.dart";
import "../services/preload_cache.dart";
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -19,142 +17,28 @@ class SplashScreen extends StatefulWidget {
State<SplashScreen> createState() => _SplashScreenState(); State<SplashScreen> createState() => _SplashScreenState();
} }
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin { class _SplashScreenState extends State<SplashScreen> {
// 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...",
];
// Beacon scanning state // Beacon scanning state
bool _scanComplete = false;
BeaconLookupResult? _bestBeacon; BeaconLookupResult? _bestBeacon;
static const List<Color> _colors = [
Colors.white,
Colors.red,
Colors.green,
Colors.blue,
Colors.yellow,
Colors.purple,
Colors.cyan,
Colors.orange,
];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
print('[Splash] Starting with native beacon scanner'); debugPrint('[Splash] Starting...');
// 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
_initializeApp(); _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 { Future<void> _initializeApp() async {
// Run auth check and preloading in parallel for faster startup debugPrint('[Splash] Initializing...');
print('[Splash] Starting parallel initialization...');
// Start preloading data in background (fire and forget for non-critical data) // Run auth check and beacon prep in parallel
PreloadCache.preloadAll(); final authFuture = _checkAuth();
final beaconPrepFuture = _prepareBeaconScan();
// Check for saved auth credentials // Wait for both to complete
print('[Splash] Checking for saved auth credentials...'); await Future.wait([authFuture, beaconPrepFuture]);
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);
// Validate token is still valid by calling profile endpoint // Now do the beacon scan (needs permissions from prep)
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
await _performBeaconScan(); await _performBeaconScan();
// Navigate based on results // Navigate based on results
@ -162,152 +46,117 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
_navigateToNextScreen(); _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 { Future<bool> _validateToken() async {
try { try {
final profile = await Api.getProfile(); final profile = await Api.getProfile();
return profile.userId > 0; return profile.userId > 0;
} catch (e) { } catch (e) {
print('[Splash] Token validation failed: $e'); debugPrint('[Splash] Token validation failed: $e');
return false; return false;
} }
} }
Future<void> _performBeaconScan() async { Future<void> _performBeaconScan() async {
print('[Splash] Starting beacon scan...'); // Check permissions (already requested in parallel)
final hasPerms = await BeaconPermissions.checkPermissions();
// Request permissions if needed if (!hasPerms) {
final granted = await BeaconPermissions.requestPermissions(); debugPrint('[Splash] Permissions not granted');
if (!granted) {
print('[Splash] Permissions denied');
_scanComplete = true;
return; return;
} }
// Check if Bluetooth is enabled // Check Bluetooth
print('[Splash] Checking Bluetooth state...'); final bluetoothOn = await BeaconPermissions.isBluetoothEnabled();
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
if (!bluetoothOn) { if (!bluetoothOn) {
print('[Splash] Bluetooth is OFF - cannot scan for beacons'); debugPrint('[Splash] Bluetooth is OFF');
_scanComplete = true;
return; return;
} }
print('[Splash] Bluetooth is ON');
// Load known beacons from cache or server (for UUID filtering) // Load known beacons from cache or server
print('[Splash] Loading beacon list...');
Map<String, int> knownBeacons = {}; Map<String, int> knownBeacons = {};
// Try cache first
final cached = await BeaconCache.load(); final cached = await BeaconCache.load();
if (cached != null && cached.isNotEmpty) { if (cached != null && cached.isNotEmpty) {
print('[Splash] Got ${cached.length} beacon UUIDs from cache');
knownBeacons = cached; knownBeacons = cached;
// Refresh cache in background (fire and forget) // Refresh cache in background
Api.listAllBeacons().then((fresh) { Api.listAllBeacons().then((fresh) => BeaconCache.save(fresh));
BeaconCache.save(fresh);
print('[Splash] Background refresh: saved ${fresh.length} beacons to cache');
}).catchError((e) {
print('[Splash] Background refresh failed: $e');
});
} else { } else {
// No cache - must fetch from server
try { try {
knownBeacons = await Api.listAllBeacons(); knownBeacons = await Api.listAllBeacons();
print('[Splash] Got ${knownBeacons.length} beacon UUIDs from server');
// Save to cache
await BeaconCache.save(knownBeacons); await BeaconCache.save(knownBeacons);
} catch (e) { } catch (e) {
print('[Splash] Failed to fetch beacons: $e'); debugPrint('[Splash] Failed to fetch beacons: $e');
_scanComplete = true;
return; return;
} }
} }
if (knownBeacons.isEmpty) { if (knownBeacons.isEmpty) return;
print('[Splash] No beacons configured');
_scanComplete = true; // Scan for beacons
debugPrint('[Splash] Scanning...');
final detectedBeacons = await BeaconChannel.startScan(
regions: knownBeacons.keys.toList(),
);
if (detectedBeacons.isEmpty) {
debugPrint('[Splash] No beacons detected');
return; 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 { 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 for (final result in lookupResults) {
final detectedBeacons = await BeaconChannel.startScan( final rssi = rssiMap[result.uuid] ?? -100;
regions: knownBeacons.keys.toList(), if (rssi > bestRssi) {
); bestRssi = rssi;
best = result;
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;
}
} }
_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) { } catch (e) {
print('[Splash] Scan error: $e'); debugPrint('[Splash] Lookup error: $e');
} }
_scanComplete = true;
} }
Future<void> _navigateToNextScreen() async { Future<void> _navigateToNextScreen() async {
@ -374,61 +223,19 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
} }
@override
void dispose() {
_bounceController.dispose();
_statusTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return const Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Center(
children: [ child: SizedBox(
// Centered static status text width: 24,
Center( height: 24,
child: Column( child: CircularProgressIndicator(
mainAxisSize: MainAxisSize.min, strokeWidth: 2,
children: [ color: Colors.white54,
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,
),
),
],
),
), ),
// 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 { class BeaconPermissions {
static Future<bool> requestPermissions() async { static Future<bool> requestPermissions() async {
try { 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(); final locationStatus = await Permission.locationWhenInUse.request();
debugPrint('[BeaconPermissions] Location: $locationStatus'); debugPrint('[BeaconPermissions] Location: $locationStatus');
bool bluetoothGranted = true; bool bluetoothGranted = true;
if (Platform.isIOS) { if (Platform.isIOS) {
// iOS uses a single Bluetooth permission
final bluetoothStatus = await Permission.bluetooth.request(); final bluetoothStatus = await Permission.bluetooth.request();
debugPrint('[BeaconPermissions] Bluetooth (iOS): $bluetoothStatus'); debugPrint('[BeaconPermissions] Bluetooth (iOS): $bluetoothStatus');
bluetoothGranted = bluetoothStatus.isGranted; bluetoothGranted = bluetoothStatus.isGranted;
} else { } else {
// Android 12+ requires separate scan/connect permissions
final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothScan = await Permission.bluetoothScan.request();
final bluetoothConnect = await Permission.bluetoothConnect.request(); final bluetoothConnect = await Permission.bluetoothConnect.request();
debugPrint('[BeaconPermissions] BluetoothScan: $bluetoothScan, BluetoothConnect: $bluetoothConnect'); debugPrint('[BeaconPermissions] BluetoothScan: $bluetoothScan, BluetoothConnect: $bluetoothConnect');
@ -28,16 +36,10 @@ class BeaconPermissions {
} }
final allGranted = locationStatus.isGranted && bluetoothGranted; final allGranted = locationStatus.isGranted && bluetoothGranted;
debugPrint('[BeaconPermissions] All granted: $allGranted');
if (allGranted) {
debugPrint('[BeaconPermissions] All permissions granted');
} else {
debugPrint('[BeaconPermissions] Permissions denied');
}
return allGranted; return allGranted;
} catch (e) { } catch (e) {
debugPrint('[BeaconPermissions] Error requesting permissions: $e'); debugPrint('[BeaconPermissions] Error: $e');
return false; return false;
} }
} }
@ -137,19 +139,15 @@ class BeaconPermissions {
} }
static Future<bool> checkPermissions() async { static Future<bool> checkPermissions() async {
final locationStatus = await Permission.locationWhenInUse.status; // On Android, use native check (much faster)
if (Platform.isAndroid) {
bool bluetoothGranted = true; return await BeaconChannel.hasPermissions();
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;
} }
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 { static Future<void> openSettings() async {

View file

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
import 'api.dart'; import 'api.dart';
import 'beacon_channel.dart';
import 'beacon_permissions.dart'; import 'beacon_permissions.dart';
/// Result of a beacon scan /// Result of a beacon scan
@ -88,64 +90,62 @@ class BeaconScannerService {
return const BeaconScanResult(error: "No beacons configured"); return const BeaconScanResult(error: "No beacons configured");
} }
// Initialize scanning // Use native scanner on Android, Flutter plugin on iOS
await flutterBeacon.initializeScanning; List<DetectedBeacon> detectedBeacons = [];
// Create regions for all known UUIDs if (Platform.isAndroid) {
final regions = knownBeacons.keys.map((uuid) { debugPrint('[BeaconScanner] Using native Android scanner...');
final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}'; detectedBeacons = await BeaconChannel.startScan(
return Region(identifier: uuid, proximityUUID: formattedUUID); regions: knownBeacons.keys.toList(),
}).toList(); );
} else {
// iOS: use Flutter plugin
debugPrint('[BeaconScanner] Using Flutter plugin for iOS...');
detectedBeacons = await _scanWithFlutterPlugin(knownBeacons);
}
// Collect RSSI samples if (detectedBeacons.isEmpty) {
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');
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
if (rssiSamples.isEmpty) {
debugPrint('[BeaconScanner] No beacons detected'); debugPrint('[BeaconScanner] No beacons detected');
return const BeaconScanResult(beaconsFound: 0); return const BeaconScanResult(beaconsFound: 0);
} }
// Lookup found beacons debugPrint('[BeaconScanner] Detected ${detectedBeacons.length} beacons');
debugPrint('[BeaconScanner] Looking up ${rssiSamples.length} beacons...');
final uuids = rssiSamples.keys.toList();
List<BeaconLookupResult> lookupResults = [];
// 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 { try {
lookupResults = await Api.lookupBeacons(uuids); lookupResults = await Api.lookupBeacons(uuids);
debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons'); debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons');
} catch (e) { } catch (e) {
debugPrint('[BeaconScanner] Lookup error: $e'); debugPrint('[BeaconScanner] Lookup error: $e');
return BeaconScanResult( return BeaconScanResult(
beaconsFound: rssiSamples.length, beaconsFound: validBeacons.length,
error: "Failed to lookup beacons", error: "Failed to lookup beacons",
); );
} }
// Find the best registered beacon // Find the best registered beacon based on RSSI
final bestBeacon = _findBestBeacon(lookupResults, rssiSamples, detectionCounts); final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi};
final bestBeacon = _findBestBeacon(lookupResults, rssiMap);
debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}'); debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}');
return BeaconScanResult( return BeaconScanResult(
bestBeacon: bestBeacon, bestBeacon: bestBeacon,
beaconsFound: rssiSamples.length, beaconsFound: validBeacons.length,
); );
} catch (e) { } catch (e) {
debugPrint('[BeaconScanner] Scan error: $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 /// Find the best registered beacon based on RSSI
BeaconLookupResult? _findBestBeacon( BeaconLookupResult? _findBestBeacon(
List<BeaconLookupResult> registeredBeacons, List<BeaconLookupResult> registeredBeacons,
Map<String, List<int>> rssiSamples, Map<String, int> rssiMap,
Map<String, int> detectionCounts,
) { ) {
if (registeredBeacons.isEmpty) return null; if (registeredBeacons.isEmpty) return null;
BeaconLookupResult? best; BeaconLookupResult? best;
double bestAvgRssi = -999; int bestRssi = -999;
// First pass: find beacon with RSSI >= -85
for (final beacon in registeredBeacons) { for (final beacon in registeredBeacons) {
final samples = rssiSamples[beacon.uuid]; final rssi = rssiMap[beacon.uuid] ?? -100;
if (samples == null || samples.isEmpty) continue; if (rssi > bestRssi && rssi >= -85) {
bestRssi = rssi;
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;
best = beacon; best = beacon;
} }
} }
@ -184,12 +216,9 @@ class BeaconScannerService {
// Fall back to strongest if none meet threshold // Fall back to strongest if none meet threshold
if (best == null) { if (best == null) {
for (final beacon in registeredBeacons) { for (final beacon in registeredBeacons) {
final samples = rssiSamples[beacon.uuid]; final rssi = rssiMap[beacon.uuid] ?? -100;
if (samples == null || samples.isEmpty) continue; if (rssi > bestRssi) {
bestRssi = rssi;
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
if (avgRssi > bestAvgRssi) {
bestAvgRssi = avgRssi;
best = beacon; best = beacon;
} }
} }