payfrit-app/lib/services/beacon_scanner_service.dart
John Mizerek c4792189dd App Store Version 2: Beacon scanning, preload caching, business selector
Features:
- Beacon scanner service for detecting nearby beacons
- Beacon cache for offline-first beacon resolution
- Preload cache for instant menu display
- Business selector screen for multi-location support
- Rescan button widget for quick beacon refresh
- Sign-in dialog for guest checkout flow
- Task type model for server tasks

Improvements:
- Enhanced menu browsing with category filtering
- Improved cart view with better modifier display
- Order history with detailed order tracking
- Chat screen improvements
- Better error handling in API service

Fixes:
- CashApp payment return crash fix
- Modifier nesting issues resolved
- Auto-expand modifier groups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:51:54 -08:00

204 lines
6.7 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart';
import 'api.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");
}
// Initialize scanning
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 = {};
final Map<String, int> detectionCounts = {};
debugPrint('[BeaconScanner] 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);
detectionCounts[uuid] = (detectionCounts[uuid] ?? 0) + 1;
debugPrint('[BeaconScanner] Found $uuid RSSI=$rssi');
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
if (rssiSamples.isEmpty) {
debugPrint('[BeaconScanner] No beacons detected');
return const BeaconScanResult(beaconsFound: 0);
}
// Lookup found beacons
debugPrint('[BeaconScanner] Looking up ${rssiSamples.length} beacons...');
final uuids = rssiSamples.keys.toList();
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: rssiSamples.length,
error: "Failed to lookup beacons",
);
}
// Find the best registered beacon
final bestBeacon = _findBestBeacon(lookupResults, rssiSamples, detectionCounts);
debugPrint('[BeaconScanner] Best beacon: ${bestBeacon?.beaconName ?? "none"}');
return BeaconScanResult(
bestBeacon: bestBeacon,
beaconsFound: rssiSamples.length,
);
} catch (e) {
debugPrint('[BeaconScanner] Scan error: $e');
return BeaconScanResult(error: e.toString());
} finally {
_isScanning = false;
_scanStateController.add(false);
}
}
/// Find the best registered beacon based on RSSI
BeaconLookupResult? _findBestBeacon(
List<BeaconLookupResult> registeredBeacons,
Map<String, List<int>> rssiSamples,
Map<String, int> detectionCounts,
) {
if (registeredBeacons.isEmpty) return null;
BeaconLookupResult? best;
double bestAvgRssi = -999;
for (final beacon in registeredBeacons) {
final samples = rssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
final detections = detectionCounts[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 if none meet threshold
if (best == null) {
for (final beacon in registeredBeacons) {
final samples = rssiSamples[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;
}
void dispose() {
_scanStateController.close();
}
}