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:
John Mizerek 2026-01-27 00:53:46 -08:00
parent 0adb172b9e
commit bc81ca98b0
6 changed files with 503 additions and 143 deletions

View file

@ -59,6 +59,11 @@ android {
}
}
dependencies {
// AltBeacon library for native beacon scanning
implementation("org.altbeacon:android-beacon-library:2.20.6")
}
flutter {
source = "../.."
}

View 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)
}
}
}

View file

@ -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()
}
}

View file

@ -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');
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
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
} catch (e) {
print('[Splash] Error looking up beacons: $e');
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,

View 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");
}
}
}

View file

@ -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');