initial switch to websocket
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/backend_service.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/pi_io.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/navigator_widget.dart';
|
||||
@@ -21,53 +22,125 @@ class DashboardScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final _random = Random();
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
Timer? _timer;
|
||||
|
||||
// Timer for Pi temp only (safety critical, direct file read)
|
||||
Timer? _piTempTimer;
|
||||
|
||||
// WebSocket stream subscriptions
|
||||
StreamSubscription<ArduinoData>? _arduinoSub;
|
||||
StreamSubscription<GpsData>? _gpsSub;
|
||||
StreamSubscription<WsConnectionState>? _connectionSub;
|
||||
|
||||
// Pi temperature - direct file read (safety critical)
|
||||
double? _piTemp;
|
||||
int _rpm = 0;
|
||||
double _voltage = 12.6;
|
||||
int _engineTemp = 25;
|
||||
|
||||
// From backend - Arduino data
|
||||
int? _rpm;
|
||||
double? _voltage;
|
||||
int? _engineTemp;
|
||||
int? _gear;
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Update values periodically
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
// Connect to WebSocket
|
||||
WebSocketService.instance.connect();
|
||||
|
||||
// Subscribe to Arduino data stream
|
||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||
setState(() {
|
||||
// Pi temp - sync read from cache, async refresh happens in background
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
|
||||
// Placeholder random data - will be replaced with real sensors
|
||||
_rpm = 1000 + _random.nextInt(8000);
|
||||
_voltage = 11.5 + _random.nextDouble() * 2;
|
||||
_engineTemp = 20 + _random.nextInt(60);
|
||||
|
||||
// Placeholder: GPS satellites (null = disconnected, 0 = no fix, 3-12 = typical)
|
||||
_gpsSatellites = _random.nextBool() ? _random.nextInt(12) : null;
|
||||
|
||||
// Placeholder: LTE signal (null = disconnected, 0-4 = signal bars)
|
||||
_lteSignal = _random.nextBool() ? _random.nextInt(5) : null;
|
||||
_voltage = data.voltage;
|
||||
_rpm = data.rpm;
|
||||
_engineTemp = data.engTemp;
|
||||
_gear = data.gear;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to GPS data stream
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
// Derive satellites from mode (placeholder logic)
|
||||
_gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
_wsState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
setState(() {
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with any cached data from WebSocketService
|
||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||
if (cachedArduino != null) {
|
||||
_voltage = cachedArduino.voltage;
|
||||
_rpm = cachedArduino.rpm;
|
||||
_engineTemp = cachedArduino.engTemp;
|
||||
_gear = cachedArduino.gear;
|
||||
}
|
||||
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0);
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
||||
_lteSignal = null;
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||
String _formatGear(int? gear) {
|
||||
if (gear == null) return '—';
|
||||
if (gear == 0) return 'N';
|
||||
return gear.toString();
|
||||
}
|
||||
|
||||
/// Format nullable int for display
|
||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||
|
||||
/// Format nullable double for display with decimal places
|
||||
String _formatDouble(double? value, [int decimals = 1]) {
|
||||
if (value == null) return '—';
|
||||
return value.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
@@ -90,6 +163,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
@@ -99,9 +173,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
flex: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
// Speed - placeholder, will come from GPS
|
||||
// RPM from Arduino
|
||||
StatBoxMain(
|
||||
value: _rpm.toString(),
|
||||
value: _formatInt(_rpm),
|
||||
label: 'RPM',
|
||||
),
|
||||
// Add second StatBoxMain here for 2-up layout:
|
||||
@@ -116,9 +190,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _rpm.toString(), label: 'RPM'),
|
||||
StatBox(value: '$_engineTemp', unit: '°C', label: 'ENG'),
|
||||
const StatBox(value: '—', label: 'GEAR'),
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM'),
|
||||
StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
149
pi/ui/lib/services/backend_service.dart
Normal file
149
pi/ui/lib/services/backend_service.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Data from Arduino (voltage, rpm, engine temp, gear)
|
||||
class ArduinoData {
|
||||
final double? voltage;
|
||||
final int? rpm;
|
||||
final int? engTemp;
|
||||
final int? gear; // 0 = neutral, 1-6 = gear
|
||||
|
||||
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear});
|
||||
|
||||
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||
return ArduinoData(
|
||||
voltage: (json['voltage'] as num?)?.toDouble(),
|
||||
rpm: (json['rpm'] as num?)?.toInt(),
|
||||
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
||||
gear: (json['gear'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data from GPS
|
||||
class GpsData {
|
||||
final double? lat;
|
||||
final double? lon;
|
||||
final double? speed; // m/s
|
||||
final double? alt;
|
||||
final double? track;
|
||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode});
|
||||
|
||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||
return GpsData(
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
speed: (json['speed'] as num?)?.toDouble(),
|
||||
alt: (json['alt'] as num?)?.toDouble(),
|
||||
track: (json['track'] as num?)?.toDouble(),
|
||||
mode: (json['mode'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||
///
|
||||
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||
class BackendService {
|
||||
BackendService._() {
|
||||
// Kick off initial fetches
|
||||
_refreshArduino();
|
||||
_refreshGps();
|
||||
}
|
||||
static final instance = BackendService._();
|
||||
|
||||
static const _baseUrl = 'http://127.0.0.1:5000';
|
||||
static const _timeout = Duration(seconds: 2);
|
||||
|
||||
// Caches
|
||||
ArduinoData? _arduinoCache;
|
||||
GpsData? _gpsCache;
|
||||
bool _connected = false;
|
||||
|
||||
// In-progress flags (prevent duplicate requests)
|
||||
bool _arduinoFetchInProgress = false;
|
||||
bool _gpsFetchInProgress = false;
|
||||
|
||||
/// Whether backend is reachable
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Get Arduino data (sync, returns cached value)
|
||||
ArduinoData? getArduinoData() {
|
||||
if (!_arduinoFetchInProgress) {
|
||||
_refreshArduino();
|
||||
}
|
||||
return _arduinoCache;
|
||||
}
|
||||
|
||||
/// Get GPS data (sync, returns cached value)
|
||||
GpsData? getGpsData() {
|
||||
if (!_gpsFetchInProgress) {
|
||||
_refreshGps();
|
||||
}
|
||||
return _gpsCache;
|
||||
}
|
||||
|
||||
/// Background fetch for Arduino data
|
||||
Future<void> _refreshArduino() async {
|
||||
if (_arduinoFetchInProgress) return;
|
||||
_arduinoFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/arduino'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_arduinoCache = ArduinoData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_arduinoFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Background fetch for GPS data
|
||||
Future<void> _refreshGps() async {
|
||||
if (_gpsFetchInProgress) return;
|
||||
_gpsFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/gps'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_gpsCache = GpsData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_gpsFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force clear all caches
|
||||
void clearCache() {
|
||||
_arduinoCache = null;
|
||||
_gpsCache = null;
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
287
pi/ui/lib/services/websocket_service.dart
Normal file
287
pi/ui/lib/services/websocket_service.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||
|
||||
/// Connection state for WebSocket
|
||||
enum WsConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
}
|
||||
|
||||
/// Acknowledgment from backend for a command
|
||||
class CommandAck {
|
||||
final String id;
|
||||
final String status;
|
||||
final String? error;
|
||||
final String? extra;
|
||||
|
||||
CommandAck({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
bool get isSuccess => status == 'ok' || status == 'sent';
|
||||
}
|
||||
|
||||
/// Alert from backend
|
||||
class BackendAlert {
|
||||
final String type;
|
||||
final String message;
|
||||
|
||||
BackendAlert({required this.type, required this.message});
|
||||
}
|
||||
|
||||
/// Backend status (connection states of GPS/Arduino)
|
||||
class BackendStatus {
|
||||
final bool gpsConnected;
|
||||
final bool arduinoConnected;
|
||||
|
||||
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
|
||||
}
|
||||
|
||||
/// WebSocket service for real-time data from backend.
|
||||
///
|
||||
/// Replaces HTTP polling with push-based updates.
|
||||
/// Maintains dual logical channels:
|
||||
/// - Telemetry: arduino/gps data streams (throttled by backend)
|
||||
/// - Control: button commands and acknowledgments
|
||||
class WebSocketService {
|
||||
WebSocketService._() {
|
||||
_setupStreams();
|
||||
}
|
||||
static final instance = WebSocketService._();
|
||||
|
||||
static const _serverUrl = 'http://127.0.0.1:5000';
|
||||
|
||||
io.Socket? _socket;
|
||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Latest values for sync access (backward compat)
|
||||
ArduinoData? _latestArduino;
|
||||
GpsData? _latestGps;
|
||||
BackendStatus? _latestStatus;
|
||||
|
||||
// Stream controllers
|
||||
late StreamController<ArduinoData> _arduinoController;
|
||||
late StreamController<GpsData> _gpsController;
|
||||
late StreamController<BackendStatus> _statusController;
|
||||
late StreamController<CommandAck> _ackController;
|
||||
late StreamController<BackendAlert> _alertController;
|
||||
late StreamController<WsConnectionState> _connectionController;
|
||||
|
||||
void _setupStreams() {
|
||||
_arduinoController = StreamController<ArduinoData>.broadcast();
|
||||
_gpsController = StreamController<GpsData>.broadcast();
|
||||
_statusController = StreamController<BackendStatus>.broadcast();
|
||||
_ackController = StreamController<CommandAck>.broadcast();
|
||||
_alertController = StreamController<BackendAlert>.broadcast();
|
||||
_connectionController = StreamController<WsConnectionState>.broadcast();
|
||||
}
|
||||
|
||||
// --- Public API: Streams ---
|
||||
|
||||
/// Stream of Arduino telemetry updates
|
||||
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
|
||||
|
||||
/// Stream of GPS updates
|
||||
Stream<GpsData> get gpsStream => _gpsController.stream;
|
||||
|
||||
/// Stream of backend status updates
|
||||
Stream<BackendStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Stream of command acknowledgments
|
||||
Stream<CommandAck> get ackStream => _ackController.stream;
|
||||
|
||||
/// Stream of alerts from backend
|
||||
Stream<BackendAlert> get alertStream => _alertController.stream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
|
||||
|
||||
// --- Public API: Sync getters (backward compat) ---
|
||||
|
||||
/// Current connection state
|
||||
WsConnectionState get connectionState => _connectionState;
|
||||
|
||||
/// Whether connected to backend
|
||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||
|
||||
/// Latest Arduino data (may be null if not yet received)
|
||||
ArduinoData? get latestArduino => _latestArduino;
|
||||
|
||||
/// Latest GPS data (may be null if not yet received)
|
||||
GpsData? get latestGps => _latestGps;
|
||||
|
||||
/// Latest backend status
|
||||
BackendStatus? get latestStatus => _latestStatus;
|
||||
|
||||
// --- Public API: Connection ---
|
||||
|
||||
/// Connect to backend WebSocket
|
||||
void connect() {
|
||||
if (_socket != null) return; // Already connected or connecting
|
||||
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
|
||||
_socket = io.io(_serverUrl, <String, dynamic>{
|
||||
'transports': ['websocket'],
|
||||
'autoConnect': true,
|
||||
'reconnection': false, // We handle reconnection ourselves
|
||||
});
|
||||
|
||||
_socket!.onConnect((_) {
|
||||
print('[WS] Connected to $_serverUrl');
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
_cancelReconnect();
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
print('[WS] Disconnected');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
print('[WS] Connection error: $error');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onError((error) {
|
||||
print('[WS] Error: $error');
|
||||
});
|
||||
|
||||
// --- Telemetry Events ---
|
||||
|
||||
_socket!.on('arduino', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final arduino = ArduinoData.fromJson(data);
|
||||
_latestArduino = arduino;
|
||||
_arduinoController.add(arduino);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('gps', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = gps;
|
||||
_gpsController.add(gps);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('status', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final status = BackendStatus(
|
||||
gpsConnected: data['gps_connected'] ?? false,
|
||||
arduinoConnected: data['arduino_connected'] ?? false,
|
||||
);
|
||||
_latestStatus = status;
|
||||
_statusController.add(status);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Control Events ---
|
||||
|
||||
_socket!.on('ack', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final ack = CommandAck(
|
||||
id: data['id'] ?? 'unknown',
|
||||
status: data['status'] ?? 'unknown',
|
||||
error: data['error'],
|
||||
extra: data['extra'],
|
||||
);
|
||||
_ackController.add(ack);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('alert', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final alert = BackendAlert(
|
||||
type: data['type'] ?? 'unknown',
|
||||
message: data['message'] ?? '',
|
||||
);
|
||||
_alertController.add(alert);
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.connect();
|
||||
}
|
||||
|
||||
/// Disconnect from backend
|
||||
void disconnect() {
|
||||
_cancelReconnect();
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
}
|
||||
|
||||
// --- Public API: Commands ---
|
||||
|
||||
/// Send button event to backend
|
||||
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
|
||||
if (_socket == null || !isConnected) {
|
||||
print('[WS] Cannot send button, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
final data = <String, dynamic>{
|
||||
'id': id,
|
||||
'action': action,
|
||||
...?params,
|
||||
};
|
||||
|
||||
_socket!.emit('button', data);
|
||||
}
|
||||
|
||||
/// Send emergency signal to backend
|
||||
void sendEmergency(String type) {
|
||||
if (_socket == null) {
|
||||
print('[WS] Cannot send emergency, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Emergency should be sent even if not fully connected
|
||||
_socket!.emit('emergency', {'type': type});
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
void _setConnectionState(WsConnectionState state) {
|
||||
if (_connectionState != state) {
|
||||
_connectionState = state;
|
||||
_connectionController.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_cancelReconnect();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 3), () {
|
||||
print('[WS] Attempting reconnect...');
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown)
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_arduinoController.close();
|
||||
_gpsController.close();
|
||||
_statusController.close();
|
||||
_ackController.close();
|
||||
_alertController.close();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/websocket_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Android-style persistent status bar for system indicators.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage at a glance.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
final WsConnectionState? wsState; // WebSocket connection state
|
||||
|
||||
const SystemBar({
|
||||
super.key,
|
||||
@@ -16,11 +18,26 @@ class SystemBar extends StatelessWidget {
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
this.wsState,
|
||||
});
|
||||
|
||||
/// Get WebSocket status text and abnormal flag
|
||||
(String, bool) _wsStatus() {
|
||||
switch (wsState) {
|
||||
case WsConnectionState.connected:
|
||||
return ('OK', false);
|
||||
case WsConnectionState.connecting:
|
||||
return ('...', true);
|
||||
case WsConnectionState.disconnected:
|
||||
case null:
|
||||
return ('OFF', true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final (wsText, wsAbnormal) = _wsStatus();
|
||||
|
||||
return Expanded(
|
||||
flex: 1,
|
||||
@@ -43,7 +60,17 @@ class SystemBar extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left group: GPS, LTE
|
||||
// Left group: WS, GPS, LTE
|
||||
_Indicator(
|
||||
label: 'WS',
|
||||
value: wsText,
|
||||
isAbnormal: wsAbnormal,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsSatellites?.toString() ?? 'N/A',
|
||||
@@ -70,7 +97,7 @@ class SystemBar extends StatelessWidget {
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerRight,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
@@ -80,7 +107,7 @@ class SystemBar extends StatelessWidget {
|
||||
label: 'Chassis',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.9,
|
||||
alignment: Alignment.centerRight,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
|
||||
@@ -67,6 +67,30 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -99,6 +123,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -136,6 +168,22 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
socket_io_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: socket_io_client
|
||||
sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3+1"
|
||||
socket_io_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: socket_io_common
|
||||
sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -184,6 +232,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -200,6 +256,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.7.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -9,6 +9,8 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
socket_io_client: ^2.0.3+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user