payfrit-app/lib/screens/splash_screen.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

465 lines
14 KiB
Dart

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 "../services/api.dart";
import "../services/auth_storage.dart";
import "../services/beacon_cache.dart";
import "../services/beacon_permissions.dart";
import "../services/preload_cache.dart";
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
// Track if permissions were freshly granted (needs Bluetooth warmup delay)
bool _permissionsWereFreshlyGranted = false;
// 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<String> _statusPhrases = [
"scanning...",
"listening...",
"searching...",
"locating...",
"analyzing...",
"connecting...",
];
// Beacon scanning state - new approach: scan all, then lookup
final Map<String, List<int>> _beaconRssiSamples = {};
final Map<String, int> _beaconDetectionCount = {};
bool _scanComplete = false;
BeaconLookupResult? _bestBeacon;
static const List<Color> _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');
// 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<void> _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>();
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();
// Navigate based on results
if (!mounted) return;
_navigateToNextScreen();
}
/// Validates the stored token by making a profile API call
Future<bool> _validateToken() async {
try {
final profile = await Api.getProfile();
return profile.userId > 0;
} catch (e) {
print('[Splash] Token validation failed: $e');
return false;
}
}
Future<void> _performBeaconScan() async {
print('[Splash] 📡 Starting beacon scan...');
// Check if permissions are already granted BEFORE requesting
final alreadyHadPermissions = await BeaconPermissions.checkPermissions();
// Request permissions (will be instant if already granted)
final granted = await BeaconPermissions.requestPermissions();
if (!granted) {
print('[Splash] ❌ Permissions denied');
_scanComplete = true;
return;
}
// If permissions were just granted (not already had), Bluetooth needs warmup
_permissionsWereFreshlyGranted = !alreadyHadPermissions;
if (_permissionsWereFreshlyGranted) {
print('[Splash] 🆕 Permissions freshly granted - will add warmup delay');
}
// 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');
// Step 1: Try to load beacon list from cache first, then fetch from server
print('[Splash] 📥 Loading beacon list...');
Map<String, int> 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;
}
// Initialize beacon scanning
try {
await flutterBeacon.initializeScanning;
// Only add delay if permissions were freshly granted (Bluetooth subsystem needs warmup)
if (_permissionsWereFreshlyGranted) {
print('[Splash] 🔄 Fresh permissions - adding Bluetooth warmup delay');
await Future.delayed(const Duration(milliseconds: 1500));
}
// 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();
// Single scan - collect samples for 2 seconds
print('[Splash] 🔍 Scanning...');
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;
_beaconRssiSamples.putIfAbsent(uuid, () => []).add(rssi);
_beaconDetectionCount[uuid] = (_beaconDetectionCount[uuid] ?? 0) + 1;
print('[Splash] 📶 Found $uuid RSSI=$rssi');
}
});
await Future.delayed(const Duration(milliseconds: 2000));
await subscription.cancel();
// Now lookup business info for found beacons
if (_beaconRssiSamples.isNotEmpty) {
print('[Splash] 🔍 Looking up ${_beaconRssiSamples.length} beacons...');
final uuids = _beaconRssiSamples.keys.toList();
try {
final lookupResults = await Api.lookupBeacons(uuids);
print('[Splash] 📋 Server returned ${lookupResults.length} registered beacons');
// Find the best registered beacon based on RSSI
_bestBeacon = _findBestRegisteredBeacon(lookupResults);
} catch (e) {
print('[Splash] Error looking up beacons: $e');
}
}
print('[Splash] 🎯 Best beacon: ${_bestBeacon?.beaconName ?? "none"}');
} catch (e) {
print('[Splash] Scan error: $e');
}
_scanComplete = true;
}
/// Find the best registered beacon from lookup results based on RSSI
BeaconLookupResult? _findBestRegisteredBeacon(List<BeaconLookupResult> registeredBeacons) {
if (registeredBeacons.isEmpty) return null;
BeaconLookupResult? best;
double bestAvgRssi = -999;
for (final beacon in registeredBeacons) {
final samples = _beaconRssiSamples[beacon.uuid];
if (samples == null || samples.isEmpty) continue;
final detections = _beaconDetectionCount[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 registered beacon if none meet threshold
if (best == null) {
for (final beacon in registeredBeacons) {
final samples = _beaconRssiSamples[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;
}
Future<void> _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>();
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,
),
),
),
],
),
);
}
}