gps manipulations tailored to sim7600h hat
This commit is contained in:
@@ -1,119 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
|
||||
|
||||
class GpsCompass extends StatelessWidget {
|
||||
final double? heading;
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
|
||||
const GpsCompass({super.key, this.heading});
|
||||
const GpsCompass({super.key, this.heading, this.gpsState});
|
||||
|
||||
bool get _hasSignal => heading != null;
|
||||
bool get _isAcquiring => gpsState == 'acquiring';
|
||||
|
||||
String get _displayHeading {
|
||||
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal
|
||||
return '${(heading! % 360).round()}'; // No need for the degree symbol
|
||||
if (!_hasSignal) return 'N/A';
|
||||
return '${(heading! % 360).round()}';
|
||||
}
|
||||
|
||||
String get _compassDirection {
|
||||
@@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _emotion = 'default';
|
||||
late AnimationController _shakeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
// Auto-reset to default after surprise animation completes
|
||||
_shakeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||
setState(() => _emotion = 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
if (emotion == 'surprise') {
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
final image = Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
// Shake animation for surprise
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeController,
|
||||
child: image,
|
||||
builder: (context, child) {
|
||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||
return Transform.translate(
|
||||
offset: Offset(shake, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _emotion = 'default';
|
||||
late AnimationController _shakeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
// Auto-reset to default after surprise animation completes
|
||||
_shakeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||
setState(() => _emotion = 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
if (emotion == 'surprise') {
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
final image = Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
// Shake animation for surprise
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeController,
|
||||
child: image,
|
||||
builder: (context, child) {
|
||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||
return Transform.translate(
|
||||
offset: Offset(shake, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +1,174 @@
|
||||
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, 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,
|
||||
this.gpsSatellites,
|
||||
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,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 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',
|
||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
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,
|
||||
this.gpsSatellites,
|
||||
this.gpsState,
|
||||
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,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 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: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix',
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user