- Add AltBeacon library for native Android beacon scanning - Create BeaconScanner.kt with 2-second scan duration - Set up MethodChannel bridge in MainActivity.kt - Add beacon_channel.dart for Dart/native communication - Update splash_screen.dart to use native scanner on Android - Keep dchs_flutter_beacon for iOS compatibility Native scanner eliminates Flutter plugin warmup overhead. Beacons broadcast at 200ms, so 2s scan captures ~10 samples. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
13 KiB
Dart
435 lines
13 KiB
Dart
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<SplashScreen> createState() => _SplashScreenState();
|
|
}
|
|
|
|
class _SplashScreenState extends State<SplashScreen> 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<String> _statusPhrases = [
|
|
"scanning...",
|
|
"listening...",
|
|
"searching...",
|
|
"locating...",
|
|
"analyzing...",
|
|
"connecting...",
|
|
];
|
|
|
|
// Beacon scanning state
|
|
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 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<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
|
|
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...');
|
|
|
|
// 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<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;
|
|
}
|
|
|
|
// 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<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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|