- Update beacon_scan_screen and beacon_scanner_service to use native Android scanner with Flutter plugin fallback for iOS - Add native permission checking for fast path startup - Simplify splash screen: remove bouncing logo animation, show only spinner on black background for clean transition - Native splash is now black-only to match Flutter splash Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
233 lines
7.6 KiB
Dart
233 lines
7.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
|
|
|
|
import 'api.dart';
|
|
import 'beacon_channel.dart';
|
|
import 'beacon_permissions.dart';
|
|
|
|
/// Result of a beacon scan
|
|
class BeaconScanResult {
|
|
final BeaconLookupResult? bestBeacon;
|
|
final int beaconsFound;
|
|
final String? error;
|
|
|
|
const BeaconScanResult({
|
|
this.bestBeacon,
|
|
this.beaconsFound = 0,
|
|
this.error,
|
|
});
|
|
|
|
bool get success => error == null;
|
|
bool get foundBeacon => bestBeacon != null;
|
|
}
|
|
|
|
/// Global beacon scanner service for rescanning from anywhere in the app
|
|
class BeaconScannerService {
|
|
static final BeaconScannerService _instance = BeaconScannerService._internal();
|
|
factory BeaconScannerService() => _instance;
|
|
BeaconScannerService._internal();
|
|
|
|
bool _isScanning = false;
|
|
bool get isScanning => _isScanning;
|
|
|
|
// Callbacks for UI updates
|
|
final _scanStateController = StreamController<bool>.broadcast();
|
|
Stream<bool> get scanStateStream => _scanStateController.stream;
|
|
|
|
/// Perform a beacon scan
|
|
/// If [businessId] is provided, only scans for that business's beacons (optimized)
|
|
/// Otherwise, scans for all beacons and looks them up
|
|
Future<BeaconScanResult> scan({int? businessId}) async {
|
|
if (_isScanning) {
|
|
return const BeaconScanResult(error: "Scan already in progress");
|
|
}
|
|
|
|
_isScanning = true;
|
|
_scanStateController.add(true);
|
|
|
|
try {
|
|
// Request permissions
|
|
final granted = await BeaconPermissions.requestPermissions();
|
|
if (!granted) {
|
|
return const BeaconScanResult(error: "Permissions denied");
|
|
}
|
|
|
|
// Check Bluetooth
|
|
final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled();
|
|
if (!bluetoothOn) {
|
|
return const BeaconScanResult(error: "Bluetooth is off");
|
|
}
|
|
|
|
// Get beacon UUIDs to scan for
|
|
Map<String, int> knownBeacons = {};
|
|
|
|
if (businessId != null && businessId > 0) {
|
|
// Optimized: only get beacons for this business
|
|
debugPrint('[BeaconScanner] Scanning for business $businessId beacons only');
|
|
try {
|
|
knownBeacons = await Api.listBeaconsByBusiness(businessId: businessId);
|
|
debugPrint('[BeaconScanner] Got ${knownBeacons.length} beacons for business');
|
|
} catch (e) {
|
|
debugPrint('[BeaconScanner] Failed to get business beacons: $e');
|
|
}
|
|
}
|
|
|
|
// Fall back to all beacons if business-specific didn't work
|
|
if (knownBeacons.isEmpty) {
|
|
debugPrint('[BeaconScanner] Fetching all beacon UUIDs');
|
|
try {
|
|
knownBeacons = await Api.listAllBeacons();
|
|
debugPrint('[BeaconScanner] Got ${knownBeacons.length} total beacons');
|
|
} catch (e) {
|
|
debugPrint('[BeaconScanner] Failed to fetch beacons: $e');
|
|
return BeaconScanResult(error: "Failed to fetch beacons: $e");
|
|
}
|
|
}
|
|
|
|
if (knownBeacons.isEmpty) {
|
|
return const BeaconScanResult(error: "No beacons configured");
|
|
}
|
|
|
|
// Use native scanner on Android, Flutter plugin on iOS
|
|
List<DetectedBeacon> detectedBeacons = [];
|
|
|
|
if (Platform.isAndroid) {
|
|
debugPrint('[BeaconScanner] Using native Android scanner...');
|
|
detectedBeacons = await BeaconChannel.startScan(
|
|
regions: knownBeacons.keys.toList(),
|
|
);
|
|
} else {
|
|
// iOS: use Flutter plugin
|
|
debugPrint('[BeaconScanner] Using Flutter plugin for iOS...');
|
|
detectedBeacons = await _scanWithFlutterPlugin(knownBeacons);
|
|
}
|
|
|
|
if (detectedBeacons.isEmpty) {
|
|
debugPrint('[BeaconScanner] No beacons detected');
|
|
return const BeaconScanResult(beaconsFound: 0);
|
|
}
|
|
|
|
debugPrint('[BeaconScanner] Detected ${detectedBeacons.length} beacons');
|
|
|
|
// Filter to only known beacons
|
|
final validBeacons = detectedBeacons
|
|
.where((b) => knownBeacons.containsKey(b.uuid))
|
|
.toList();
|
|
|
|
if (validBeacons.isEmpty) {
|
|
debugPrint('[BeaconScanner] No known beacons found');
|
|
return BeaconScanResult(beaconsFound: detectedBeacons.length);
|
|
}
|
|
|
|
// Lookup found beacons
|
|
final uuids = validBeacons.map((b) => b.uuid).toList();
|
|
debugPrint('[BeaconScanner] Looking up ${uuids.length} beacons...');
|
|
|
|
List<BeaconLookupResult> lookupResults = [];
|
|
try {
|
|
lookupResults = await Api.lookupBeacons(uuids);
|
|
debugPrint('[BeaconScanner] Server returned ${lookupResults.length} registered beacons');
|
|
} catch (e) {
|
|
debugPrint('[BeaconScanner] Lookup error: $e');
|
|
return BeaconScanResult(
|
|
beaconsFound: validBeacons.length,
|
|
error: "Failed to lookup beacons",
|
|
);
|
|
}
|
|
|
|
// Find the best registered beacon based on RSSI
|
|
final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi};
|
|
final bestBeacon = _findBestBeacon(lookupResults, rssiMap);
|
|
|
|
debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}');
|
|
|
|
return BeaconScanResult(
|
|
bestBeacon: bestBeacon,
|
|
beaconsFound: validBeacons.length,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[BeaconScanner] Scan error: $e');
|
|
return BeaconScanResult(error: e.toString());
|
|
} finally {
|
|
_isScanning = false;
|
|
_scanStateController.add(false);
|
|
}
|
|
}
|
|
|
|
/// iOS fallback: scan with Flutter plugin
|
|
Future<List<DetectedBeacon>> _scanWithFlutterPlugin(Map<String, int> knownBeacons) async {
|
|
await flutterBeacon.initializeScanning;
|
|
|
|
// Create regions for all known UUIDs
|
|
final regions = knownBeacons.keys.map((uuid) {
|
|
final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}';
|
|
return Region(identifier: uuid, proximityUUID: formattedUUID);
|
|
}).toList();
|
|
|
|
// Collect RSSI samples
|
|
final Map<String, List<int>> rssiSamples = {};
|
|
|
|
debugPrint('[BeaconScanner] iOS: Starting 2-second scan...');
|
|
StreamSubscription<RangingResult>? subscription;
|
|
subscription = flutterBeacon.ranging(regions).listen((result) {
|
|
for (var beacon in result.beacons) {
|
|
final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', '');
|
|
final rssi = beacon.rssi;
|
|
rssiSamples.putIfAbsent(uuid, () => []).add(rssi);
|
|
}
|
|
});
|
|
|
|
await Future.delayed(const Duration(milliseconds: 2000));
|
|
await subscription.cancel();
|
|
|
|
// Convert to DetectedBeacon format
|
|
return rssiSamples.entries.map((entry) {
|
|
final samples = entry.value;
|
|
final avgRssi = samples.reduce((a, b) => a + b) ~/ samples.length;
|
|
return DetectedBeacon(
|
|
uuid: entry.key,
|
|
rssi: avgRssi,
|
|
samples: samples.length,
|
|
);
|
|
}).toList()..sort((a, b) => b.rssi.compareTo(a.rssi));
|
|
}
|
|
|
|
/// Find the best registered beacon based on RSSI
|
|
BeaconLookupResult? _findBestBeacon(
|
|
List<BeaconLookupResult> registeredBeacons,
|
|
Map<String, int> rssiMap,
|
|
) {
|
|
if (registeredBeacons.isEmpty) return null;
|
|
|
|
BeaconLookupResult? best;
|
|
int bestRssi = -999;
|
|
|
|
// First pass: find beacon with RSSI >= -85
|
|
for (final beacon in registeredBeacons) {
|
|
final rssi = rssiMap[beacon.uuid] ?? -100;
|
|
if (rssi > bestRssi && rssi >= -85) {
|
|
bestRssi = rssi;
|
|
best = beacon;
|
|
}
|
|
}
|
|
|
|
// Fall back to strongest if none meet threshold
|
|
if (best == null) {
|
|
for (final beacon in registeredBeacons) {
|
|
final rssi = rssiMap[beacon.uuid] ?? -100;
|
|
if (rssi > bestRssi) {
|
|
bestRssi = rssi;
|
|
best = beacon;
|
|
}
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
void dispose() {
|
|
_scanStateController.close();
|
|
}
|
|
}
|