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>
204 lines
6.7 KiB
Dart
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();
|
|
}
|
|
}
|