synheart_focus 0.1.0
synheart_focus: ^0.1.0 copied to clipboard
On-device cognitive concentration inference from biosignals and behavioral patterns for Flutter.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:synheart_focus/synheart_focus.dart';
import 'package:synheart_wear/synheart_wear.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Synheart Focus Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const FocusTestPage(),
);
}
}
class FocusTestPage extends StatefulWidget {
const FocusTestPage({super.key});
@override
State<FocusTestPage> createState() => _FocusTestPageState();
}
class _FocusTestPageState extends State<FocusTestPage> {
FocusEngine? _engine;
bool _isInitialized = false;
bool _isRunning = false;
String _status = 'Not initialized';
// SynheartWear SDK instance
SynheartWear? _synheartWear;
StreamSubscription<WearMetrics>? _hrStreamSubscription;
bool _wearableConnected = false;
String _deviceInfo = '';
// Current inference result
FocusResult? _currentResult;
// Timer for continuous HR data generation
DateTime? _startTime;
int _dataPointCount = 0;
// Timer for window tracking
int _windowElapsedSeconds = 0;
int _totalElapsedSeconds = 0; // Total elapsed time since start
@override
void initState() {
super.initState();
_initializeEngine();
_initializeWearable();
}
// ──────────────────────────────────────────────────────────────
// SDK Initialization
// ──────────────────────────────────────────────────────────────
void _initializeSDK() {
// Initialize SDK with platform-appropriate adapter
final adapters = <DeviceAdapter>{};
if (Platform.isIOS) {
adapters.add(DeviceAdapter.appleHealthKit);
debugPrint('📱 Initialized Health SDK for iOS (HealthKit)');
} else if (Platform.isAndroid) {
// For Android, we use AppleHealthKit adapter which uses health package
// The health package automatically handles Android Health Connect
adapters.add(DeviceAdapter.appleHealthKit);
debugPrint(
'📱 Initialized Health SDK for Android (Health Connect via health package)',
);
} else {
adapters.add(DeviceAdapter.appleHealthKit);
debugPrint('📱 Initialized Health SDK (default)');
}
_synheartWear = SynheartWear(
config: SynheartWearConfig.withAdapters(adapters),
);
}
// ──────────────────────────────────────────────────────────────
// Wearable Connection
// ──────────────────────────────────────────────────────────────
Future<void> _initializeWearable() async {
_updateStatus('Checking permissions...', error: '');
try {
// Initialize SDK instance
_initializeSDK();
// Initialize SDK first (only when user clicks toggle)
// This is the first time we initialize, so it won't trigger permission dialogs
await _synheartWear!.initialize();
// First, check if permissions are already granted
final existingConsents = ConsentManager.getAllConsents();
final hasExistingPermissions = existingConsents.values.any(
(status) => status == ConsentStatus.granted,
);
bool granted = false;
if (hasExistingPermissions) {
// Permissions already exist, just verify they're still valid
debugPrint('✅ Existing permissions found, verifying...');
granted = true;
} else {
// No existing permissions, request them
_updateStatus('Requesting permissions...', error: '');
// Request permissions for health data
// Note: Health Connect on Android doesn't support HRV_SDNN, so exclude it on Android
Set<PermissionType> permissions;
if (Platform.isAndroid) {
// Health Connect doesn't support DISTANCE_WALKING_RUNNING
// Request only supported data types
permissions = {
PermissionType.heartRate,
PermissionType.heartRateVariability, // RMSSD on Android
PermissionType.steps,
PermissionType.calories,
// Distance is not supported by Health Connect
};
debugPrint('📱 Requesting Android Health Connect permissions...');
} else {
// iOS HealthKit supports all types
permissions = {
PermissionType.heartRate,
PermissionType.heartRateVariability,
PermissionType.steps,
PermissionType.calories,
PermissionType.distance,
};
debugPrint('📱 Requesting iOS HealthKit permissions...');
}
final result = await _synheartWear!.requestPermissions(
permissions: permissions,
reason:
'This app needs access to your health data from Apple Health or Health Connect to provide insights.',
);
// Check if any permissions were granted
granted = result.values.any(
(status) => status == ConsentStatus.granted,
);
if (!granted) {
if (Platform.isAndroid) {
throw Exception(
'Permissions were not granted. '
'Please ensure Health Connect is installed and grant permissions when prompted.',
);
} else {
throw Exception('Permissions were not granted');
}
}
}
// Try to read metrics to verify connection
try {
final metrics = await _synheartWear!.readMetrics();
if (metrics.hasValidData) {
_updateStatus(
'Connected successfully!',
error: '',
isConnected: true,
deviceInfo: 'Device: ${metrics.deviceId}',
);
debugPrint('✓ Wearable device connected: ${metrics.deviceId}');
} else {
_updateStatus(
'Connected! (No data available yet)',
error: '',
isConnected: true,
deviceInfo: 'Connected! (No data available yet)',
);
}
} catch (e) {
// If reading fails but permissions are granted, still consider connected
// This handles cases where permissions are granted but no data exists
if (granted) {
_updateStatus(
'Connected! (Permissions granted - no data available yet)',
error: '',
isConnected: true,
deviceInfo:
'Connected! (Permissions granted - no data available yet)',
);
} else {
rethrow;
}
}
} catch (e) {
final errorMessage = _extractErrorMessage(e.toString());
debugPrint('❌ Failed to connect: $e');
_updateStatus(
'Connection failed',
error: errorMessage,
isConnected: false,
deviceInfo: errorMessage,
);
}
}
// ──────────────────────────────────────────────────────────────
// State Management
// ──────────────────────────────────────────────────────────────
void _updateStatus(
String status, {
String? error,
bool? isConnected,
String? deviceInfo,
}) {
setState(() {
_status = status;
if (isConnected != null) {
_wearableConnected = isConnected;
}
// Use deviceInfo if provided, otherwise use error
if (deviceInfo != null) {
_deviceInfo = deviceInfo;
} else if (error != null && error.isNotEmpty) {
_deviceInfo = error;
}
});
}
String _extractErrorMessage(String error) {
final errorLower = error.toLowerCase();
if (errorLower.contains('permission denied') ||
errorLower.contains('permission') ||
errorLower.contains('denied') ||
errorLower.contains('not granted')) {
String message =
'Permissions were denied. Please grant permissions in Apple Health or Health Connect settings.\n\n';
if (Platform.isAndroid) {
message +=
'On emulators, you may need to install Health Connect from the Play Store.';
} else {
message +=
'Note: On simulators, you may need to enable HealthKit in Xcode.';
}
return message;
}
if (errorLower.contains('not available') ||
errorLower.contains('unavailable') ||
errorLower.contains('device unavailable')) {
return 'Health data is not available on this device.\n\n'
'iOS: Requires Apple HealthKit enabled device (simulators may have limited support).\n'
'Android: Requires Health Connect app installed (may not work on all emulators).\n\n'
'For best results, use a physical device.';
}
if (errorLower.contains('invalid metrics') ||
errorLower.contains('no valid data')) {
String message = 'No health data available yet.\n\n';
if (!Platform.isAndroid) {
message +=
'This is normal on simulators which may not have actual health data.\n';
}
message +=
'Try on a physical device with real health data, or wait for data to be recorded.';
return message;
}
if (errorLower.contains('not initialized') ||
errorLower.contains('initialize')) {
return 'SDK not initialized. Please try connecting again.';
}
return error;
}
Future<void> _initializeEngine() async {
try {
debugPrint('═══════════════════════════════════════════════════════');
debugPrint('Initializing Focus Engine with Gradient Boosting model...');
debugPrint('═══════════════════════════════════════════════════════\n');
setState(() {
_status = 'Initializing...';
});
_engine = FocusEngine(
config: const FocusConfig(
windowSeconds: 60, // 60-second window
stepSeconds: 5, // 5-second step
minRrCount: 30,
enableDebugLogging: true,
),
onLog: (level, message, {context}) {
debugPrint('[$level] $message');
},
);
debugPrint('Loading model: assets/models/Gradient_Boosting.onnx...');
await _engine!.initialize(
modelPath: 'assets/models/Gradient_Boosting.onnx',
backend: 'onnx',
);
debugPrint('✓ Model loaded successfully!');
debugPrint('Window: 60 seconds, Step: 5 seconds\n');
setState(() {
_isInitialized = true;
_status = 'Ready';
});
} catch (e, stackTrace) {
debugPrint('✗ Error initializing engine: $e');
debugPrint('Stack trace: $stackTrace');
setState(() {
_status = 'Error: $e';
});
}
}
/// Generate Gaussian random number using Box-Muller transform
double _nextGaussian(Random rng) {
double u1 = rng.nextDouble();
double u2 = rng.nextDouble();
return sqrt(-2 * log(u1)) * cos(2 * pi * u2);
}
/// Generate realistic HR data with smooth transitions
double _generateRealisticHR(DateTime currentTime, DateTime startTime) {
final elapsed = currentTime.difference(startTime).inSeconds;
// Simulate different cognitive states over time
double baseHR;
double variability;
if (elapsed < 120) {
// First 2 minutes: Focused state
baseHR = 70.0;
variability = 4.0;
} else if (elapsed < 240) {
// Next 2 minutes: Anxious state
baseHR = 88.0;
variability = 7.0;
} else if (elapsed < 360) {
// Next 2 minutes: Bored state
baseHR = 62.0;
variability = 3.0;
} else {
// After 6 minutes: Overload state
baseHR = 95.0;
variability = 9.0;
}
// Add some natural variation with smooth transitions
final random = Random(elapsed);
final variation = _nextGaussian(random) * variability;
// Add a subtle sine wave for natural HR variation
final sineVariation = 2.0 * sin(elapsed * 0.1);
final hr = baseHR + variation + sineVariation;
// Clamp to physiological range
return hr.clamp(45.0, 120.0);
}
Future<void> _startRealTimeSimulation() async {
if (!_isInitialized || _engine == null || _isRunning) return;
if (_synheartWear == null) {
debugPrint('⚠️ Wearable service not initialized');
setState(() {
_status = 'Wearable service not available';
});
return;
}
setState(() {
_isRunning = true;
_startTime = DateTime.now();
_dataPointCount = 0;
_currentResult = null;
_windowElapsedSeconds = 0;
_totalElapsedSeconds = 0;
});
debugPrint('\n═══════════════════════════════════════════════════════');
debugPrint('Starting Real-Time HR Data Streaming from Wearable');
debugPrint('Window: 60 seconds | Step: 5 seconds');
debugPrint('═══════════════════════════════════════════════════════\n');
// Timer to update elapsed time every second
Timer.periodic(const Duration(seconds: 1), (timer) {
if (!_isRunning) {
timer.cancel();
return;
}
if (_startTime != null) {
final elapsed = DateTime.now().difference(_startTime!).inSeconds;
setState(() {
_totalElapsedSeconds = elapsed;
// Show seconds within current 60-second window (for display)
_windowElapsedSeconds = elapsed < 60 ? elapsed : elapsed % 60;
});
}
});
// Stream HR data from wearable device (every 1 second)
_hrStreamSubscription =
_synheartWear!.streamHR(interval: const Duration(seconds: 1)).listen(
(metrics) {
if (!_isRunning) return;
final currentTime = DateTime.now();
final hrMetric = metrics.getMetric(MetricType.hr);
final isSimulated = hrMetric == null;
final hrValue = hrMetric?.toDouble() ??
_generateRealisticHR(currentTime, _startTime ?? currentTime);
_dataPointCount++;
// Update device info
setState(() {
_wearableConnected = true;
_deviceInfo =
'Device: ${metrics.deviceId} | HR: ${hrValue.toStringAsFixed(0)} bpm${isSimulated ? " (simulated)" : ""} | Points: $_dataPointCount | Window: ${_windowElapsedSeconds}s';
});
// Feed HR data to inference engine
_engine!
.inferFromHrData(hrBpm: hrValue, timestamp: currentTime)
.then((result) {
if (result != null) {
// Update UI with new result
setState(() {
_currentResult = result;
});
// Log to console
final elapsed = currentTime.difference(_startTime!).inSeconds;
debugPrint('[$elapsed s] ✓ Inference completed!');
debugPrint(' HR: ${hrValue.toStringAsFixed(0)} BPM');
debugPrint(' State: ${result.focusState}');
debugPrint(' Score: ${result.focusScore.toStringAsFixed(1)}');
debugPrint(
' Confidence: ${(result.confidence * 100).toStringAsFixed(1)}%',
);
debugPrint(' Probabilities: ${result.probabilities}');
} else {
// Log data collection status periodically
final elapsed = currentTime.difference(_startTime!).inSeconds;
if (elapsed <= 60 && elapsed % 15 == 0) {
// Log every 15 seconds during first 60 seconds
debugPrint(
'[$elapsed s] Collecting data... ($elapsed/60 seconds) | HR: ${hrValue.toStringAsFixed(0)} BPM',
);
} else if (elapsed > 60 && elapsed % 5 == 0) {
// Log every 5 seconds after 60 seconds if no inference
debugPrint(
'[$elapsed s] Waiting for inference... | HR: ${hrValue.toStringAsFixed(0)} BPM',
);
}
}
}).catchError((e) {
debugPrint('Error processing HR data: $e');
});
},
onError: (error) {
debugPrint('Error streaming HR: $error');
setState(() {
_wearableConnected = false;
_deviceInfo = 'Stream error: $error';
});
},
);
}
void _stopSimulation() {
debugPrint('\n⚠ Simulation stopped by user\n');
_hrStreamSubscription?.cancel();
setState(() {
_isRunning = false;
});
}
@override
void dispose() {
_stopSimulation();
_hrStreamSubscription?.cancel();
_synheartWear?.dispose();
_engine?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Synheart Focus - Real-Time'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status indicator
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isInitialized
? Colors.green.shade50
: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isInitialized
? Colors.green.shade300
: Colors.orange.shade300,
width: 2,
),
),
child: Row(
children: [
Icon(
_isInitialized ? Icons.check_circle : Icons.warning,
color: _isInitialized ? Colors.green : Colors.orange,
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
Text(
_status,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color:
_isInitialized ? Colors.green : Colors.orange,
),
),
const SizedBox(height: 8),
Text(
_deviceInfo,
style: TextStyle(
fontSize: 12,
color: _wearableConnected
? Colors.green.shade700
: Colors.grey.shade600,
),
),
],
),
),
if (_isRunning)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
const SizedBox(height: 24),
// Window Timer Display
if (_isRunning)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _totalElapsedSeconds >= 60
? Colors.green.shade100
: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _totalElapsedSeconds >= 60
? Colors.green.shade300
: Colors.orange.shade300,
width: 2,
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_totalElapsedSeconds >= 60
? Icons.check_circle
: Icons.timer,
color: _totalElapsedSeconds >= 60
? Colors.green
: Colors.orange,
size: 32,
),
const SizedBox(width: 12),
Column(
children: [
Text(
_totalElapsedSeconds < 60
? 'Collecting Data'
: 'Window Active',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
Text(
_totalElapsedSeconds < 60
? '${_totalElapsedSeconds}s / 60s'
: '${_totalElapsedSeconds}s elapsed',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: _totalElapsedSeconds >= 60
? Colors.green.shade700
: Colors.orange.shade700,
),
),
],
),
],
),
if (_totalElapsedSeconds < 60) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: _totalElapsedSeconds / 60.0,
backgroundColor: Colors.grey.shade300,
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.orange,
),
minHeight: 6,
),
const SizedBox(height: 4),
Text(
'Waiting for 60-second window...',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
] else ...[
const SizedBox(height: 8),
Text(
'Inference: every 5s using last 60s of data',
style: TextStyle(
fontSize: 11,
color: Colors.green.shade700,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
if (_isRunning) const SizedBox(height: 24),
// Control buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isInitialized && !_isRunning
? _startRealTimeSimulation
: null,
icon: const Icon(Icons.play_arrow),
label: const Text('Start HR Streaming'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _isRunning ? _stopSimulation : null,
icon: const Icon(Icons.stop),
label: const Text('Stop'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
const SizedBox(height: 24),
// Current Result Display
if (_currentResult != null) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.psychology, color: Colors.blue.shade700),
const SizedBox(width: 8),
Text(
'Current Cognitive State',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
],
),
const SizedBox(height: 16),
// Dominant label
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.blue.shade200,
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Text(
_currentResult!.focusState,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
const SizedBox(height: 16),
// Focus Score - Prominent Display
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue.shade300,
width: 2,
),
),
child: Column(
children: [
Text(
'Focus Score',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 4),
Text(
_currentResult!.focusScore.toStringAsFixed(1),
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.blue.shade700,
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: _currentResult!.focusScore / 100.0,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
_currentResult!.focusScore >= 70
? Colors.green
: _currentResult!.focusScore >= 40
? Colors.orange
: Colors.red,
),
minHeight: 8,
),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Confidence: ',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade700,
),
),
Text(
'${(_currentResult!.confidence * 100).toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// All probabilities
Text(
'All Class Probabilities:',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
const SizedBox(height: 12),
..._currentResult!.probabilities.entries.map((entry) {
final isDominant =
entry.key == _currentResult!.focusState;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDominant
? Colors.blue.shade100
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDominant
? Colors.blue.shade300
: Colors.grey.shade300,
width: isDominant ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Text(
entry.key,
style: TextStyle(
fontSize: 16,
fontWeight: isDominant
? FontWeight.bold
: FontWeight.normal,
color: isDominant
? Colors.blue.shade900
: Colors.grey.shade800,
),
),
),
const SizedBox(width: 12),
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: entry.value,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
isDominant
? Colors.blue
: Colors.grey.shade600,
),
),
),
const SizedBox(width: 12),
SizedBox(
width: 50,
child: Text(
'${(entry.value * 100).toStringAsFixed(1)}%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: isDominant
? Colors.blue.shade700
: Colors.grey.shade700,
),
),
),
],
),
);
}),
const SizedBox(height: 24),
// Features Display
Text(
'HRV Features (24 features):',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
const SizedBox(height: 12),
Container(
constraints: const BoxConstraints(maxHeight: 300),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _currentResult!.features.entries.map((
entry,
) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 4,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
entry.key,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade800,
),
),
),
Expanded(
flex: 1,
child: Text(
entry.value.toStringAsFixed(4),
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.grey.shade700,
),
),
),
],
),
);
}).toList(),
),
),
),
),
],
),
),
] else ...[
Container(
padding: const EdgeInsets.all(40),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(
Icons.psychology_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No inference results yet',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Start the simulation to see real-time results',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
],
),
),
],
const SizedBox(height: 24),
// Info box
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.shade200),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.amber.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
'Results update every 5 seconds as the 60-second window slides. Check console for detailed logs.',
style: TextStyle(
fontSize: 12,
color: Colors.amber.shade900,
),
),
),
],
),
),
],
),
),
);
}
}