- Update beacon_scan_screen and beacon_scanner_service to use native Android scanner with Flutter plugin fallback for iOS - Add native permission checking for fast path startup - Simplify splash screen: remove bouncing logo animation, show only spinner on black background for clean transition - Native splash is now black-only to match Flutter splash Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
242 lines
7 KiB
Dart
242 lines
7 KiB
Dart
import "dart:async";
|
|
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";
|
|
|
|
class SplashScreen extends StatefulWidget {
|
|
const SplashScreen({super.key});
|
|
|
|
@override
|
|
State<SplashScreen> createState() => _SplashScreenState();
|
|
}
|
|
|
|
class _SplashScreenState extends State<SplashScreen> {
|
|
// Beacon scanning state
|
|
BeaconLookupResult? _bestBeacon;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
debugPrint('[Splash] Starting...');
|
|
_initializeApp();
|
|
}
|
|
|
|
Future<void> _initializeApp() async {
|
|
debugPrint('[Splash] Initializing...');
|
|
|
|
// Run auth check and beacon prep in parallel
|
|
final authFuture = _checkAuth();
|
|
final beaconPrepFuture = _prepareBeaconScan();
|
|
|
|
// Wait for both to complete
|
|
await Future.wait([authFuture, beaconPrepFuture]);
|
|
|
|
// Now do the beacon scan (needs permissions from prep)
|
|
await _performBeaconScan();
|
|
|
|
// Navigate based on results
|
|
if (!mounted) return;
|
|
_navigateToNextScreen();
|
|
}
|
|
|
|
Future<void> _checkAuth() async {
|
|
final credentials = await AuthStorage.loadAuth();
|
|
if (credentials != null && mounted) {
|
|
debugPrint('[Splash] Found saved credentials');
|
|
Api.setAuthToken(credentials.token);
|
|
|
|
// Validate token in background - don't block startup
|
|
_validateToken().then((isValid) {
|
|
if (isValid && mounted) {
|
|
final appState = context.read<AppState>();
|
|
appState.setUserId(credentials.userId);
|
|
} else {
|
|
AuthStorage.clearAuth();
|
|
Api.clearAuthToken();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _prepareBeaconScan() async {
|
|
// Request permissions (this is the slow part)
|
|
await BeaconPermissions.requestPermissions();
|
|
}
|
|
|
|
Future<bool> _validateToken() async {
|
|
try {
|
|
final profile = await Api.getProfile();
|
|
return profile.userId > 0;
|
|
} catch (e) {
|
|
debugPrint('[Splash] Token validation failed: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _performBeaconScan() async {
|
|
// Check permissions (already requested in parallel)
|
|
final hasPerms = await BeaconPermissions.checkPermissions();
|
|
if (!hasPerms) {
|
|
debugPrint('[Splash] Permissions not granted');
|
|
return;
|
|
}
|
|
|
|
// Check Bluetooth
|
|
final bluetoothOn = await BeaconPermissions.isBluetoothEnabled();
|
|
if (!bluetoothOn) {
|
|
debugPrint('[Splash] Bluetooth is OFF');
|
|
return;
|
|
}
|
|
|
|
// Load known beacons from cache or server
|
|
Map<String, int> knownBeacons = {};
|
|
final cached = await BeaconCache.load();
|
|
if (cached != null && cached.isNotEmpty) {
|
|
knownBeacons = cached;
|
|
// Refresh cache in background
|
|
Api.listAllBeacons().then((fresh) => BeaconCache.save(fresh));
|
|
} else {
|
|
try {
|
|
knownBeacons = await Api.listAllBeacons();
|
|
await BeaconCache.save(knownBeacons);
|
|
} catch (e) {
|
|
debugPrint('[Splash] Failed to fetch beacons: $e');
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (knownBeacons.isEmpty) return;
|
|
|
|
// Scan for beacons
|
|
debugPrint('[Splash] Scanning...');
|
|
final detectedBeacons = await BeaconChannel.startScan(
|
|
regions: knownBeacons.keys.toList(),
|
|
);
|
|
|
|
if (detectedBeacons.isEmpty) {
|
|
debugPrint('[Splash] No beacons detected');
|
|
return;
|
|
}
|
|
|
|
debugPrint('[Splash] Found ${detectedBeacons.length} beacons');
|
|
|
|
// Filter to known beacons
|
|
final validBeacons = detectedBeacons
|
|
.where((b) => knownBeacons.containsKey(b.uuid))
|
|
.toList();
|
|
|
|
if (validBeacons.isEmpty) return;
|
|
|
|
// Look up business info
|
|
final uuids = validBeacons.map((b) => b.uuid).toList();
|
|
try {
|
|
final lookupResults = await Api.lookupBeacons(uuids);
|
|
if (lookupResults.isNotEmpty) {
|
|
final rssiMap = {for (var b in validBeacons) b.uuid: b.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;
|
|
debugPrint('[Splash] Best: ${_bestBeacon?.beaconName} (RSSI=$bestRssi)');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[Splash] Lookup error: $e');
|
|
}
|
|
}
|
|
|
|
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
|
|
Widget build(BuildContext context) {
|
|
return const Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Center(
|
|
child: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white54,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|