Add native Kotlin beacon scanner for faster detection
- Add AltBeacon library for native Android beacon scanning - Create BeaconScanner.kt with 2-second scan duration - Set up MethodChannel bridge in MainActivity.kt - Add beacon_channel.dart for Dart/native communication - Update splash_screen.dart to use native scanner on Android - Keep dchs_flutter_beacon for iOS compatibility Native scanner eliminates Flutter plugin warmup overhead. Beacons broadcast at 200ms, so 2s scan captures ~10 samples. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0adb172b9e
commit
bc81ca98b0
6 changed files with 503 additions and 143 deletions
|
|
@ -59,6 +59,11 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AltBeacon library for native beacon scanning
|
||||
implementation("org.altbeacon:android-beacon-library:2.20.6")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
|
|
|||
219
android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt
Normal file
219
android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package com.payfrit.app
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.altbeacon.beacon.*
|
||||
|
||||
/**
|
||||
* Native beacon scanner using AltBeacon library.
|
||||
* Scans for iBeacons and reports UUID + RSSI to Flutter.
|
||||
*/
|
||||
class BeaconScanner(private val context: Context) : BeaconConsumer, RangeNotifier {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BeaconScanner"
|
||||
|
||||
// iBeacon layout
|
||||
private const val IBEACON_LAYOUT = "m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24"
|
||||
|
||||
// Scan duration in milliseconds (beacons broadcast at 200ms, so 2s = ~10 samples)
|
||||
private const val SCAN_DURATION_MS = 2000L
|
||||
|
||||
// Minimum RSSI threshold
|
||||
private const val MIN_RSSI = -90
|
||||
}
|
||||
|
||||
private var beaconManager: BeaconManager? = null
|
||||
private var isScanning = false
|
||||
private var scanCallback: ((List<Map<String, Any>>) -> Unit)? = null
|
||||
private var errorCallback: ((String) -> Unit)? = null
|
||||
|
||||
// Collected beacon data: UUID -> list of RSSI samples
|
||||
private val beaconSamples = mutableMapOf<String, MutableList<Int>>()
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Check if Bluetooth permissions are granted
|
||||
*/
|
||||
fun hasPermissions(): Boolean {
|
||||
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
)
|
||||
}
|
||||
|
||||
return permissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Bluetooth is enabled
|
||||
*/
|
||||
fun isBluetoothEnabled(): Boolean {
|
||||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
||||
val adapter = bluetoothManager?.adapter
|
||||
return adapter?.isEnabled == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scanning for beacons
|
||||
* @param regions List of UUID strings to scan for
|
||||
* @param onComplete Callback with list of detected beacons [{uuid, rssi, samples}]
|
||||
* @param onError Callback for errors
|
||||
*/
|
||||
fun startScan(
|
||||
regions: List<String>,
|
||||
onComplete: (List<Map<String, Any>>) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
if (isScanning) {
|
||||
onError("Scan already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasPermissions()) {
|
||||
onError("Bluetooth permissions not granted")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isBluetoothEnabled()) {
|
||||
onError("Bluetooth is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Starting beacon scan for ${regions.size} regions")
|
||||
|
||||
isScanning = true
|
||||
scanCallback = onComplete
|
||||
errorCallback = onError
|
||||
beaconSamples.clear()
|
||||
|
||||
try {
|
||||
// Initialize beacon manager
|
||||
beaconManager = BeaconManager.getInstanceForApplication(context).apply {
|
||||
// Configure for iBeacon
|
||||
beaconParsers.clear()
|
||||
beaconParsers.add(BeaconParser().setBeaconLayout(IBEACON_LAYOUT))
|
||||
|
||||
// Faster scan settings for immediate detection
|
||||
foregroundScanPeriod = SCAN_DURATION_MS
|
||||
foregroundBetweenScanPeriod = 0
|
||||
|
||||
addRangeNotifier(this@BeaconScanner)
|
||||
}
|
||||
|
||||
beaconManager?.bind(this)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize beacon scanner", e)
|
||||
isScanning = false
|
||||
onError("Failed to initialize scanner: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop scanning and clean up
|
||||
*/
|
||||
fun stopScan() {
|
||||
Log.d(TAG, "Stopping beacon scan")
|
||||
isScanning = false
|
||||
|
||||
try {
|
||||
beaconManager?.removeAllRangeNotifiers()
|
||||
beaconManager?.unbind(this)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error stopping scan", e)
|
||||
}
|
||||
|
||||
beaconManager = null
|
||||
}
|
||||
|
||||
// BeaconConsumer implementation
|
||||
override fun onBeaconServiceConnect() {
|
||||
Log.d(TAG, "Beacon service connected")
|
||||
|
||||
try {
|
||||
// Start ranging for all iBeacons (wildcard region)
|
||||
val region = Region("all-beacons", null, null, null)
|
||||
beaconManager?.startRangingBeacons(region)
|
||||
|
||||
// Schedule scan completion
|
||||
mainHandler.postDelayed({
|
||||
completeScan()
|
||||
}, SCAN_DURATION_MS)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start ranging", e)
|
||||
errorCallback?.invoke("Failed to start ranging: ${e.message}")
|
||||
stopScan()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getApplicationContext(): Context = context.applicationContext
|
||||
|
||||
override fun unbindService(connection: android.content.ServiceConnection) {
|
||||
context.unbindService(connection)
|
||||
}
|
||||
|
||||
override fun bindService(intent: android.content.Intent, connection: android.content.ServiceConnection, flags: Int): Boolean {
|
||||
return context.bindService(intent, connection, flags)
|
||||
}
|
||||
|
||||
// RangeNotifier implementation
|
||||
override fun didRangeBeaconsInRegion(beacons: MutableCollection<Beacon>, region: Region) {
|
||||
for (beacon in beacons) {
|
||||
val uuid = beacon.id1.toString().uppercase().replace("-", "")
|
||||
val rssi = beacon.rssi
|
||||
|
||||
if (rssi >= MIN_RSSI) {
|
||||
Log.d(TAG, "Detected beacon: $uuid RSSI=$rssi")
|
||||
beaconSamples.getOrPut(uuid) { mutableListOf() }.add(rssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeScan() {
|
||||
Log.d(TAG, "Scan complete. Found ${beaconSamples.size} unique beacons")
|
||||
|
||||
// Build results
|
||||
val results = beaconSamples.map { (uuid, samples) ->
|
||||
val avgRssi = samples.average().toInt()
|
||||
mapOf<String, Any>(
|
||||
"uuid" to uuid,
|
||||
"rssi" to avgRssi,
|
||||
"samples" to samples.size
|
||||
)
|
||||
}.sortedByDescending { it["rssi"] as Int }
|
||||
|
||||
// Stop scanning
|
||||
try {
|
||||
val region = Region("all-beacons", null, null, null)
|
||||
beaconManager?.stopRangingBeacons(region)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error stopping ranging", e)
|
||||
}
|
||||
|
||||
stopScan()
|
||||
|
||||
// Return results on main thread
|
||||
mainHandler.post {
|
||||
scanCallback?.invoke(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,59 @@ package com.payfrit.app
|
|||
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "com.payfrit.app/beacon"
|
||||
}
|
||||
|
||||
private var beaconScanner: BeaconScanner? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
beaconScanner = BeaconScanner(this)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"hasPermissions" -> {
|
||||
result.success(beaconScanner?.hasPermissions() == true)
|
||||
}
|
||||
|
||||
"isBluetoothEnabled" -> {
|
||||
result.success(beaconScanner?.isBluetoothEnabled() == true)
|
||||
}
|
||||
|
||||
"startScan" -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val regions = call.argument<List<String>>("regions") ?: emptyList()
|
||||
|
||||
beaconScanner?.startScan(
|
||||
regions = regions,
|
||||
onComplete = { beacons ->
|
||||
result.success(beacons)
|
||||
},
|
||||
onError = { error ->
|
||||
result.error("SCAN_ERROR", error, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
"stopScan" -> {
|
||||
beaconScanner?.stopScan()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix crash when returning from CashApp/external payment authorization
|
||||
// Stripe SDK fragments don't properly support state restoration
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
|
@ -11,4 +62,10 @@ class MainActivity : FlutterFragmentActivity() {
|
|||
// Remove fragment state to prevent Stripe PaymentSheetFragment restoration crash
|
||||
outState.remove("android:support:fragments")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
beaconScanner?.stopScan()
|
||||
beaconScanner = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import "dart:async";
|
|||
import "dart:math";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart";
|
||||
|
||||
import "../app/app_router.dart";
|
||||
import "../app/app_state.dart";
|
||||
import "../services/api.dart";
|
||||
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";
|
||||
|
||||
|
|
@ -20,9 +20,6 @@ class SplashScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
|
||||
bool _permissionsWereFreshlyGranted = false;
|
||||
|
||||
// Bouncing logo animation
|
||||
late AnimationController _bounceController;
|
||||
double _x = 100;
|
||||
|
|
@ -44,9 +41,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
"connecting...",
|
||||
];
|
||||
|
||||
// Beacon scanning state - new approach: scan all, then lookup
|
||||
final Map<String, List<int>> _beaconRssiSamples = {};
|
||||
final Map<String, int> _beaconDetectionCount = {};
|
||||
// Beacon scanning state
|
||||
bool _scanComplete = false;
|
||||
BeaconLookupResult? _bestBeacon;
|
||||
|
||||
|
|
@ -64,7 +59,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
|
||||
print('[Splash] Starting with native beacon scanner');
|
||||
|
||||
// Start bouncing animation
|
||||
_bounceController = AnimationController(
|
||||
|
|
@ -131,35 +126,35 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
|
||||
Future<void> _initializeApp() async {
|
||||
// Run auth check and preloading in parallel for faster startup
|
||||
print('[Splash] 🚀 Starting parallel initialization...');
|
||||
print('[Splash] Starting parallel initialization...');
|
||||
|
||||
// Start preloading data in background (fire and forget for non-critical data)
|
||||
PreloadCache.preloadAll();
|
||||
|
||||
// Check for saved auth credentials
|
||||
print('[Splash] 🔐 Checking 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)}...');
|
||||
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
|
||||
print('[Splash] 🔍 Validating token with server...');
|
||||
print('[Splash] Validating token with server...');
|
||||
final isValid = await _validateToken();
|
||||
if (isValid && mounted) {
|
||||
print('[Splash] ✅ Token is valid');
|
||||
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');
|
||||
print('[Splash] Token is invalid or expired, clearing saved auth');
|
||||
await AuthStorage.clearAuth();
|
||||
Api.clearAuthToken();
|
||||
}
|
||||
} else {
|
||||
print('[Splash] ❌ No saved credentials found');
|
||||
print('[Splash] No saved credentials found');
|
||||
}
|
||||
|
||||
// Start beacon scanning in background
|
||||
// Start beacon scanning
|
||||
await _performBeaconScan();
|
||||
|
||||
// Navigate based on results
|
||||
|
|
@ -179,130 +174,135 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
}
|
||||
|
||||
Future<void> _performBeaconScan() async {
|
||||
print('[Splash] 📡 Starting beacon scan...');
|
||||
print('[Splash] Starting beacon scan...');
|
||||
|
||||
// Check if permissions are already granted BEFORE requesting
|
||||
final alreadyHadPermissions = await BeaconPermissions.checkPermissions();
|
||||
|
||||
// Request permissions (will be instant if already granted)
|
||||
// Request permissions if needed
|
||||
final granted = await BeaconPermissions.requestPermissions();
|
||||
if (!granted) {
|
||||
print('[Splash] ❌ Permissions denied');
|
||||
print('[Splash] Permissions denied');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If permissions were just granted (not already had), Bluetooth needs warmup
|
||||
_permissionsWereFreshlyGranted = !alreadyHadPermissions;
|
||||
if (_permissionsWereFreshlyGranted) {
|
||||
print('[Splash] 🆕 Permissions freshly granted - will add warmup delay');
|
||||
}
|
||||
|
||||
// Check if Bluetooth is ON
|
||||
print('[Splash] 📶 Checking Bluetooth state...');
|
||||
// Check if Bluetooth is enabled
|
||||
print('[Splash] Checking Bluetooth state...');
|
||||
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
|
||||
if (!bluetoothOn) {
|
||||
print('[Splash] ❌ Bluetooth is OFF - cannot scan for beacons');
|
||||
print('[Splash] Bluetooth is OFF - cannot scan for beacons');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
print('[Splash] ✅ Bluetooth is ON');
|
||||
print('[Splash] Bluetooth is ON');
|
||||
|
||||
// Step 1: Try to load beacon list from cache first, then fetch from server
|
||||
print('[Splash] 📥 Loading beacon list...');
|
||||
// Load known beacons from cache or server (for UUID filtering)
|
||||
print('[Splash] Loading beacon list...');
|
||||
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');
|
||||
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');
|
||||
print('[Splash] Background refresh: saved ${fresh.length} beacons to cache');
|
||||
}).catchError((e) {
|
||||
print('[Splash] ⚠️ Background refresh failed: $e');
|
||||
print('[Splash] Background refresh failed: $e');
|
||||
});
|
||||
} else {
|
||||
// No cache - must fetch from server
|
||||
try {
|
||||
knownBeacons = await Api.listAllBeacons();
|
||||
print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
|
||||
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');
|
||||
print('[Splash] Failed to fetch beacons: $e');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (knownBeacons.isEmpty) {
|
||||
print('[Splash] ⚠️ No beacons configured');
|
||||
print('[Splash] No beacons configured');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize beacon scanning
|
||||
// Use native beacon scanner
|
||||
try {
|
||||
// Close any existing ranging streams first
|
||||
await flutterBeacon.close;
|
||||
print('[Splash] Starting native beacon scan...');
|
||||
|
||||
await flutterBeacon.initializeScanning;
|
||||
// Scan using native channel
|
||||
final detectedBeacons = await BeaconChannel.startScan(
|
||||
regions: knownBeacons.keys.toList(),
|
||||
);
|
||||
|
||||
// Always add warmup delay - Bluetooth adapter needs time to initialize
|
||||
print('[Splash] 🔄 Bluetooth warmup...');
|
||||
await Future.delayed(const Duration(milliseconds: 2000));
|
||||
|
||||
// Extra delay if permissions were freshly granted
|
||||
if (_permissionsWereFreshlyGranted) {
|
||||
print('[Splash] 🆕 Fresh permissions - adding extra warmup');
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
if (detectedBeacons.isEmpty) {
|
||||
print('[Splash] No beacons detected');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
print('[Splash] Detected ${detectedBeacons.length} beacons');
|
||||
|
||||
// Single scan - collect samples for 2 seconds
|
||||
print('[Splash] 🔍 Scanning...');
|
||||
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;
|
||||
// Filter to only known beacons with good RSSI
|
||||
final validBeacons = detectedBeacons
|
||||
.where((b) => knownBeacons.containsKey(b.uuid) && b.rssi >= -85)
|
||||
.toList();
|
||||
|
||||
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
||||
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
|
||||
print('[Splash] 📶 Found $uuid RSSI=$rssi');
|
||||
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}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scan for 5 seconds to ensure we catch beacons
|
||||
await Future.delayed(const Duration(milliseconds: 5000));
|
||||
await subscription.cancel();
|
||||
if (validBeacons.isEmpty) {
|
||||
print('[Splash] No known beacons found');
|
||||
_scanComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Now lookup business info for found beacons
|
||||
if (_beaconRssiSamples.isNotEmpty) {
|
||||
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
|
||||
final uuids = _beaconRssiSamples.keys.toList();
|
||||
// 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');
|
||||
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
|
||||
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
|
||||
|
||||
} catch (e) {
|
||||
print('[Splash] Scan error: $e');
|
||||
}
|
||||
|
|
@ -310,54 +310,16 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
_scanComplete = true;
|
||||
}
|
||||
|
||||
/// Find the best registered beacon from lookup results based on RSSI
|
||||
BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
|
||||
if (registeredBeacons.isEmpty) return null;
|
||||
|
||||
BeaconLookupResult? best;
|
||||
double bestAvgRssi = -999;
|
||||
|
||||
for (final beacon in registeredBeacons) {
|
||||
final samples = _beaconRssiSamples[beacon.uuid];
|
||||
if (samples == null || samples.isEmpty) continue;
|
||||
|
||||
final detections = _beaconDetectionCount[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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to strongest registered beacon if none meet threshold
|
||||
if (best == null) {
|
||||
for (final beacon in registeredBeacons) {
|
||||
final samples = _beaconRssiSamples[beacon.uuid];
|
||||
if (samples == null || samples.isEmpty) continue;
|
||||
|
||||
final avgRssi = samples.reduce((a, b) => a + b) / samples.length;
|
||||
if (avgRssi > bestAvgRssi) {
|
||||
bestAvgRssi = avgRssi;
|
||||
best = beacon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
Future<void> _navigateToNextScreen() async {
|
||||
if (!mounted) return;
|
||||
|
||||
if (_bestBeacon != null) {
|
||||
final beacon = _bestBeacon!;
|
||||
print('[Splash] 📊 Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
|
||||
print('[Splash] Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}');
|
||||
|
||||
// Check if this business has child businesses (food court scenario)
|
||||
if (beacon.hasChildren) {
|
||||
print('[Splash] 🏢 Business has children - showing selector');
|
||||
print('[Splash] Business has children - showing selector');
|
||||
// Need to fetch children and show selector
|
||||
try {
|
||||
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
|
||||
|
|
@ -395,7 +357,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
|||
appState.setOrderType(OrderType.dineIn);
|
||||
Api.setBusinessId(beacon.businessId);
|
||||
|
||||
print('[Splash] 🎉 Auto-selected: ${beacon.businessName}');
|
||||
print('[Splash] Auto-selected: ${beacon.businessName}');
|
||||
|
||||
Navigator.of(context).pushReplacementNamed(
|
||||
AppRoutes.menuBrowse,
|
||||
|
|
|
|||
109
lib/services/beacon_channel.dart
Normal file
109
lib/services/beacon_channel.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import "dart:io";
|
||||
import "package:flutter/services.dart";
|
||||
|
||||
/// Detected beacon from native scanner
|
||||
class DetectedBeacon {
|
||||
final String uuid;
|
||||
final int rssi;
|
||||
final int samples;
|
||||
|
||||
const DetectedBeacon({
|
||||
required this.uuid,
|
||||
required this.rssi,
|
||||
required this.samples,
|
||||
});
|
||||
|
||||
factory DetectedBeacon.fromMap(Map<dynamic, dynamic> map) {
|
||||
return DetectedBeacon(
|
||||
uuid: (map["uuid"] as String?) ?? "",
|
||||
rssi: (map["rssi"] as int?) ?? -100,
|
||||
samples: (map["samples"] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "DetectedBeacon(uuid: $uuid, rssi: $rssi, samples: $samples)";
|
||||
}
|
||||
|
||||
/// Native beacon scanner via MethodChannel
|
||||
/// Only works on Android. iOS falls back to Flutter plugin.
|
||||
class BeaconChannel {
|
||||
static const _channel = MethodChannel("com.payfrit.app/beacon");
|
||||
|
||||
/// Check if running on Android (native scanner only works there)
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
|
||||
/// Check if Bluetooth permissions are granted
|
||||
static Future<bool> hasPermissions() async {
|
||||
if (!isSupported) return false;
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>("hasPermissions");
|
||||
return result ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
print("[BeaconChannel] hasPermissions error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Bluetooth is enabled
|
||||
static Future<bool> isBluetoothEnabled() async {
|
||||
if (!isSupported) return false;
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>("isBluetoothEnabled");
|
||||
return result ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
print("[BeaconChannel] isBluetoothEnabled error: $e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start scanning for beacons
|
||||
/// Returns list of detected beacons sorted by RSSI (strongest first)
|
||||
static Future<List<DetectedBeacon>> startScan({List<String>? regions}) async {
|
||||
if (!isSupported) {
|
||||
print("[BeaconChannel] Not supported on this platform");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
print("[BeaconChannel] Starting native beacon scan...");
|
||||
|
||||
final result = await _channel.invokeMethod<List<dynamic>>(
|
||||
"startScan",
|
||||
{"regions": regions ?? []},
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
print("[BeaconChannel] Scan returned null");
|
||||
return [];
|
||||
}
|
||||
|
||||
final beacons = result
|
||||
.map((e) => DetectedBeacon.fromMap(e as Map<dynamic, dynamic>))
|
||||
.toList();
|
||||
|
||||
print("[BeaconChannel] Scan complete: found ${beacons.length} beacons");
|
||||
for (final b in beacons) {
|
||||
print("[BeaconChannel] $b");
|
||||
}
|
||||
|
||||
return beacons;
|
||||
} on PlatformException catch (e) {
|
||||
print("[BeaconChannel] Scan error: ${e.message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an ongoing scan
|
||||
static Future<void> stopScan() async {
|
||||
if (!isSupported) return;
|
||||
|
||||
try {
|
||||
await _channel.invokeMethod("stopScan");
|
||||
} on PlatformException catch (e) {
|
||||
print("[BeaconChannel] stopScan error: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
|
||||
|
||||
import 'beacon_channel.dart';
|
||||
|
||||
class BeaconPermissions {
|
||||
static Future<bool> requestPermissions() async {
|
||||
try {
|
||||
|
|
@ -28,9 +30,9 @@ class BeaconPermissions {
|
|||
final allGranted = locationStatus.isGranted && bluetoothGranted;
|
||||
|
||||
if (allGranted) {
|
||||
debugPrint('[BeaconPermissions] ✅ All permissions granted');
|
||||
debugPrint('[BeaconPermissions] All permissions granted');
|
||||
} else {
|
||||
debugPrint('[BeaconPermissions] ❌ Permissions denied');
|
||||
debugPrint('[BeaconPermissions] Permissions denied');
|
||||
}
|
||||
|
||||
return allGranted;
|
||||
|
|
@ -43,6 +45,12 @@ class BeaconPermissions {
|
|||
/// Check if Bluetooth is enabled - returns current state without prompting
|
||||
static Future<bool> isBluetoothEnabled() async {
|
||||
try {
|
||||
// Use native channel on Android
|
||||
if (Platform.isAndroid) {
|
||||
return await BeaconChannel.isBluetoothEnabled();
|
||||
}
|
||||
|
||||
// Use Flutter plugin on iOS
|
||||
final bluetoothState = await flutterBeacon.bluetoothState;
|
||||
return bluetoothState == BluetoothState.stateOn;
|
||||
} catch (e) {
|
||||
|
|
@ -54,7 +62,7 @@ class BeaconPermissions {
|
|||
/// Request to enable Bluetooth via system prompt (Android only)
|
||||
static Future<bool> requestEnableBluetooth() async {
|
||||
try {
|
||||
debugPrint('[BeaconPermissions] 📶 Requesting Bluetooth enable...');
|
||||
debugPrint('[BeaconPermissions] Requesting Bluetooth enable...');
|
||||
// This opens a system dialog on Android asking user to turn on Bluetooth
|
||||
final result = await flutterBeacon.requestAuthorization;
|
||||
debugPrint('[BeaconPermissions] Request authorization result: $result');
|
||||
|
|
@ -82,45 +90,45 @@ class BeaconPermissions {
|
|||
static Future<bool> ensureBluetoothEnabled() async {
|
||||
try {
|
||||
// Check current Bluetooth state
|
||||
final bluetoothState = await flutterBeacon.bluetoothState;
|
||||
debugPrint('[BeaconPermissions] 📶 Bluetooth state: $bluetoothState');
|
||||
final isOn = await isBluetoothEnabled();
|
||||
debugPrint('[BeaconPermissions] Bluetooth state: ${isOn ? "ON" : "OFF"}');
|
||||
|
||||
if (bluetoothState == BluetoothState.stateOn) {
|
||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is ON');
|
||||
if (isOn) {
|
||||
debugPrint('[BeaconPermissions] Bluetooth is ON');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Request to enable Bluetooth via system prompt
|
||||
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
|
||||
debugPrint('[BeaconPermissions] Bluetooth is OFF, requesting enable...');
|
||||
await requestEnableBluetooth();
|
||||
|
||||
// Poll for Bluetooth state change - short wait first
|
||||
for (int i = 0; i < 6; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final newState = await flutterBeacon.bluetoothState;
|
||||
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
|
||||
if (newState == BluetoothState.stateOn) {
|
||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
|
||||
final newState = await isBluetoothEnabled();
|
||||
debugPrint('[BeaconPermissions] Polling Bluetooth state ($i): ${newState ? "ON" : "OFF"}');
|
||||
if (newState) {
|
||||
debugPrint('[BeaconPermissions] Bluetooth is now ON');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If still off after 3 seconds, try opening Bluetooth settings directly
|
||||
debugPrint('[BeaconPermissions] ⚠️ Bluetooth still OFF, opening settings...');
|
||||
debugPrint('[BeaconPermissions] Bluetooth still OFF, opening settings...');
|
||||
await openBluetoothSettings();
|
||||
|
||||
// Poll again for up to 15 seconds (user needs time to toggle in settings)
|
||||
for (int i = 0; i < 30; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final newState = await flutterBeacon.bluetoothState;
|
||||
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state after settings ($i): $newState');
|
||||
if (newState == BluetoothState.stateOn) {
|
||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
|
||||
final newState = await isBluetoothEnabled();
|
||||
debugPrint('[BeaconPermissions] Polling Bluetooth state after settings ($i): ${newState ? "ON" : "OFF"}');
|
||||
if (newState) {
|
||||
debugPrint('[BeaconPermissions] Bluetooth is now ON');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
|
||||
debugPrint('[BeaconPermissions] Bluetooth still OFF after waiting');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[BeaconPermissions] Error checking Bluetooth state: $e');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue