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 "../models/cart.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; import "../services/beacon_permissions.dart"; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override State createState() => _SplashScreenState(); } class _SplashScreenState extends State with TickerProviderStateMixin { // Bouncing logo animation late AnimationController _bounceController; double _x = 100; double _y = 100; double _dx = 2.5; double _dy = 2.0; Color _logoColor = Colors.white; final Random _random = Random(); // Rotating status text Timer? _statusTimer; int _statusIndex = 0; static const List _statusPhrases = [ "scanning...", "listening...", "searching...", "locating...", "analyzing...", "connecting...", ]; // Beacon scanning state Map _uuidToBeaconId = {}; final Map> _beaconRssiSamples = {}; final Map _beaconDetectionCount = {}; bool _scanComplete = false; BeaconResult? _bestBeacon; // Existing cart state ActiveCartInfo? _existingCart; BeaconBusinessMapping? _beaconMapping; // Skip scan state bool _scanSkipped = false; // Navigation state - true once we start navigating away bool _navigating = false; // Minimum display time for splash screen late DateTime _splashStartTime; static const List _colors = [ Colors.white, Colors.red, Colors.green, Colors.blue, Colors.yellow, Colors.purple, Colors.cyan, Colors.orange, ]; @override void initState() { super.initState(); print('[Splash] 🚀 Starting with bouncing logo + beacon scan'); // Record start time for minimum display duration _splashStartTime = DateTime.now(); // Start bouncing animation _bounceController = AnimationController( vsync: this, duration: const Duration(milliseconds: 16), // ~60fps )..addListener(_updatePosition)..repeat(); // Start rotating status text (randomized) _statusTimer = Timer.periodic(const Duration(milliseconds: 1600), (_) { if (mounted) { setState(() { int newIndex; do { newIndex = _random.nextInt(_statusPhrases.length); } while (newIndex == _statusIndex && _statusPhrases.length > 1); _statusIndex = newIndex; }); } }); // Start the initialization flow _initializeApp(); } void _updatePosition() { if (!mounted) return; final size = MediaQuery.of(context).size; const logoWidth = 180.0; const logoHeight = 60.0; setState(() { _x += _dx; _y += _dy; // Bounce off edges and change color if (_x <= 0 || _x >= size.width - logoWidth) { _dx = -_dx; _changeColor(); } if (_y <= 0 || _y >= size.height - logoHeight) { _dy = -_dy; _changeColor(); } // Keep in bounds _x = _x.clamp(0, size.width - logoWidth); _y = _y.clamp(0, size.height - logoHeight); }); } void _changeColor() { final newColor = _colors[_random.nextInt(_colors.length)]; if (newColor != _logoColor) { _logoColor = newColor; } } Future _initializeApp() async { // Check 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)}...'); Api.setAuthToken(credentials.token); // Validate token is still valid by calling profile endpoint print('[Splash] 🔍 Validating token with server...'); final isValid = await _validateToken(); if (isValid && mounted) { print('[Splash] ✅ Token is valid'); final appState = context.read(); appState.setUserId(credentials.userId); } else { print('[Splash] ❌ Token is invalid or expired, clearing saved auth'); await AuthStorage.clearAuth(); Api.clearAuthToken(); } } else { print('[Splash] ❌ No saved credentials found'); } // Start beacon scanning in background await _performBeaconScan(); // No minimum display time - proceed as soon as scan completes // Navigate based on results if (!mounted) return; _navigateToNextScreen(); } /// Validates the stored token by making a profile API call Future _validateToken() async { try { final profile = await Api.getProfile(); return profile.userId > 0; } catch (e) { print('[Splash] Token validation failed: $e'); return false; } } Future _performBeaconScan() async { print('[Splash] 📡 Starting beacon scan...'); // Request permissions final granted = await BeaconPermissions.requestPermissions(); if (!granted) { print('[Splash] ❌ Permissions denied'); _scanComplete = true; return; } // Check if Bluetooth is ON print('[Splash] 📶 Checking Bluetooth state...'); final bluetoothOn = await BeaconPermissions.ensureBluetoothEnabled(); if (!bluetoothOn) { print('[Splash] ❌ Bluetooth is OFF - cannot scan for beacons'); _scanComplete = true; return; } print('[Splash] ✅ Bluetooth is ON'); // Fetch beacon list from server try { _uuidToBeaconId = await Api.listAllBeacons(); print('[Splash] Loaded ${_uuidToBeaconId.length} beacons from database'); } catch (e) { print('[Splash] Error loading beacons: $e'); _scanComplete = true; return; } if (_uuidToBeaconId.isEmpty) { print('[Splash] No beacons in database'); _scanComplete = true; return; } // Initialize beacon scanning try { await flutterBeacon.initializeScanning; await Future.delayed(const Duration(milliseconds: 500)); // Create regions for all known UUIDs final regions = _uuidToBeaconId.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(); // Perform scan cycles for (int scanCycle = 1; scanCycle <= 5; scanCycle++) { print('[Splash] ----- Scan cycle $scanCycle/5 -----'); StreamSubscription? subscription; subscription = flutterBeacon.ranging(regions).listen((result) { for (var beacon in result.beacons) { final uuid = beacon.proximityUUID.toUpperCase().replaceAll('-', ''); final rssi = beacon.rssi; if (_uuidToBeaconId.containsKey(uuid)) { _beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi); _beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1; print('[Splash] ✅ Beacon ${_uuidToBeaconId[uuid]}, RSSI=$rssi'); } } }); await Future.delayed(const Duration(seconds: 2)); await subscription.cancel(); // Check for early exit after 3 cycles if (scanCycle >= 3 && _beaconRssiSamples.isNotEmpty && _canExitEarly()) { print('[Splash] ⚡ Early exit - stable readings'); break; } if (scanCycle < 5) { await Future.delayed(const Duration(milliseconds: 200)); } } // Find best beacon _bestBeacon = _findBestBeacon(); print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconId ?? "none"}'); } catch (e) { print('[Splash] Scan error: $e'); } _scanComplete = true; } bool _canExitEarly() { if (_beaconRssiSamples.isEmpty) return false; bool hasEnoughSamples = _beaconRssiSamples.values.any((s) => s.length >= 3); if (!hasEnoughSamples) return false; for (final entry in _beaconRssiSamples.entries) { final samples = entry.value; if (samples.length < 3) continue; final avg = samples.reduce((a, b) => a + b) / samples.length; final variance = samples.map((r) => (r - avg) * (r - avg)).reduce((a, b) => a + b) / samples.length; if (variance > 50) return false; } if (_beaconRssiSamples.length > 1) { final avgRssis = {}; for (final entry in _beaconRssiSamples.entries) { if (entry.value.isNotEmpty) { avgRssis[entry.key] = entry.value.reduce((a, b) => a + b) / entry.value.length; } } final sorted = avgRssis.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); if (sorted.length >= 2 && (sorted[0].value - sorted[1].value) < 5) { return false; } } return true; } BeaconResult? _findBestBeacon() { if (_beaconRssiSamples.isEmpty) return null; String? bestUuid; double bestAvgRssi = -999; for (final entry in _beaconRssiSamples.entries) { final samples = entry.value; final detections = _beaconDetectionCount[entry.key] ?? 0; if (detections < 3) continue; final avgRssi = samples.reduce((a, b) => a + b) / samples.length; if (avgRssi > bestAvgRssi && avgRssi >= -85) { bestAvgRssi = avgRssi; bestUuid = entry.key; } } if (bestUuid != null) { return BeaconResult( uuid: bestUuid, beaconId: _uuidToBeaconId[bestUuid]!, avgRssi: bestAvgRssi, ); } // Fall back to strongest signal even if doesn't meet threshold if (_beaconRssiSamples.isNotEmpty) { for (final entry in _beaconRssiSamples.entries) { final samples = entry.value; if (samples.isEmpty) continue; final avgRssi = samples.reduce((a, b) => a + b) / samples.length; if (avgRssi > bestAvgRssi) { bestAvgRssi = avgRssi; bestUuid = entry.key; } } if (bestUuid != null) { return BeaconResult( uuid: bestUuid, beaconId: _uuidToBeaconId[bestUuid]!, avgRssi: bestAvgRssi, ); } } return null; } Future _navigateToNextScreen() async { if (!mounted || _navigating) return; setState(() { _navigating = true; }); final appState = context.read(); // Get beacon mapping if we found a beacon if (_bestBeacon != null) { try { _beaconMapping = await Api.getBusinessFromBeacon(beaconId: _bestBeacon!.beaconId); print('[Splash] 📍 Beacon maps to: ${_beaconMapping!.businessName}'); } catch (e) { print('[Splash] Error mapping beacon to business: $e'); _beaconMapping = null; } } // Check for existing cart if user is logged in final userId = appState.userId; if (userId != null && userId > 0) { try { _existingCart = await Api.getActiveCart(userId: userId); if (_existingCart != null && _existingCart!.hasItems) { print('[Splash] 🛒 Found existing cart: ${_existingCart!.itemCount} items at ${_existingCart!.businessName}'); } else { _existingCart = null; } } catch (e) { print('[Splash] Error checking for existing cart: $e'); _existingCart = null; } } if (!mounted) return; // DECISION TREE: // 1. Beacon found? // - Yes: Is there an existing cart? // - Yes: Same restaurant? // - Yes: Continue order as dine-in, update service point // - No: Start fresh with beacon's restaurant (dine-in) // - No: Start fresh with beacon's restaurant (dine-in) // - No: Is there an existing cart? // - Yes: Show "Continue or Start Fresh?" popup // - No: Go to restaurant select if (_beaconMapping != null) { // BEACON FOUND if (_existingCart != null && _existingCart!.businessId == _beaconMapping!.businessId) { // Same restaurant - continue order, update to dine-in with new service point print('[Splash] ✅ Continuing existing order at same restaurant (dine-in)'); await _continueExistingOrderWithBeacon(); } else { // Different restaurant or no cart - start fresh with beacon print('[Splash] 🆕 Starting fresh dine-in order at ${_beaconMapping!.businessName}'); _startFreshWithBeacon(); } } else { // NO BEACON if (_existingCart != null) { // Has existing cart - ask user what to do print('[Splash] ❓ No beacon, but has existing cart - showing choice dialog'); _showContinueOrStartFreshDialog(); } else { // No cart, no beacon - go to restaurant select print('[Splash] 📋 No beacon, no cart - going to restaurant select'); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } } } /// Continue existing order and update to dine-in with beacon's service point Future _continueExistingOrderWithBeacon() async { if (!mounted || _existingCart == null || _beaconMapping == null) return; final appState = context.read(); // Update order type to dine-in and set service point try { await Api.setOrderType( orderId: _existingCart!.orderId, orderTypeId: 1, // dine-in ); } catch (e) { print('[Splash] Error updating order type: $e'); } // Set app state appState.setBusinessAndServicePoint( _beaconMapping!.businessId, _beaconMapping!.servicePointId, businessName: _beaconMapping!.businessName, servicePointName: _beaconMapping!.servicePointName, ); appState.setOrderType(OrderType.dineIn); appState.setCartOrder( orderId: _existingCart!.orderId, orderUuid: _existingCart!.orderUuid, itemCount: _existingCart!.itemCount, ); Api.setBusinessId(_beaconMapping!.businessId); Navigator.of(context).pushReplacementNamed( AppRoutes.menuBrowse, arguments: { 'businessId': _beaconMapping!.businessId, 'servicePointId': _beaconMapping!.servicePointId, }, ); } /// Start fresh dine-in order with beacon void _startFreshWithBeacon() { if (!mounted || _beaconMapping == null) return; final appState = context.read(); // Clear any existing cart reference appState.clearCart(); appState.setBusinessAndServicePoint( _beaconMapping!.businessId, _beaconMapping!.servicePointId, businessName: _beaconMapping!.businessName, servicePointName: _beaconMapping!.servicePointName, ); appState.setOrderType(OrderType.dineIn); Api.setBusinessId(_beaconMapping!.businessId); Navigator.of(context).pushReplacementNamed( AppRoutes.menuBrowse, arguments: { 'businessId': _beaconMapping!.businessId, 'servicePointId': _beaconMapping!.servicePointId, }, ); } /// Show dialog asking user to continue existing order or start fresh void _showContinueOrStartFreshDialog() { if (!mounted || _existingCart == null) return; showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text("Existing Order Found"), content: Text( "You have an existing order at ${_existingCart!.businessName} " "with ${_existingCart!.itemCount} item${_existingCart!.itemCount == 1 ? '' : 's'}.\n\n" "Would you like to continue with this order or start fresh?", ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); _startFresh(); }, child: const Text("Start Fresh"), ), ElevatedButton( onPressed: () { Navigator.of(context).pop(); _continueExistingOrder(); }, child: const Text("Continue Order"), ), ], ), ); } /// Continue with existing order (no beacon) void _continueExistingOrder() { if (!mounted || _existingCart == null) return; final appState = context.read(); // Only use service point if this is actually a dine-in order // Otherwise clear it to avoid showing stale table info final isDineIn = _existingCart!.isDineIn; appState.setBusinessAndServicePoint( _existingCart!.businessId, isDineIn && _existingCart!.servicePointId > 0 ? _existingCart!.servicePointId : 0, businessName: _existingCart!.businessName, servicePointName: isDineIn ? _existingCart!.servicePointName : null, ); // Set order type based on existing cart if (isDineIn) { appState.setOrderType(OrderType.dineIn); } else if (_existingCart!.isTakeaway) { appState.setOrderType(OrderType.takeaway); } else if (_existingCart!.isDelivery) { appState.setOrderType(OrderType.delivery); } else { appState.setOrderType(null); // Undecided - will choose at checkout } appState.setCartOrder( orderId: _existingCart!.orderId, orderUuid: _existingCart!.orderUuid, itemCount: _existingCart!.itemCount, ); Api.setBusinessId(_existingCart!.businessId); Navigator.of(context).pushReplacementNamed( AppRoutes.menuBrowse, arguments: { 'businessId': _existingCart!.businessId, 'servicePointId': _existingCart!.servicePointId, }, ); } /// Start fresh - abandon existing order and go to restaurant select Future _startFresh() async { if (!mounted) return; final appState = context.read(); // Abandon the existing order on the server if (_existingCart != null) { print('[Splash] Abandoning order ${_existingCart!.orderId}...'); try { await Api.abandonOrder(orderId: _existingCart!.orderId); print('[Splash] Order abandoned successfully'); } catch (e) { // Ignore errors - just proceed with clearing local state print('[Splash] Failed to abandon order: $e'); } } else { print('[Splash] No existing cart to abandon'); } appState.clearCart(); if (!mounted) return; Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } /// Skip the beacon scan and proceed without dine-in detection void _skipScan() { if (_scanSkipped || _navigating) return; print('[Splash] ⏭️ User skipped beacon scan'); setState(() { _scanSkipped = true; _scanComplete = true; _bestBeacon = null; // No beacon since we skipped }); // Proceed with navigation (will check for existing cart) _navigateToNextScreen(); } @override void dispose() { _bounceController.dispose(); _statusTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // Centered static status text Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( "site survey", style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w300, letterSpacing: 1, ), ), const SizedBox(height: 6), Text( _statusPhrases[_statusIndex], style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w300, letterSpacing: 1, ), ), ], ), ), // Bouncing logo Positioned( left: _x, top: _y, child: Text( "PAYFRIT", style: TextStyle( color: _logoColor, fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: 3, ), ), ), // Skip button at bottom - show until we start navigating away if (!_navigating) Positioned( bottom: 50, left: 0, right: 0, child: Center( child: TextButton( onPressed: _skipScan, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), ), child: const Text( "Skip Scan", style: TextStyle( color: Colors.white70, fontSize: 16, fontWeight: FontWeight.w500, letterSpacing: 0.5, ), ), ), ), ), ], ), ); } } class BeaconResult { final String uuid; final int beaconId; final double avgRssi; const BeaconResult({ required this.uuid, required this.beaconId, required this.avgRssi, }); }