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 {
|
flutter {
|
||||||
source = "../.."
|
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 android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
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
|
// Fix crash when returning from CashApp/external payment authorization
|
||||||
// Stripe SDK fragments don't properly support state restoration
|
// Stripe SDK fragments don't properly support state restoration
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
|
@ -11,4 +62,10 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
// Remove fragment state to prevent Stripe PaymentSheetFragment restoration crash
|
// Remove fragment state to prevent Stripe PaymentSheetFragment restoration crash
|
||||||
outState.remove("android:support:fragments")
|
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 "dart:math";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
import "package:dchs_flutter_beacon/dchs_flutter_beacon.dart";
|
|
||||||
|
|
||||||
import "../app/app_router.dart";
|
import "../app/app_router.dart";
|
||||||
import "../app/app_state.dart";
|
import "../app/app_state.dart";
|
||||||
import "../services/api.dart";
|
import "../services/api.dart";
|
||||||
import "../services/auth_storage.dart";
|
import "../services/auth_storage.dart";
|
||||||
import "../services/beacon_cache.dart";
|
import "../services/beacon_cache.dart";
|
||||||
|
import "../services/beacon_channel.dart";
|
||||||
import "../services/beacon_permissions.dart";
|
import "../services/beacon_permissions.dart";
|
||||||
import "../services/preload_cache.dart";
|
import "../services/preload_cache.dart";
|
||||||
|
|
||||||
|
|
@ -20,9 +20,6 @@ class SplashScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||||
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
|
|
||||||
bool _permissionsWereFreshlyGranted = false;
|
|
||||||
|
|
||||||
// Bouncing logo animation
|
// Bouncing logo animation
|
||||||
late AnimationController _bounceController;
|
late AnimationController _bounceController;
|
||||||
double _x = 100;
|
double _x = 100;
|
||||||
|
|
@ -44,9 +41,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
"connecting...",
|
"connecting...",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Beacon scanning state - new approach: scan all, then lookup
|
// Beacon scanning state
|
||||||
final Map<String, List<int>> _beaconRssiSamples = {};
|
|
||||||
final Map<String, int> _beaconDetectionCount = {};
|
|
||||||
bool _scanComplete = false;
|
bool _scanComplete = false;
|
||||||
BeaconLookupResult? _bestBeacon;
|
BeaconLookupResult? _bestBeacon;
|
||||||
|
|
||||||
|
|
@ -64,7 +59,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
print('[Splash] 🚀 Starting with bouncing logo + beacon scan');
|
print('[Splash] Starting with native beacon scanner');
|
||||||
|
|
||||||
// Start bouncing animation
|
// Start bouncing animation
|
||||||
_bounceController = AnimationController(
|
_bounceController = AnimationController(
|
||||||
|
|
@ -131,35 +126,35 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
|
|
||||||
Future<void> _initializeApp() async {
|
Future<void> _initializeApp() async {
|
||||||
// Run auth check and preloading in parallel for faster startup
|
// 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)
|
// Start preloading data in background (fire and forget for non-critical data)
|
||||||
PreloadCache.preloadAll();
|
PreloadCache.preloadAll();
|
||||||
|
|
||||||
// Check for saved auth credentials
|
// Check for saved auth credentials
|
||||||
print('[Splash] 🔐 Checking for saved auth credentials...');
|
print('[Splash] Checking for saved auth credentials...');
|
||||||
final credentials = await AuthStorage.loadAuth();
|
final credentials = await AuthStorage.loadAuth();
|
||||||
if (credentials != null && mounted) {
|
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);
|
Api.setAuthToken(credentials.token);
|
||||||
|
|
||||||
// Validate token is still valid by calling profile endpoint
|
// 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();
|
final isValid = await _validateToken();
|
||||||
if (isValid && mounted) {
|
if (isValid && mounted) {
|
||||||
print('[Splash] ✅ Token is valid');
|
print('[Splash] Token is valid');
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
appState.setUserId(credentials.userId);
|
appState.setUserId(credentials.userId);
|
||||||
} else {
|
} else {
|
||||||
print('[Splash] ❌ Token is invalid or expired, clearing saved auth');
|
print('[Splash] Token is invalid or expired, clearing saved auth');
|
||||||
await AuthStorage.clearAuth();
|
await AuthStorage.clearAuth();
|
||||||
Api.clearAuthToken();
|
Api.clearAuthToken();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('[Splash] ❌ No saved credentials found');
|
print('[Splash] No saved credentials found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start beacon scanning in background
|
// Start beacon scanning
|
||||||
await _performBeaconScan();
|
await _performBeaconScan();
|
||||||
|
|
||||||
// Navigate based on results
|
// Navigate based on results
|
||||||
|
|
@ -179,130 +174,135 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performBeaconScan() async {
|
Future<void> _performBeaconScan() async {
|
||||||
print('[Splash] 📡 Starting beacon scan...');
|
print('[Splash] Starting beacon scan...');
|
||||||
|
|
||||||
// Check if permissions are already granted BEFORE requesting
|
// Request permissions if needed
|
||||||
final alreadyHadPermissions = await BeaconPermissions.checkPermissions();
|
|
||||||
|
|
||||||
// Request permissions (will be instant if already granted)
|
|
||||||
final granted = await BeaconPermissions.requestPermissions();
|
final granted = await BeaconPermissions.requestPermissions();
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
print('[Splash] ❌ Permissions denied');
|
print('[Splash] Permissions denied');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If permissions were just granted (not already had), Bluetooth needs warmup
|
// Check if Bluetooth is enabled
|
||||||
_permissionsWereFreshlyGranted = !alreadyHadPermissions;
|
print('[Splash] Checking Bluetooth state...');
|
||||||
if (_permissionsWereFreshlyGranted) {
|
|
||||||
print('[Splash] 🆕 Permissions freshly granted - will add warmup delay');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Bluetooth is ON
|
|
||||||
print('[Splash] 📶 Checking Bluetooth state...');
|
|
||||||
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
|
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
|
||||||
if (!bluetoothOn) {
|
if (!bluetoothOn) {
|
||||||
print('[Splash] ❌ Bluetooth is OFF - cannot scan for beacons');
|
print('[Splash] Bluetooth is OFF - cannot scan for beacons');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
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
|
// Load known beacons from cache or server (for UUID filtering)
|
||||||
print('[Splash] 📥 Loading beacon list...');
|
print('[Splash] Loading beacon list...');
|
||||||
Map<String, int> knownBeacons = {};
|
Map<String, int> knownBeacons = {};
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
final cached = await BeaconCache.load();
|
final cached = await BeaconCache.load();
|
||||||
if (cached != null && cached.isNotEmpty) {
|
if (cached != null && cached.isNotEmpty) {
|
||||||
print('[Splash] ✅ Got ${cached.length} beacon UUIDs from cache');
|
print('[Splash] Got ${cached.length} beacon UUIDs from cache');
|
||||||
knownBeacons = cached;
|
knownBeacons = cached;
|
||||||
// Refresh cache in background (fire and forget)
|
// Refresh cache in background (fire and forget)
|
||||||
Api.listAllBeacons().then((fresh) {
|
Api.listAllBeacons().then((fresh) {
|
||||||
BeaconCache.save(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) {
|
}).catchError((e) {
|
||||||
print('[Splash] ⚠️ Background refresh failed: $e');
|
print('[Splash] Background refresh failed: $e');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No cache - must fetch from server
|
// No cache - must fetch from server
|
||||||
try {
|
try {
|
||||||
knownBeacons = await Api.listAllBeacons();
|
knownBeacons = await Api.listAllBeacons();
|
||||||
print('[Splash] ✅ Got ${knownBeacons.length} beacon UUIDs from server');
|
print('[Splash] Got ${knownBeacons.length} beacon UUIDs from server');
|
||||||
// Save to cache
|
// Save to cache
|
||||||
await BeaconCache.save(knownBeacons);
|
await BeaconCache.save(knownBeacons);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Splash] ❌ Failed to fetch beacons: $e');
|
print('[Splash] Failed to fetch beacons: $e');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (knownBeacons.isEmpty) {
|
if (knownBeacons.isEmpty) {
|
||||||
print('[Splash] ⚠️ No beacons configured');
|
print('[Splash] No beacons configured');
|
||||||
_scanComplete = true;
|
_scanComplete = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize beacon scanning
|
// Use native beacon scanner
|
||||||
try {
|
try {
|
||||||
// Close any existing ranging streams first
|
print('[Splash] Starting native beacon scan...');
|
||||||
await flutterBeacon.close;
|
|
||||||
|
|
||||||
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
|
if (detectedBeacons.isEmpty) {
|
||||||
print('[Splash] 🔄 Bluetooth warmup...');
|
print('[Splash] No beacons detected');
|
||||||
await Future.delayed(const Duration(milliseconds: 2000));
|
_scanComplete = true;
|
||||||
|
return;
|
||||||
// Extra delay if permissions were freshly granted
|
|
||||||
if (_permissionsWereFreshlyGranted) {
|
|
||||||
print('[Splash] 🆕 Fresh permissions - adding extra warmup');
|
|
||||||
await Future.delayed(const Duration(milliseconds: 1500));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create regions for all known UUIDs
|
print('[Splash] Detected ${detectedBeacons.length} beacons');
|
||||||
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();
|
|
||||||
|
|
||||||
// Single scan - collect samples for 2 seconds
|
// Filter to only known beacons with good RSSI
|
||||||
print('[Splash] 🔍 Scanning...');
|
final validBeacons = detectedBeacons
|
||||||
StreamSubscription<RangingResult>? subscription;
|
.where((b) => knownBeacons.containsKey(b.uuid) && b.rssi >= -85)
|
||||||
subscription = flutterBeacon.ranging(regions).listen((result) {
|
.toList();
|
||||||
for (var beacon in result.beacons) {
|
|
||||||
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
|
|
||||||
final rssi = beacon.rssi;
|
|
||||||
|
|
||||||
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
if (validBeacons.isEmpty) {
|
||||||
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
|
print('[Splash] No valid beacons (known + RSSI >= -85)');
|
||||||
print('[Splash] 📶 Found $uuid RSSI=$rssi');
|
|
||||||
|
// 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
|
if (validBeacons.isEmpty) {
|
||||||
await Future.delayed(const Duration(milliseconds: 5000));
|
print('[Splash] No known beacons found');
|
||||||
await subscription.cancel();
|
_scanComplete = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Now lookup business info for found beacons
|
// Look up business info for detected beacons
|
||||||
if (_beaconRssiSamples.isNotEmpty) {
|
final uuids = validBeacons.map((b) => b.uuid).toList();
|
||||||
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
|
print('[Splash] Looking up ${uuids.length} beacons...');
|
||||||
final uuids = _beaconRssiSamples.keys.toList();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final lookupResults = await Api.lookupBeacons(uuids);
|
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
|
// 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) {
|
} catch (e) {
|
||||||
print('[Splash] Error looking up beacons: $e');
|
print('[Splash] Error looking up beacons: $e');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Splash] Scan error: $e');
|
print('[Splash] Scan error: $e');
|
||||||
}
|
}
|
||||||
|
|
@ -310,54 +310,16 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
_scanComplete = true;
|
_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 {
|
Future<void> _navigateToNextScreen() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (_bestBeacon != null) {
|
if (_bestBeacon != null) {
|
||||||
final beacon = _bestBeacon!;
|
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)
|
// Check if this business has child businesses (food court scenario)
|
||||||
if (beacon.hasChildren) {
|
if (beacon.hasChildren) {
|
||||||
print('[Splash] 🏢 Business has children - showing selector');
|
print('[Splash] Business has children - showing selector');
|
||||||
// Need to fetch children and show selector
|
// Need to fetch children and show selector
|
||||||
try {
|
try {
|
||||||
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
|
final children = await Api.getChildBusinesses(businessId: beacon.businessId);
|
||||||
|
|
@ -395,7 +357,7 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
|
||||||
appState.setOrderType(OrderType.dineIn);
|
appState.setOrderType(OrderType.dineIn);
|
||||||
Api.setBusinessId(beacon.businessId);
|
Api.setBusinessId(beacon.businessId);
|
||||||
|
|
||||||
print('[Splash] 🎉 Auto-selected: ${beacon.businessName}');
|
print('[Splash] Auto-selected: ${beacon.businessName}');
|
||||||
|
|
||||||
Navigator.of(context).pushReplacementNamed(
|
Navigator.of(context).pushReplacementNamed(
|
||||||
AppRoutes.menuBrowse,
|
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:permission_handler/permission_handler.dart';
|
||||||
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
|
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
|
||||||
|
|
||||||
|
import 'beacon_channel.dart';
|
||||||
|
|
||||||
class BeaconPermissions {
|
class BeaconPermissions {
|
||||||
static Future<bool> requestPermissions() async {
|
static Future<bool> requestPermissions() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -28,9 +30,9 @@ class BeaconPermissions {
|
||||||
final allGranted = locationStatus.isGranted && bluetoothGranted;
|
final allGranted = locationStatus.isGranted && bluetoothGranted;
|
||||||
|
|
||||||
if (allGranted) {
|
if (allGranted) {
|
||||||
debugPrint('[BeaconPermissions] ✅ All permissions granted');
|
debugPrint('[BeaconPermissions] All permissions granted');
|
||||||
} else {
|
} else {
|
||||||
debugPrint('[BeaconPermissions] ❌ Permissions denied');
|
debugPrint('[BeaconPermissions] Permissions denied');
|
||||||
}
|
}
|
||||||
|
|
||||||
return allGranted;
|
return allGranted;
|
||||||
|
|
@ -43,6 +45,12 @@ class BeaconPermissions {
|
||||||
/// Check if Bluetooth is enabled - returns current state without prompting
|
/// Check if Bluetooth is enabled - returns current state without prompting
|
||||||
static Future<bool> isBluetoothEnabled() async {
|
static Future<bool> isBluetoothEnabled() async {
|
||||||
try {
|
try {
|
||||||
|
// Use native channel on Android
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return await BeaconChannel.isBluetoothEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Flutter plugin on iOS
|
||||||
final bluetoothState = await flutterBeacon.bluetoothState;
|
final bluetoothState = await flutterBeacon.bluetoothState;
|
||||||
return bluetoothState == BluetoothState.stateOn;
|
return bluetoothState == BluetoothState.stateOn;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -54,7 +62,7 @@ class BeaconPermissions {
|
||||||
/// Request to enable Bluetooth via system prompt (Android only)
|
/// Request to enable Bluetooth via system prompt (Android only)
|
||||||
static Future<bool> requestEnableBluetooth() async {
|
static Future<bool> requestEnableBluetooth() async {
|
||||||
try {
|
try {
|
||||||
debugPrint('[BeaconPermissions] 📶 Requesting Bluetooth enable...');
|
debugPrint('[BeaconPermissions] Requesting Bluetooth enable...');
|
||||||
// This opens a system dialog on Android asking user to turn on Bluetooth
|
// This opens a system dialog on Android asking user to turn on Bluetooth
|
||||||
final result = await flutterBeacon.requestAuthorization;
|
final result = await flutterBeacon.requestAuthorization;
|
||||||
debugPrint('[BeaconPermissions] Request authorization result: $result');
|
debugPrint('[BeaconPermissions] Request authorization result: $result');
|
||||||
|
|
@ -82,45 +90,45 @@ class BeaconPermissions {
|
||||||
static Future<bool> ensureBluetoothEnabled() async {
|
static Future<bool> ensureBluetoothEnabled() async {
|
||||||
try {
|
try {
|
||||||
// Check current Bluetooth state
|
// Check current Bluetooth state
|
||||||
final bluetoothState = await flutterBeacon.bluetoothState;
|
final isOn = await isBluetoothEnabled();
|
||||||
debugPrint('[BeaconPermissions] 📶 Bluetooth state: $bluetoothState');
|
debugPrint('[BeaconPermissions] Bluetooth state: ${isOn ? "ON" : "OFF"}');
|
||||||
|
|
||||||
if (bluetoothState == BluetoothState.stateOn) {
|
if (isOn) {
|
||||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is ON');
|
debugPrint('[BeaconPermissions] Bluetooth is ON');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request to enable Bluetooth via system prompt
|
// Request to enable Bluetooth via system prompt
|
||||||
debugPrint('[BeaconPermissions] ⚠️ Bluetooth is OFF, requesting enable...');
|
debugPrint('[BeaconPermissions] Bluetooth is OFF, requesting enable...');
|
||||||
await requestEnableBluetooth();
|
await requestEnableBluetooth();
|
||||||
|
|
||||||
// Poll for Bluetooth state change - short wait first
|
// Poll for Bluetooth state change - short wait first
|
||||||
for (int i = 0; i < 6; i++) {
|
for (int i = 0; i < 6; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
final newState = await flutterBeacon.bluetoothState;
|
final newState = await isBluetoothEnabled();
|
||||||
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state ($i): $newState');
|
debugPrint('[BeaconPermissions] Polling Bluetooth state ($i): ${newState ? "ON" : "OFF"}');
|
||||||
if (newState == BluetoothState.stateOn) {
|
if (newState) {
|
||||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
|
debugPrint('[BeaconPermissions] Bluetooth is now ON');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still off after 3 seconds, try opening Bluetooth settings directly
|
// 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();
|
await openBluetoothSettings();
|
||||||
|
|
||||||
// Poll again for up to 15 seconds (user needs time to toggle in settings)
|
// Poll again for up to 15 seconds (user needs time to toggle in settings)
|
||||||
for (int i = 0; i < 30; i++) {
|
for (int i = 0; i < 30; i++) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
final newState = await flutterBeacon.bluetoothState;
|
final newState = await isBluetoothEnabled();
|
||||||
debugPrint('[BeaconPermissions] 📶 Polling Bluetooth state after settings ($i): $newState');
|
debugPrint('[BeaconPermissions] Polling Bluetooth state after settings ($i): ${newState ? "ON" : "OFF"}');
|
||||||
if (newState == BluetoothState.stateOn) {
|
if (newState) {
|
||||||
debugPrint('[BeaconPermissions] ✅ Bluetooth is now ON');
|
debugPrint('[BeaconPermissions] Bluetooth is now ON');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('[BeaconPermissions] ❌ Bluetooth still OFF after waiting');
|
debugPrint('[BeaconPermissions] Bluetooth still OFF after waiting');
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[BeaconPermissions] Error checking Bluetooth state: $e');
|
debugPrint('[BeaconPermissions] Error checking Bluetooth state: $e');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue