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:
parent
d5f0721215
commit
982152383a
6 changed files with 345 additions and 447 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue