diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 585a8fc..ea1a5c2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -59,6 +59,11 @@ android { } } +dependencies { + // AltBeacon library for native beacon scanning + implementation("org.altbeacon:android-beacon-library:2.20.6") +} + flutter { source = "../.." } diff --git a/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt b/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt new file mode 100644 index 0000000..4594afb --- /dev/null +++ b/android/app/src/main/kotlin/com/payfrit/app/BeaconScanner.kt @@ -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>) -> Unit)? = null + private var errorCallback: ((String) -> Unit)? = null + + // Collected beacon data: UUID -> list of RSSI samples + private val beaconSamples = mutableMapOf>() + + 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, + onComplete: (List>) -> 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, 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( + "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) + } + } +} diff --git a/android/app/src/main/kotlin/com/payfrit/app/MainActivity.kt b/android/app/src/main/kotlin/com/payfrit/app/MainActivity.kt index 25fb460..812e6d3 100644 --- a/android/app/src/main/kotlin/com/payfrit/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/payfrit/app/MainActivity.kt @@ -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>("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() + } } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 78ec8e4..e05b703 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -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 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 with TickerProviderStateMix "connecting...", ]; - // Beacon scanning state - new approach: scan all, then lookup - final Map> _beaconRssiSamples = {}; - final Map _beaconDetectionCount = {}; + // Beacon scanning state bool _scanComplete = false; BeaconLookupResult? _bestBeacon; @@ -64,7 +59,7 @@ class _SplashScreenState extends State 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 with TickerProviderStateMix Future _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.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 with TickerProviderStateMix } Future _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 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? 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 with TickerProviderStateMix _scanComplete = true; } - /// Find the best registered beacon from lookup results based on RSSI - BeaconLookupResult? _findBestRegisteredBeacon(List 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 _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 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, diff --git a/lib/services/beacon_channel.dart b/lib/services/beacon_channel.dart new file mode 100644 index 0000000..209b04f --- /dev/null +++ b/lib/services/beacon_channel.dart @@ -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 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 hasPermissions() async { + if (!isSupported) return false; + + try { + final result = await _channel.invokeMethod("hasPermissions"); + return result ?? false; + } on PlatformException catch (e) { + print("[BeaconChannel] hasPermissions error: $e"); + return false; + } + } + + /// Check if Bluetooth is enabled + static Future isBluetoothEnabled() async { + if (!isSupported) return false; + + try { + final result = await _channel.invokeMethod("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> startScan({List? regions}) async { + if (!isSupported) { + print("[BeaconChannel] Not supported on this platform"); + return []; + } + + try { + print("[BeaconChannel] Starting native beacon scan..."); + + final result = await _channel.invokeMethod>( + "startScan", + {"regions": regions ?? []}, + ); + + if (result == null) { + print("[BeaconChannel] Scan returned null"); + return []; + } + + final beacons = result + .map((e) => DetectedBeacon.fromMap(e as Map)) + .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 stopScan() async { + if (!isSupported) return; + + try { + await _channel.invokeMethod("stopScan"); + } on PlatformException catch (e) { + print("[BeaconChannel] stopScan error: $e"); + } + } +} diff --git a/lib/services/beacon_permissions.dart b/lib/services/beacon_permissions.dart index c6a6234..161f8f6 100644 --- a/lib/services/beacon_permissions.dart +++ b/lib/services/beacon_permissions.dart @@ -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 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 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 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 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');