710 lines
21 KiB
Dart
710 lines
21 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 "../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<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
|
|
Map<String, int> _uuidToBeaconId = {};
|
|
final Map<String, List<int>> _beaconRssiSamples = {};
|
|
final Map<String, int> _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<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');
|
|
|
|
// 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<void> _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>();
|
|
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<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
|
|
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<RangingResult>? 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 = <String, double>{};
|
|
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<void> _navigateToNextScreen() async {
|
|
if (!mounted || _navigating) return;
|
|
|
|
setState(() {
|
|
_navigating = true;
|
|
});
|
|
|
|
final appState = context.read<AppState>();
|
|
|
|
// 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<void> _continueExistingOrderWithBeacon() async {
|
|
if (!mounted || _existingCart == null || _beaconMapping == null) return;
|
|
|
|
final appState = context.read<AppState>();
|
|
|
|
// 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<AppState>();
|
|
|
|
// 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<AppState>();
|
|
|
|
// 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<void> _startFresh() async {
|
|
if (!mounted) return;
|
|
|
|
final appState = context.read<AppState>();
|
|
|
|
// 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,
|
|
});
|
|
}
|