diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index 926974e..875dc12 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -44,6 +44,16 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void setBusinessAndServicePoint(int businessId, int servicePointId) { + _selectedBusinessId = businessId; + _selectedServicePointId = servicePointId; + + _cartOrderId = null; + _cartOrderUuid = null; + + notifyListeners(); + } + void setUserId(int userId) { _userId = userId; notifyListeners(); diff --git a/lib/screens/beacon_scan_screen.dart b/lib/screens/beacon_scan_screen.dart index 6a15352..25d04ea 100644 --- a/lib/screens/beacon_scan_screen.dart +++ b/lib/screens/beacon_scan_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; +import 'package:provider/provider.dart'; import '../app/app_router.dart'; +import '../app/app_state.dart'; import '../services/beacon_permissions.dart'; import '../services/api.dart'; @@ -19,7 +21,8 @@ class _BeaconScanScreenState extends State { bool _scanning = false; Map _uuidToBeaconId = {}; - final Map> _detectedBeacons = {}; // UUID -> (BeaconID, RSSI) + final Map> _beaconRssiSamples = {}; // UUID -> List of RSSI values + final Map _beaconDetectionCount = {}; // UUID -> detection count @override void initState() { @@ -48,6 +51,14 @@ class _BeaconScanScreenState extends State { try { _uuidToBeaconId = await Api.listAllBeacons(); + debugPrint('[BeaconScan] ========================================'); + debugPrint('[BeaconScan] Loaded ${_uuidToBeaconId.length} beacons from database:'); + _uuidToBeaconId.forEach((uuid, beaconId) { + debugPrint('[BeaconScan] BeaconID=$beaconId'); + debugPrint('[BeaconScan] UUID=$uuid'); + debugPrint('[BeaconScan] ---'); + }); + debugPrint('[BeaconScan] ========================================'); } catch (e) { debugPrint('[BeaconScan] Error loading beacons: $e'); _uuidToBeaconId = {}; @@ -77,56 +88,119 @@ class _BeaconScanScreenState extends State { final regions = _uuidToBeaconId.keys.map((uuid) { // Format UUID with dashes for the plugin final formattedUUID = '${uuid.substring(0, 8)}-${uuid.substring(8, 12)}-${uuid.substring(12, 16)}-${uuid.substring(16, 20)}-${uuid.substring(20)}'; + debugPrint('[BeaconScan] Creating region for UUID: $uuid -> $formattedUUID'); return Region(identifier: uuid, proximityUUID: formattedUUID); }).toList(); + debugPrint('[BeaconScan] Created ${regions.length} regions for scanning'); + if (regions.isEmpty) { if (mounted) _navigateToRestaurantSelect(); return; } - StreamSubscription? subscription; + // Perform 5 scans of 2 seconds each to increase chance of detecting all beacons + debugPrint('[BeaconScan] Starting 5 scan cycles of 2 seconds each'); - // Start ranging for 3 seconds - subscription = flutterBeacon.ranging(regions).listen((result) { - for (var beacon in result.beacons) { - final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); - final rssi = beacon.rssi; + for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { + debugPrint('[BeaconScan] ----- Scan cycle $scanCycle/5 -----'); - if (_uuidToBeaconId.containsKey(uuid)) { - final beaconId = _uuidToBeaconId[uuid]!; + StreamSubscription? subscription; - // Update if new or better RSSI - if (!_detectedBeacons.containsKey(uuid) || _detectedBeacons[uuid]!.value < rssi) { - setState(() { - _detectedBeacons[uuid] = MapEntry(beaconId, rssi); - }); - debugPrint('[BeaconScan] Detected: UUID=$uuid, BeaconID=$beaconId, RSSI=$rssi'); + subscription = flutterBeacon.ranging(regions).listen((result) { + debugPrint('[BeaconScan] Ranging result: ${result.beacons.length} beacons in range'); + for (var beacon in result.beacons) { + final rawUUID = beacon.proximityUUID; + final uuid = rawUUID.toUpperCase().replaceAll('-', ''); + final rssi = beacon.rssi; + + debugPrint('[BeaconScan] Raw beacon detected: UUID=$rawUUID (normalized: $uuid), RSSI=$rssi, Major=${beacon.major}, Minor=${beacon.minor}'); + + if (_uuidToBeaconId.containsKey(uuid)) { + // Collect RSSI samples for averaging + _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); + _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; + + debugPrint('[BeaconScan] MATCHED! BeaconID=${_uuidToBeaconId[uuid]}, Sample #${_beaconDetectionCount[uuid]}, RSSI=$rssi'); + } else { + debugPrint('[BeaconScan] UUID not in database, ignoring'); } } - } - }); + }); - // Wait 3 seconds - await Future.delayed(const Duration(seconds: 3)); - await subscription.cancel(); + // Wait 2 seconds for this scan cycle to collect beacon data + await Future.delayed(const Duration(seconds: 2)); + await subscription.cancel(); + + // Short pause between scan cycles + if (scanCycle < 5) { + await Future.delayed(const Duration(milliseconds: 200)); + } + } + + debugPrint('[BeaconScan] All scan cycles complete'); if (!mounted) return; - if (_detectedBeacons.isEmpty) { + // Analyze results and select best beacon + debugPrint('[BeaconScan] ===== SCAN COMPLETE ====='); + debugPrint('[BeaconScan] Total beacons detected: ${_beaconRssiSamples.length}'); + + final beaconScores = {}; + + for (final uuid in _beaconRssiSamples.keys) { + final samples = _beaconRssiSamples[uuid]!; + final detections = _beaconDetectionCount[uuid]!; + final beaconId = _uuidToBeaconId[uuid]!; + + // Calculate average RSSI + final avgRssi = samples.reduce((a, b) => a + b) / samples.length; + + // Calculate RSSI variance for stability metric + final variance = samples.map((r) => (r - avgRssi) * (r - avgRssi)).reduce((a, b) => a + b) / samples.length; + + beaconScores[uuid] = BeaconScore( + uuid: uuid, + beaconId: beaconId, + avgRssi: avgRssi, + minRssi: samples.reduce((a, b) => a < b ? a : b), + maxRssi: samples.reduce((a, b) => a > b ? a : b), + detectionCount: detections, + variance: variance, + ); + } + + if (beaconScores.isNotEmpty) { + debugPrint('[BeaconScan] Beacon analysis results:'); + final sorted = beaconScores.values.toList() + ..sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); // Sort by avg RSSI descending + + for (final score in sorted) { + debugPrint('[BeaconScan] - BeaconID=${score.beaconId}:'); + debugPrint('[BeaconScan] Avg RSSI: ${score.avgRssi.toStringAsFixed(1)}'); + debugPrint('[BeaconScan] Range: ${score.minRssi} to ${score.maxRssi}'); + debugPrint('[BeaconScan] Detections: ${score.detectionCount}'); + debugPrint('[BeaconScan] Variance: ${score.variance.toStringAsFixed(2)}'); + } + } + debugPrint('[BeaconScan] =========================='); + + if (beaconScores.isEmpty) { setState(() => _status = 'No beacons nearby'); await Future.delayed(const Duration(milliseconds: 800)); if (mounted) _navigateToRestaurantSelect(); } else { - // Find beacon with highest RSSI - final best = _findBestBeacon(); + // Find beacon with highest average RSSI and minimum detections + final best = _findBestBeacon(beaconScores); if (best != null) { - setState(() => _status = 'Beacon detected! BeaconID=${best.value}'); - await Future.delayed(const Duration(milliseconds: 800)); - // TODO: Auto-select business from beacon - if (mounted) _navigateToRestaurantSelect(); + debugPrint('[BeaconScan] Selected beacon: BeaconID=${best.beaconId} (avg RSSI: ${best.avgRssi.toStringAsFixed(1)})'); + setState(() => _status = 'Beacon detected! Loading business...'); + await _autoSelectBusinessFromBeacon(best.beaconId); } else { - _navigateToRestaurantSelect(); + debugPrint('[BeaconScan] No beacon met minimum confidence threshold'); + setState(() => _status = 'No strong beacon signal'); + await Future.delayed(const Duration(milliseconds: 800)); + if (mounted) _navigateToRestaurantSelect(); } } } catch (e) { @@ -139,25 +213,65 @@ class _BeaconScanScreenState extends State { } } - MapEntry? _findBestBeacon() { - if (_detectedBeacons.isEmpty) return null; + Future _autoSelectBusinessFromBeacon(int beaconId) async { + try { + final mapping = await Api.getBusinessFromBeacon(beaconId: beaconId); - String? bestUUID; - int bestRSSI = -200; + if (!mounted) return; - for (final entry in _detectedBeacons.entries) { - if (entry.value.value > bestRSSI) { - bestRSSI = entry.value.value; - bestUUID = entry.key; + // Update app state with selected business and service point + final appState = context.read(); + appState.setBusinessAndServicePoint(mapping.businessId, mapping.servicePointId); + + // Update API business ID for headers + Api.setBusinessId(mapping.businessId); + + setState(() => _status = 'Welcome to ${mapping.businessName}!'); + await Future.delayed(const Duration(milliseconds: 800)); + + if (!mounted) return; + + // Navigate directly to menu (user can browse without logging in) + Navigator.of(context).pushReplacementNamed( + AppRoutes.menuBrowse, + arguments: { + 'businessId': mapping.businessId, + 'servicePointId': mapping.servicePointId, + }, + ); + } catch (e) { + debugPrint('[BeaconScan] Error fetching business from beacon: $e'); + if (mounted) { + setState(() => _status = 'Beacon not assigned - selecting manually'); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) _navigateToRestaurantSelect(); } } + } - if (bestUUID != null) { - final beaconId = _detectedBeacons[bestUUID]!.key; - return MapEntry(bestUUID, beaconId); + BeaconScore? _findBestBeacon(Map scores) { + if (scores.isEmpty) return null; + + // Filter beacons that meet minimum requirements + const minDetections = 3; // Must be seen at least 3 times + const minRssi = -85; // Minimum average RSSI (beacons further than ~10m will be weaker) + + final qualified = scores.values.where((score) { + return score.detectionCount >= minDetections && score.avgRssi >= minRssi; + }).toList(); + + if (qualified.isEmpty) { + debugPrint('[BeaconScan] No beacons met minimum requirements (detections>=$minDetections, avgRSSI>=$minRssi)'); + // Fall back to best available beacon if none meet threshold + final allSorted = scores.values.toList() + ..sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); + return allSorted.isNotEmpty ? allSorted.first : null; } - return null; + // Sort by average RSSI (higher is better/closer) + qualified.sort((a, b) => b.avgRssi.compareTo(a.avgRssi)); + + return qualified.first; } void _navigateToRestaurantSelect() { @@ -195,10 +309,10 @@ class _BeaconScanScreenState extends State { textAlign: TextAlign.center, ), - if (_detectedBeacons.isNotEmpty) ...[ + if (_beaconRssiSamples.isNotEmpty) ...[ const SizedBox(height: 16), Text( - 'Found ${_detectedBeacons.length} beacon(s)', + 'Found ${_beaconRssiSamples.length} beacon(s)', style: const TextStyle(color: Colors.white70, fontSize: 12), ), ], @@ -216,9 +330,38 @@ class _BeaconScanScreenState extends State { child: const Text('Skip and select manually'), ), ], + + if (_permissionsGranted && _scanning) ...[ + const SizedBox(height: 24), + TextButton( + onPressed: _navigateToRestaurantSelect, + style: TextButton.styleFrom(foregroundColor: Colors.white70), + child: const Text('Skip and select manually'), + ), + ], ], ), ), ); } } + +class BeaconScore { + final String uuid; + final int beaconId; + final double avgRssi; + final int minRssi; + final int maxRssi; + final int detectionCount; + final double variance; + + const BeaconScore({ + required this.uuid, + required this.beaconId, + required this.avgRssi, + required this.minRssi, + required this.maxRssi, + required this.detectionCount, + required this.variance, + }); +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index f737637..01ed52e 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -48,7 +48,12 @@ class _LoginScreenState extends State { final appState = context.read(); appState.setUserId(result.userId); - Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); + // Go back to previous screen (menu) or beacon scan if no previous route + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } else { + Navigator.of(context).pushReplacementNamed(AppRoutes.beaconScan); + } } catch (e) { if (!mounted) return; diff --git a/lib/screens/menu_browse_screen.dart b/lib/screens/menu_browse_screen.dart index b41513c..0b53813 100644 --- a/lib/screens/menu_browse_screen.dart +++ b/lib/screens/menu_browse_screen.dart @@ -40,11 +40,13 @@ class _MenuBrowseScreenState extends State { void didChangeDependencies() { super.didChangeDependencies(); + final appState = context.watch(); + final u = appState.userId; + final args = ModalRoute.of(context)?.settings.arguments; if (args is Map) { final b = _asIntNullable(args["BusinessID"]) ?? _asIntNullable(args["businessId"]); final sp = _asIntNullable(args["ServicePointID"]) ?? _asIntNullable(args["servicePointId"]); - final u = _asIntNullable(args["UserID"]) ?? _asIntNullable(args["userId"]); if (_businessId != b || _servicePointId != sp || _userId != u || _future == null) { _businessId = b; @@ -238,7 +240,33 @@ class _MenuBrowseScreenState extends State { print("DEBUG: _addToCart called for item ${item.name} (ItemID=${item.itemId})"); print("DEBUG: Selected modifier IDs: $selectedModifierIds"); - if (_userId == null || _businessId == null || _servicePointId == null) { + // Check if user is logged in - if not, navigate to login + if (_userId == null) { + final shouldLogin = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Login Required"), + content: const Text("Please login to add items to your cart."), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text("Login"), + ), + ], + ), + ); + + if (shouldLogin == true && mounted) { + Navigator.of(context).pushNamed(AppRoutes.login); + } + return; + } + + if (_businessId == null || _servicePointId == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Missing required information")), ); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 49266fa..cd400be 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -22,14 +22,8 @@ class _SplashScreenState extends State { _timer = Timer(const Duration(milliseconds: 2400), () { if (!mounted) return; - final appState = context.read(); - - // Navigate based on authentication status - if (appState.isLoggedIn) { - Navigator.of(context).pushReplacementNamed(AppRoutes.beaconScan); - } else { - Navigator.of(context).pushReplacementNamed(AppRoutes.login); - } + // Always go to beacon scan first - allows browsing without login + Navigator.of(context).pushReplacementNamed(AppRoutes.beaconScan); }); } diff --git a/lib/services/api.dart b/lib/services/api.dart index 6557630..329559e 100644 --- a/lib/services/api.dart +++ b/lib/services/api.dart @@ -435,4 +435,62 @@ class Api { return uuidToBeaconId; } + + static Future getBusinessFromBeacon({ + required int beaconId, + }) async { + final raw = await _postRaw( + "/beacons/getBusinessFromBeacon.cfm", + {"BeaconID": beaconId}, + ); + final j = _requireJson(raw, "GetBusinessFromBeacon"); + + if (!_ok(j)) { + throw StateError( + "GetBusinessFromBeacon API returned OK=false\nERROR: ${_err(j)}\nHTTP Status: ${raw.statusCode}", + ); + } + + final beacon = j["BEACON"] as Map? ?? {}; + final business = j["BUSINESS"] as Map? ?? {}; + final servicePoint = j["SERVICEPOINT"] as Map? ?? {}; + + return BeaconBusinessMapping( + beaconId: _parseInt(beacon["BeaconID"]) ?? 0, + beaconName: (beacon["BeaconName"] as String?) ?? "", + businessId: _parseInt(business["BusinessID"]) ?? 0, + businessName: (business["BusinessName"] as String?) ?? "", + servicePointId: _parseInt(servicePoint["ServicePointID"]) ?? 0, + servicePointName: (servicePoint["ServicePointName"] as String?) ?? "", + ); + } + + static int? _parseInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + if (value.isEmpty) return null; + return int.tryParse(value); + } + return null; + } +} + +class BeaconBusinessMapping { + final int beaconId; + final String beaconName; + final int businessId; + final String businessName; + final int servicePointId; + final String servicePointName; + + const BeaconBusinessMapping({ + required this.beaconId, + required this.beaconName, + required this.businessId, + required this.businessName, + required this.servicePointId, + required this.servicePointName, + }); }