import "dart:async"; import "dart:math"; import "package:flutter/material.dart"; import "package:provider/provider.dart"; import "../app/app_router.dart"; import "../app/app_state.dart"; import "../services/api.dart"; import "../services/auth_storage.dart"; import "../services/beacon_cache.dart"; import "../services/beacon_channel.dart"; import "../services/beacon_permissions.dart"; import "../services/preload_cache.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 bool _scanComplete = false; BeaconLookupResult? _bestBeacon; 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 native beacon scanner'); // 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; // Skip if screen size not yet available if (size.width <= logoWidth || size.height <= logoHeight) return; final maxX = size.width - logoWidth; final maxY = size.height - logoHeight; setState(() { _x += _dx; _y += _dy; // Bounce off edges and change color if (_x <= 0 || _x >= maxX) { _dx = -_dx; _changeColor(); } if (_y <= 0 || _y >= maxY) { _dy = -_dy; _changeColor(); } // Keep in bounds _x = _x.clamp(0.0, maxX); _y = _y.clamp(0.0, maxY); }); } void _changeColor() { final newColor = _colors[_random.nextInt(_colors.length)]; if (newColor != _logoColor) { _logoColor = newColor; } } Future _initializeApp() async { // Run auth check and preloading in parallel for faster startup print('[Splash] Starting parallel initialization...'); // Start preloading data in background (fire and forget for non-critical data) PreloadCache.preloadAll(); // 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 await _performBeaconScan(); // 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 if needed final granted = await BeaconPermissions.requestPermissions(); if (!granted) { print('[Splash] Permissions denied'); _scanComplete = true; return; } // Check if Bluetooth is enabled 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'); // Load known beacons from cache or server (for UUID filtering) print('[Splash] Loading beacon list...'); Map knownBeacons = {}; // Try cache first final cached = await BeaconCache.load(); if (cached != null && cached.isNotEmpty) { print('[Splash] Got ${cached.length} beacon UUIDs from cache'); knownBeacons = cached; // Refresh cache in background (fire and forget) Api.listAllBeacons().then((fresh) { BeaconCache.save(fresh); print('[Splash] Background refresh: saved ${fresh.length} beacons to cache'); }).catchError((e) { print('[Splash] Background refresh failed: $e'); }); } else { // No cache - must fetch from server try { knownBeacons = await Api.listAllBeacons(); print('[Splash] Got ${knownBeacons.length} beacon UUIDs from server'); // Save to cache await BeaconCache.save(knownBeacons); } catch (e) { print('[Splash] Failed to fetch beacons: $e'); _scanComplete = true; return; } } if (knownBeacons.isEmpty) { print('[Splash] No beacons configured'); _scanComplete = true; return; } // Use native beacon scanner try { print('[Splash] Starting native beacon scan...'); // Scan using native channel final detectedBeacons = await BeaconChannel.startScan( regions: knownBeacons.keys.toList(), ); if (detectedBeacons.isEmpty) { print('[Splash] No beacons detected'); _scanComplete = true; return; } print('[Splash] Detected ${detectedBeacons.length} beacons'); // Filter to only known beacons with good RSSI final validBeacons = detectedBeacons .where((b) => knownBeacons.containsKey(b.uuid) && b.rssi >= -85) .toList(); if (validBeacons.isEmpty) { print('[Splash] No valid beacons (known + RSSI >= -85)'); // Fall back to strongest detected beacon that's known final knownDetected = detectedBeacons .where((b) => knownBeacons.containsKey(b.uuid)) .toList(); if (knownDetected.isNotEmpty) { validBeacons.add(knownDetected.first); print('[Splash] Using fallback: ${knownDetected.first.uuid} RSSI=${knownDetected.first.rssi}'); } } if (validBeacons.isEmpty) { print('[Splash] No known beacons found'); _scanComplete = true; return; } // Look up business info for detected beacons final uuids = validBeacons.map((b) => b.uuid).toList(); print('[Splash] Looking up ${uuids.length} beacons...'); try { final lookupResults = await Api.lookupBeacons(uuids); print('[Splash] Server returned ${lookupResults.length} registered beacons'); // Find the best beacon (strongest RSSI that's registered) if (lookupResults.isNotEmpty) { // Build a map of UUID -> RSSI from detected beacons final rssiMap = {for (var b in validBeacons) b.uuid: b.rssi}; // Find the best registered beacon based on RSSI BeaconLookupResult? best; int bestRssi = -999; for (final result in lookupResults) { final rssi = rssiMap[result.uuid] ?? -100; if (rssi > bestRssi) { bestRssi = rssi; best = result; } } _bestBeacon = best; print('[Splash] Best beacon: ${_bestBeacon?.beaconName ?? "none"} (RSSI=$bestRssi)'); } } catch (e) { print('[Splash] Error looking up beacons: $e'); } } catch (e) { print('[Splash] Scan error: $e'); } _scanComplete = true; } Future _navigateToNextScreen() async { if (!mounted) return; if (_bestBeacon != null) { final beacon = _bestBeacon!; print('[Splash] Best beacon: ${beacon.businessName}, hasChildren=${beacon.hasChildren}, parent=${beacon.parentBusinessName}'); // Check if this business has child businesses (food court scenario) if (beacon.hasChildren) { print('[Splash] Business has children - showing selector'); // Need to fetch children and show selector try { final children = await Api.getChildBusinesses(businessId: beacon.businessId); if (!mounted) return; if (children.isNotEmpty) { Navigator.of(context).pushReplacementNamed( AppRoutes.businessSelector, arguments: { "parentBusinessId": beacon.businessId, "parentBusinessName": beacon.businessName, "servicePointId": beacon.servicePointId, "servicePointName": beacon.servicePointName, "children": children, }, ); return; } } catch (e) { print('[Splash] Error fetching children: $e'); } } // Single business - go directly to menu final appState = context.read(); appState.setBusinessAndServicePoint( beacon.businessId, beacon.servicePointId, businessName: beacon.businessName, servicePointName: beacon.servicePointName, parentBusinessId: beacon.hasParent ? beacon.parentBusinessId : null, parentBusinessName: beacon.hasParent ? beacon.parentBusinessName : null, ); // Beacon detected = dine-in at a table appState.setOrderType(OrderType.dineIn); Api.setBusinessId(beacon.businessId); print('[Splash] Auto-selected: ${beacon.businessName}'); Navigator.of(context).pushReplacementNamed( AppRoutes.menuBrowse, arguments: { 'businessId': beacon.businessId, 'servicePointId': beacon.servicePointId, }, ); return; } // No beacon or error - go to restaurant select print('[Splash] Going to restaurant select'); Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect); } @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, ), ), ), ], ), ); } }