diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index 5f7a7df..3e082a6 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,5 +1,4 @@
-
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 5f7a7df..3e082a6 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,5 +1,4 @@
-
diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart
index 0f6290b..970e0aa 100644
--- a/lib/screens/beacon_scan_screen.dart
+++ b/lib/screens/beacon_scan_screen.dart
@@ -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,100 +114,19 @@ class _BeaconScanScreenState extends State with SingleTickerPr
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 (_uuidToBeaconId.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;
+ // Use native scanner on Android, Flutter plugin on iOS
+ if (Platform.isAndroid) {
+ await _scanWithNativeScanner();
} 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();
- }
+ await _scanWithFlutterPlugin();
}
} catch (e) {
+ debugPrint('[BeaconScan] Scan error: $e');
if (mounted) {
setState(() {
_scanning = false;
@@ -217,6 +138,151 @@ class _BeaconScanScreenState extends State with SingleTickerPr
}
}
+ /// Native Android scanner - fast and accurate
+ Future _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 = {};
+ 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 _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? 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 = {};
+ 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 _processBeaconScores(Map 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 _autoSelectBusinessFromBeacon(int beaconId) async {
try {
final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId);
diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart
index e05b703..a2e93cb 100644
--- a/lib/screens/splash_screen.dart
+++ b/lib/screens/splash_screen.dart
@@ -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 createState() => _SplashScreenState();
}
-class _SplashScreenState extends State 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 _statusPhrases = [
- "scanning...",
- "listening...",
- "searching...",
- "locating...",
- "analyzing...",
- "connecting...",
- ];
-
+class _SplashScreenState extends State {
// Beacon scanning state
- bool _scanComplete = false;
BeaconLookupResult? _bestBeacon;
- static const List _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 _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.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,152 +46,117 @@ class _SplashScreenState extends State with TickerProviderStateMix
_navigateToNextScreen();
}
- /// Validates the stored token by making a profile API call
+ Future _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.setUserId(credentials.userId);
+ } else {
+ AuthStorage.clearAuth();
+ Api.clearAuthToken();
+ }
+ });
+ }
+ }
+
+ Future _prepareBeaconScan() async {
+ // Request permissions (this is the slow part)
+ await BeaconPermissions.requestPermissions();
+ }
+
Future _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 _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 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;
+ if (knownBeacons.isEmpty) return;
+
+ // Scan for beacons
+ debugPrint('[Splash] Scanning...');
+ final detectedBeacons = await BeaconChannel.startScan(
+ regions: knownBeacons.keys.toList(),
+ );
+
+ if (detectedBeacons.isEmpty) {
+ debugPrint('[Splash] No beacons detected');
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 {
- 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
- final detectedBeacons = await BeaconChannel.startScan(
- regions: knownBeacons.keys.toList(),
- );
-
- 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;
- }
+ 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) {
- print('[Splash] Scan error: $e');
+ debugPrint('[Splash] Lookup error: $e');
}
-
- _scanComplete = true;
}
Future _navigateToNextScreen() async {
@@ -374,61 +223,19 @@ class _SplashScreenState extends State 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,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- _statusPhrases[_statusIndex],
- style: const 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,
),
- // Bouncing logo
- Positioned(
- left: _x,
- top: _y,
- child: Text(
- "PAYFRIT",
- style: TextStyle(
- color: _logoColor,
- fontSize: 38,
- fontWeight: FontWeight.w800,
- letterSpacing: 3,
- ),
- ),
- ),
- ],
+ ),
),
);
}
diff --git a/lib/services/beacon_permissions.dart b/lib/services/beacon_permissions.dart
index 161f8f6..839da1b 100644
--- a/lib/services/beacon_permissions.dart
+++ b/lib/services/beacon_permissions.dart
@@ -8,19 +8,27 @@ import 'beacon_channel.dart';
class BeaconPermissions {
static Future 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 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 openSettings() async {
diff --git a/lib/services/beacon_scanner_service.dart b/lib/services/beacon_scanner_service.dart
index 550e90d..2fab70a 100644
--- a/lib/services/beacon_scanner_service.dart
+++ b/lib/services/beacon_scanner_service.dart
@@ -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 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();
+ 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);
+ }
- // Collect RSSI samples
- final Map> rssiSamples = {};
- final Map detectionCounts = {};
-
- debugPrint('[BeaconScanner] Starting 2-second scan...');
- StreamSubscription? 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) {
+ 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 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 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> _scanWithFlutterPlugin(Map 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> rssiSamples = {};
+
+ debugPrint('[BeaconScanner] iOS: Starting 2-second scan...');
+ StreamSubscription? 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 registeredBeacons,
- Map> rssiSamples,
- Map detectionCounts,
+ Map 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;
}
}