2026-02-01 17:01:45 +09:00
|
|
|
import 'dart:convert';
|
2026-01-25 18:47:35 +09:00
|
|
|
import 'package:flutter/material.dart';
|
2026-02-01 17:01:45 +09:00
|
|
|
import 'package:http/http.dart' as http;
|
2026-01-25 18:47:35 +09:00
|
|
|
|
|
|
|
|
import 'screens/splash_screen.dart';
|
|
|
|
|
import 'screens/dashboard_screen.dart';
|
2026-01-25 19:53:43 +09:00
|
|
|
import 'screens/overheat_screen.dart';
|
|
|
|
|
import 'services/config_service.dart';
|
|
|
|
|
import 'services/overheat_monitor.dart';
|
2026-01-25 18:47:35 +09:00
|
|
|
|
|
|
|
|
/// Root widget that manages app state transitions
|
|
|
|
|
class AppRoot extends StatefulWidget {
|
|
|
|
|
const AppRoot({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<AppRoot> createState() => _AppRootState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AppRootState extends State<AppRoot> {
|
|
|
|
|
bool _initialized = false;
|
2026-01-25 19:53:43 +09:00
|
|
|
bool _overheatTriggered = false;
|
2026-02-08 02:56:32 +09:00
|
|
|
final Map<String, String> _initStatuses = {};
|
2026-01-25 18:47:35 +09:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_runInitSequence();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 19:53:43 +09:00
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
OverheatMonitor.instance.stop();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:56:32 +09:00
|
|
|
void _updateStatus(String key, String value) {
|
|
|
|
|
setState(() => _initStatuses[key] = value);
|
|
|
|
|
}
|
2026-01-25 19:53:43 +09:00
|
|
|
|
2026-02-08 02:56:32 +09:00
|
|
|
Future<void> _runInitSequence() async {
|
|
|
|
|
// Show all items from the start so the row doesn't jump around
|
|
|
|
|
_updateStatus('Config', '...');
|
|
|
|
|
_updateStatus('UART', '...');
|
2026-02-09 02:11:55 +09:00
|
|
|
_updateStatus('GPS', '...');
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('Navigator', '...');
|
2026-01-25 18:47:35 +09:00
|
|
|
|
2026-02-08 02:56:32 +09:00
|
|
|
// Config must load first (everything else depends on it)
|
|
|
|
|
_updateStatus('Config', 'Loading');
|
|
|
|
|
await ConfigService.instance.load();
|
|
|
|
|
_updateStatus('Config', 'Ready');
|
2026-01-25 18:47:35 +09:00
|
|
|
|
2026-02-09 02:11:55 +09:00
|
|
|
// UART, GPS, and navigator image preload run truly in parallel
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('UART', 'Connecting');
|
2026-02-09 02:11:55 +09:00
|
|
|
_updateStatus('GPS', 'Waiting');
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('Navigator', 'Loading');
|
|
|
|
|
await Future.wait([
|
|
|
|
|
_waitForUart(),
|
2026-02-09 02:11:55 +09:00
|
|
|
_waitForGps(),
|
2026-02-08 02:56:32 +09:00
|
|
|
_preloadNavigatorImages(),
|
|
|
|
|
]);
|
2026-01-25 18:47:35 +09:00
|
|
|
|
2026-02-08 02:56:32 +09:00
|
|
|
// Let the user see the all-ready state for a moment
|
|
|
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
2026-01-25 18:47:35 +09:00
|
|
|
|
2026-01-25 19:53:43 +09:00
|
|
|
// Start overheat monitoring
|
|
|
|
|
OverheatMonitor.instance.start(
|
|
|
|
|
onOverheat: () {
|
|
|
|
|
setState(() => _overheatTriggered = true);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-25 18:47:35 +09:00
|
|
|
setState(() => _initialized = true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 17:01:45 +09:00
|
|
|
/// Poll backend health endpoint until Arduino is connected
|
|
|
|
|
Future<void> _waitForUart() async {
|
|
|
|
|
final backendUrl = ConfigService.instance.backendUrl;
|
|
|
|
|
const maxAttempts = 30; // ~30 seconds max wait
|
|
|
|
|
const retryDelay = Duration(seconds: 1);
|
|
|
|
|
|
|
|
|
|
for (int attempt = 0; attempt < maxAttempts; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
final response = await http
|
|
|
|
|
.get(Uri.parse('$backendUrl/health'))
|
|
|
|
|
.timeout(const Duration(seconds: 2));
|
|
|
|
|
|
|
|
|
|
if (response.statusCode == 200) {
|
|
|
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
2026-02-08 02:56:32 +09:00
|
|
|
if (data['arduino_connected'] == true) {
|
|
|
|
|
_updateStatus('UART', 'Ready');
|
2026-02-01 17:01:45 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Backend not reachable yet - keep trying
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('UART', 'Waiting');
|
2026-02-01 17:01:45 +09:00
|
|
|
await Future.delayed(retryDelay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Timeout - proceed anyway (UI will show stale data indicators)
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('UART', 'Timeout');
|
2026-02-01 17:01:45 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-09 02:11:55 +09:00
|
|
|
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
|
|
|
|
|
Future<void> _waitForGps() async {
|
|
|
|
|
final backendUrl = ConfigService.instance.backendUrl;
|
|
|
|
|
const bailOut = Duration(milliseconds: 7500);
|
|
|
|
|
const retryDelay = Duration(seconds: 1);
|
|
|
|
|
final deadline = DateTime.now().add(bailOut);
|
|
|
|
|
|
|
|
|
|
_updateStatus('GPS', 'Acquiring');
|
|
|
|
|
|
|
|
|
|
while (DateTime.now().isBefore(deadline)) {
|
|
|
|
|
try {
|
|
|
|
|
final response = await http
|
|
|
|
|
.get(Uri.parse('$backendUrl/health'))
|
|
|
|
|
.timeout(const Duration(seconds: 2));
|
|
|
|
|
|
|
|
|
|
if (response.statusCode == 200) {
|
|
|
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
|
|
|
if (data['gps_state'] == 'fix') {
|
|
|
|
|
_updateStatus('GPS', 'Ready');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Backend not reachable yet - keep trying
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Future.delayed(retryDelay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bail out - dashboard will show live GPS state when it arrives
|
|
|
|
|
_updateStatus('GPS', 'Timeout');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 00:00:38 +09:00
|
|
|
/// Preload navigator images into Flutter's image cache
|
|
|
|
|
///
|
|
|
|
|
/// Scans for all PNGs in the navigator folder and precaches them.
|
|
|
|
|
Future<void> _preloadNavigatorImages() async {
|
|
|
|
|
final images = await ConfigService.instance.getNavigatorImages();
|
|
|
|
|
for (final file in images) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
await precacheImage(FileImage(file), context);
|
|
|
|
|
}
|
2026-02-08 02:56:32 +09:00
|
|
|
_updateStatus('Navigator', 'Ready');
|
2026-02-05 00:00:38 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-25 18:47:35 +09:00
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-01-25 19:53:43 +09:00
|
|
|
// Determine which screen to show (priority: overheat > splash > dashboard)
|
|
|
|
|
Widget child;
|
|
|
|
|
if (_overheatTriggered) {
|
|
|
|
|
child = const OverheatScreen(key: ValueKey('overheat'));
|
|
|
|
|
} else if (!_initialized) {
|
2026-02-08 02:56:32 +09:00
|
|
|
child = SplashScreen(key: const ValueKey('splash'), statuses: _initStatuses);
|
2026-01-25 19:53:43 +09:00
|
|
|
} else {
|
|
|
|
|
child = const DashboardScreen(key: ValueKey('dashboard'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 18:47:35 +09:00
|
|
|
return AnimatedSwitcher(
|
|
|
|
|
duration: const Duration(milliseconds: 500),
|
2026-01-25 19:53:43 +09:00
|
|
|
child: child,
|
2026-01-25 18:47:35 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|