import 'dart:async'; import 'dart:math' as math; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; import '../features/devices/presentation/bloc/device_status_bloc.dart'; import '../features/devices/presentation/bloc/device_status_state.dart'; import '../features/devices/presentation/bloc/device_status_event.dart'; import '../features/devices/presentation/bloc/devices_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_state.dart'; import '../core/logging/log_time.dart'; const int DATA_TIMEOUT_SECONDS = 15; class DeviceStatusModal extends StatefulWidget { const DeviceStatusModal({super.key}); @override State createState() => _DeviceStatusModalState(); } class _DeviceStatusModalState extends State { bool _isCardView = true; bool _voltageGaugeMode = true; bool _chipTempGaugeMode = true; bool _knifeSpeedGaugeMode = false; final List _leftMeasureHistory = []; final List _rightMeasureHistory = []; final List _leftTargetHistory = []; final List _rightTargetHistory = []; final List _leftCurrentHistory = []; final List _rightCurrentHistory = []; final List _leftTempHistory = []; final List _rightTempHistory = []; final List _knifeHistory = []; final List _chipTempHistory = []; final List _voltageHistory = []; double _timeIndex = 0; Timer? _dataTimeoutTimer; bool _isDataTimeout = false; @override void initState() { super.initState(); debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据'); _startDataTimeoutTimer(); // 🔥 页面初始化时自动调用刷新 WidgetsBinding.instance.addPostFrameCallback((_) { _onRefresh(); }); } /// 🔥 刷新函数 - 与刷新按钮共享逻辑 void _onRefresh() { context.read().add(DeviceStatusReset()); _resetChartData(); } @override void dispose() { debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)'); _dataTimeoutTimer?.cancel(); super.dispose(); } void _startDataTimeoutTimer() { _dataTimeoutTimer?.cancel(); _dataTimeoutTimer = Timer(Duration(seconds: DATA_TIMEOUT_SECONDS), () { if (mounted) { setState(() { _isDataTimeout = true; _resetChartData(); }); } }); } void _resetChartData() { _leftMeasureHistory.clear(); _rightMeasureHistory.clear(); _leftTargetHistory.clear(); _rightTargetHistory.clear(); _leftCurrentHistory.clear(); _rightCurrentHistory.clear(); _leftTempHistory.clear(); _rightTempHistory.clear(); _knifeHistory.clear(); _chipTempHistory.clear(); _voltageHistory.clear(); _timeIndex = 0; } List _getMappedSpots(List data) { final List mapped = []; final List limited = data.length > 6 ? data.sublist(data.length - 6) : List.from(data); for (int i = 0; i < limited.length; i++) { mapped.add(FlSpot(i.toDouble(), limited[i].y)); } while (mapped.length < 6) { mapped.add(FlSpot(mapped.length.toDouble(), 0)); } return mapped; } void _limit(List list, {int max = 30}) { if (list.length > max) list.removeAt(0); } void _appendChartData(DeviceStatusUpdated state) { final status = state.status; _leftTargetHistory.add(FlSpot(_timeIndex, status.leftTargetSpeed)); _rightTargetHistory.add(FlSpot(_timeIndex, status.rightTargetSpeed)); _leftMeasureHistory.add(FlSpot(_timeIndex, status.leftMeasureSpeed)); _rightMeasureHistory.add(FlSpot(_timeIndex, status.rightMeasureSpeed)); _leftCurrentHistory.add(FlSpot(_timeIndex, status.leftCurrent)); _rightCurrentHistory.add(FlSpot(_timeIndex, status.rightCurrent)); _leftTempHistory.add(FlSpot(_timeIndex, status.leftMotorTemp)); _rightTempHistory.add(FlSpot(_timeIndex, status.rightMotorTemp)); _knifeHistory.add( FlSpot(_timeIndex, double.tryParse(status.knifeCuttingSpeed) ?? 0), ); _chipTempHistory.add(FlSpot(_timeIndex, status.chipTemp)); _voltageHistory.add(FlSpot(_timeIndex, status.voltage)); _timeIndex += 1; _limit(_leftTargetHistory); _limit(_rightTargetHistory); _limit(_leftMeasureHistory); _limit(_rightMeasureHistory); _limit(_leftCurrentHistory); _limit(_rightCurrentHistory); _limit(_leftTempHistory); _limit(_rightTempHistory); _limit(_knifeHistory); _limit(_chipTempHistory); _limit(_voltageHistory); } Widget _noDataWidget() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.inbox_outlined, color: Colors.grey, size: 48), SizedBox(height: 16), Text( AppLocalizations.of(context).translate('running_status.no_data'), style: TextStyle(color: Colors.grey, fontSize: 16), ), ], ), ); } Widget _buildCardContentView(DeviceStatusState state) { if (state is DeviceStatusInitial) { return _noDataWidget(); } if (state is DeviceStatusUpdated) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _buildMotorTable(state), const SizedBox(height: 20), _buildOtherParamsTable(state), ], ), ); } else if (state is DeviceStatusError) { return _noDataWidget(); } else { return _noDataWidget(); } } Widget _buildMotorTable(DeviceStatusUpdated state) { final status = state.status; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( AppLocalizations.of( context, ).translate('running_status.motor_params'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ), Table( border: TableBorder.all(color: const Color(0xFFE5E5E5)), defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF5F5F5)), children: [ const SizedBox(), _buildTableCell( AppLocalizations.of( context, ).translate('running_status.left_wheel'), isHeader: true, ), _buildTableCell( AppLocalizations.of( context, ).translate('running_status.right_wheel'), isHeader: true, ), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.target_speed') + "\n(rpm)", ), _buildTableCell('--'), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.measure_speed') + "\n(rpm)", ), _buildTableCell('--'), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.current') + "(A)", ), _buildTableCell('--'), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.motor_temp') + "(°C)", ), _buildTableCell('--'), _buildTableCell('--'), ], ), ], ), ], ); } Widget _buildOtherParamsTable(DeviceStatusUpdated state) { final status = state.status; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( AppLocalizations.of( context, ).translate('running_status.other_params'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), ), Table( border: TableBorder.all(color: const Color(0xFFE5E5E5)), defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF5F5F5)), children: [ _buildTableCell( AppLocalizations.of(context).translate('running_status.name'), isHeader: true, ), _buildTableCell( AppLocalizations.of( context, ).translate('running_status.value'), isHeader: true, ), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.pitch_angle') + "(°)", ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.roll_angle') + "(°)", ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.heading_angle') + "(°)", ), _buildTableCell(status.yaw.toStringAsFixed(2)), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.battery') + "(%)", ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.chip_temp') + "(°C)", ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.knife_speed') + "(rpm)", ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.control_mode'), ), _buildTableCell('--'), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.longitude') + "(°)", ), _buildTableCell(status.longitude.toStringAsFixed(6)), ], ), TableRow( children: [ _buildTableCell( AppLocalizations.of( context, ).translate('running_status.latitude') + "(°)", ), _buildTableCell(status.latitude.toStringAsFixed(6)), ], ), ], ), ], ); } Widget _buildTableCell(String text, {bool isHeader = false}) { return TableCell( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), child: Text( text, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: isHeader ? const Color(0xFF333333) : const Color(0xFF666666), fontWeight: isHeader ? FontWeight.bold : FontWeight.normal, ), ), ), ); } String _getControlModeKey(int modeValue) { switch (modeValue) { case 3: return 'running_status.remote_control'; default: return 'running_status.local_control'; } } @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is DeviceStatusUpdated) { _startDataTimeoutTimer(); // 每次收到数据重置超时计时器 _isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止) _appendChartData(state); } return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.85, ), decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), ), ), child: Column( children: [ // Header Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: const BoxDecoration( color: Color(0xFF1677FF), borderRadius: BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of( context, ).translate('running_status.title'), style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), // 🔥 显示当前TCP认证的设备号 BlocBuilder( builder: (context, state) { var deviceS = state.targetDevice?.deviceName ?? '未选择'; return Text( '设备: $deviceS', style: const TextStyle( color: Colors.white70, fontSize: 12, ), ); }, ), ], ), ), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), // Status Bar BlocBuilder( builder: (context, state) { // 🔥 headingStatus/satelliteCnt 来自 realtime/post(尚未推送) String satelliteCnt = '--'; String headingStatus = '--'; // 🔥 定位质量来自 location/post 的 fix_status String qual = '--'; if (state is DeviceStatusUpdated) { qual = _getLocationQualityText(state.status.qual); } return Container( color: Colors.white, padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( flex: 3, child: Text( AppLocalizations.of( context, ).translate('running_status.heading_status') + ":$headingStatus", style: const TextStyle(fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), Expanded( flex: 4, child: Text( AppLocalizations.of( context, ).translate('running_status.position_quality') + ":$qual", style: const TextStyle(fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ), Expanded( flex: 2, child: Text( AppLocalizations.of( context, ).translate('running_status.satellite_count') + ":$satelliteCnt", style: const TextStyle(fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.right, ), ), ], ), ); }, ), // Tab Switch Container( color: Colors.white, margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ GestureDetector( onTap: () => setState(() => _isCardView = true), child: _buildTab( AppLocalizations.of( context, ).translate('running_status.card'), isActive: _isCardView, ), ), const SizedBox(width: 24), GestureDetector( onTap: () => setState(() => _isCardView = false), child: _buildTab( AppLocalizations.of( context, ).translate('running_status.chart'), isActive: !_isCardView, ), ), ], ), IconButton( icon: const Icon(Icons.refresh, color: Color(0xFF1677FF)), onPressed: _onRefresh, ), ], ), ), // Content Expanded( child: Container( margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state), ), ), ], ), ); }, ); } Widget _buildTab(String title, {required bool isActive}) { return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: isActive ? const Color(0xFF1677FF) : Colors.transparent, width: 2, ), ), ), child: Text( title, style: TextStyle( color: isActive ? const Color(0xFF1677FF) : const Color(0xFF666666), fontSize: 16, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, height: 1.0, ), ), ); } Widget _buildChartContentView(DeviceStatusState state) { if (state is DeviceStatusInitial) { return _noDataWidget(); } if (state is! DeviceStatusUpdated) { return _noDataWidget(); } final status = state.status; return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _chartCard( title: AppLocalizations.of( context, ).translate('running_status.voltage') + " (V)", isGaugeMode: _voltageGaugeMode, onGaugeTap: () => setState(() => _voltageGaugeMode = true), onChartTap: () => setState(() => _voltageGaugeMode = false), child: _voltageGaugeMode ? _circularGauge( value: status.voltage, maxValue: 250, unit: "V", majorTicks: const [0, 50, 100, 150, 200, 250], ) : SizedBox( height: 180, child: _styledLineChart( title: AppLocalizations.of( context, ).translate('running_status.voltage'), lines: [ _lineData(_voltageHistory, const Color(0xFF4CAF50)), ], yAxisMax: 250, yAxisMin: 0, yTickCount: 6, ), ), ), _gap(), _chartCard( title: AppLocalizations.of( context, ).translate('running_status.chip_temp') + " (°C)", isGaugeMode: _chipTempGaugeMode, onGaugeTap: () => setState(() => _chipTempGaugeMode = true), onChartTap: () => setState(() => _chipTempGaugeMode = false), child: _chipTempGaugeMode ? _circularGauge( value: status.chipTemp, maxValue: 100, unit: "°C", majorTicks: const [ 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, ], ) : SizedBox( height: 180, child: _styledLineChart( title: AppLocalizations.of( context, ).translate('running_status.chip_temp'), lines: [ _lineData(_chipTempHistory, const Color(0xFF4CAF50)), ], yAxisMax: 100, yAxisMin: 0, yTickCount: 6, ), ), ), _gap(), _chartCard( title: AppLocalizations.of( context, ).translate('running_status.knife_speed') + " (rpm)", isGaugeMode: _knifeSpeedGaugeMode, onGaugeTap: () => setState(() => _knifeSpeedGaugeMode = true), onChartTap: () => setState(() => _knifeSpeedGaugeMode = false), child: _knifeSpeedGaugeMode ? _circularGauge( value: double.tryParse(status.knifeCuttingSpeed) ?? 0, maxValue: 3000, unit: "rpm", majorTicks: const [0, 500, 1000, 1500, 2000, 2500, 3000], ) : SizedBox( height: 180, child: _styledLineChart( title: AppLocalizations.of( context, ).translate('running_status.knife_speed'), lines: [ _lineData(_knifeHistory, const Color(0xFF4CAF50)), ], yAxisMax: 3000, yAxisMin: -3000, yTickCount: 7, ), ), ), _gap(), Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of( context, ).translate('running_status.left_right_measure_speed') + " (rpm)", style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF), ), ), const SizedBox(height: 16), SizedBox( height: 180, child: _styledLineChart( title: AppLocalizations.of( context, ).translate('running_status.left_right_measure_speed'), lines: [ _lineData(_leftMeasureHistory, const Color(0xFF3F51B5)), _lineData(_rightMeasureHistory, const Color(0xFF8BC34A)), ], yAxisMax: 3000, yAxisMin: -3000, yTickCount: 7, ), ), ], ), ), _gap(), ], ), ); } Widget _gap({double height = 20}) { return SizedBox(height: height); } Widget _chartCard({ required String title, required Widget child, required bool isGaugeMode, required Function() onGaugeTap, required Function() onChartTap, }) { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF), ), ), Row( children: [ GestureDetector( onTap: onGaugeTap, child: Text( "仪表盘", style: TextStyle( color: isGaugeMode ? const Color(0xFF1677FF) : Colors.grey, fontSize: 14, fontWeight: isGaugeMode ? FontWeight.bold : FontWeight.normal, ), ), ), const SizedBox(width: 16), GestureDetector( onTap: onChartTap, child: Text( "折线图", style: TextStyle( color: !isGaugeMode ? const Color(0xFF1677FF) : Colors.grey, fontSize: 14, fontWeight: !isGaugeMode ? FontWeight.bold : FontWeight.normal, ), ), ), ], ), ], ), const SizedBox(height: 16), child, ], ), ); } Widget _circularGauge({ required double value, required double maxValue, required String unit, List majorTicks = const [ 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, ], }) { return AnimatedCircularGauge( value: value, maxValue: maxValue, unit: unit, majorTicks: majorTicks, ); } Widget _styledLineChart({ required String title, required List lines, required double yAxisMax, double yAxisMin = 0, List bottomLabels = const ["t", "t+1", "t+2", "t+3", "t+4", "t+5"], int yTickCount = 5, bool forceShowZeroTick = true, double leftTitlePadding = 8.0, double bottomTitlePadding = 8.0, }) { final List yTicks = _calculateYTicks( yAxisMin, yAxisMax, yTickCount, ); final List finalYTicks = forceShowZeroTick ? (yTicks.contains(0) ? yTicks : [...yTicks, 0] ..sort()) : yTicks; final double yInterval = finalYTicks.length > 1 ? (finalYTicks.last - finalYTicks.first) / (finalYTicks.length - 1) : 1.0; const double xMin = 0; const double xMax = 5; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (lines.length > 1) Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _legendItem( color: const Color(0xFF3F51B5), label: AppLocalizations.of( context, ).translate('running_status.left_wheel'), ), const SizedBox(width: 24), _legendItem( color: const Color(0xFF8BC34A), label: AppLocalizations.of( context, ).translate('running_status.right_wheel'), ), ], ), ), Expanded( child: Padding( padding: const EdgeInsets.only( right: 35, top: 5, bottom: 0, left: 0, ), child: LineChart( LineChartData( minY: yAxisMin, maxY: yAxisMax, minX: xMin, maxX: xMax, borderData: FlBorderData(show: false), gridData: FlGridData( show: true, horizontalInterval: yInterval, getDrawingHorizontalLine: (value) { if (value == 0) { return const FlLine( color: Color(0xFFCCCCCC), strokeWidth: 1.5, ); } return const FlLine( color: Color(0xFFE5E5E5), strokeWidth: 1, ); }, drawVerticalLine: false, ), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 50 + leftTitlePadding, interval: yInterval, getTitlesWidget: (value, meta) { if (finalYTicks.any( (tick) => (value - tick).abs() < 0.01, )) { return Padding( padding: EdgeInsets.only(right: leftTitlePadding), child: Text( value.toStringAsFixed(0), style: const TextStyle( fontSize: 12, color: Colors.grey, ), textAlign: TextAlign.right, ), ); } return const SizedBox.shrink(); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, interval: 1, reservedSize: 35 + bottomTitlePadding, getTitlesWidget: (value, meta) { int idx = value.toInt(); if (idx >= 0 && idx < bottomLabels.length) { return Padding( padding: EdgeInsets.only(top: bottomTitlePadding), child: Transform.translate( offset: const Offset(-5, 0), child: Text( bottomLabels[idx], style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ), ); } return const SizedBox.shrink(); }, ), ), rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), lineBarsData: lines, extraLinesData: ExtraLinesData(horizontalLines: []), ), ), ), ), ], ); } List _calculateYTicks(double min, double max, int count) { if (min > 0) { min = 0; } final List ticks = []; final double step = (max - min) / (count - 1); for (int i = 0; i < count; i++) { ticks.add(min + step * i); } return ticks; } Widget _legendItem({required Color color, required String label}) { return Row( children: [ Container( width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white, border: Border.all(color: color, width: 2), ), ), const SizedBox(width: 4), Text( label, style: const TextStyle(fontSize: 14, color: Colors.black87), ), ], ); } LineChartBarData _lineData(List data, Color color) { return LineChartBarData( spots: _getMappedSpots(data), isCurved: false, color: color, barWidth: 2, isStrokeCapRound: true, dotData: FlDotData( show: true, getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( radius: 4, color: color, strokeWidth: 2, strokeColor: Colors.white, ), ), belowBarData: BarAreaData(show: false), ); } /// 🔥 fix_status 枚举翻译(来自 MQTT location/post) String _getLocationQualityText(int qualValue) { switch (qualValue) { case 0: return '无定位'; case 1: return '单点定位'; case 2: return '差分定位'; case 3: return '惯性导航'; case 4: return 'RTK固定解'; case 5: return 'RTK浮点解'; case 6: return '单点(无航向)'; case 7: return '差分(无航向)'; case 8: return 'RTK固定(无航向)'; case 9: return 'RTK浮点(无航向)'; default: return '--'; } } } class AnimatedCircularGauge extends StatefulWidget { final double value; final double maxValue; final String unit; final List majorTicks; const AnimatedCircularGauge({ super.key, required this.value, required this.maxValue, required this.unit, required this.majorTicks, }); @override State createState() => _AnimatedCircularGaugeState(); } class _AnimatedCircularGaugeState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation _valueAnimation; double _previousValue = 0.0; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _previousValue = widget.value; _valueAnimation = Tween(begin: 0, end: widget.value).animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, ), )..addListener(() { setState(() {}); }); _animationController.forward(); } @override void didUpdateWidget(covariant AnimatedCircularGauge oldWidget) { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value) { _previousValue = _valueAnimation.value; _valueAnimation = Tween(begin: _previousValue, end: widget.value).animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, ), )..addListener(() { setState(() {}); }); _animationController.reset(); _animationController.forward(); } } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: 180, child: Stack( alignment: Alignment.center, children: [ CustomPaint( painter: CircularGaugePainter( value: _valueAnimation.value, maxValue: widget.maxValue, majorTicks: widget.majorTicks, ), size: const Size(240, 240), ), Positioned( bottom: 20, child: Text( "${_valueAnimation.value.toStringAsFixed(2)} ${widget.unit}", style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w500, color: Colors.grey, ), ), ), ], ), ); } } class CircularGaugePainter extends CustomPainter { final double value; final double maxValue; final List majorTicks; CircularGaugePainter({ required this.value, required this.maxValue, required this.majorTicks, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2 - 20; final backgroundPaint = Paint() ..color = const Color(0xFFE5E5E5) ..style = PaintingStyle.stroke ..strokeWidth = 12; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -math.pi * 1.25, math.pi * 2.5, false, backgroundPaint, ); final progress = value / maxValue; final progressPaint = Paint() ..color = const Color(0xFF1677FF) ..style = PaintingStyle.stroke ..strokeWidth = 12 ..strokeCap = StrokeCap.round; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -math.pi * 1.25, math.pi * 2.5 * progress, false, progressPaint, ); final tickPaint = Paint()..color = const Color(0xFF888888); final textStyle = const TextStyle(fontSize: 10, color: Color(0xFF888888)); final textPainter = TextPainter(textDirection: TextDirection.ltr); for (int i = 0; i < majorTicks.length; i++) { final tickValue = majorTicks[i]; final tickProgress = tickValue / maxValue; final angle = -math.pi * 1.25 + math.pi * 2.5 * tickProgress; final innerRadius = radius - 20; final outerRadius = radius - 8; final start = Offset( center.dx + innerRadius * math.cos(angle), center.dy + innerRadius * math.sin(angle), ); final end = Offset( center.dx + outerRadius * math.cos(angle), center.dy + outerRadius * math.sin(angle), ); canvas.drawLine(start, end, tickPaint); final labelRadius = radius - 35; final labelOffset = Offset( center.dx + labelRadius * math.cos(angle), center.dy + labelRadius * math.sin(angle), ); textPainter.text = TextSpan(text: tickValue.toString(), style: textStyle); textPainter.layout(); textPainter.paint( canvas, Offset( labelOffset.dx - textPainter.width / 2, labelOffset.dy - textPainter.height / 2, ), ); } } @override bool shouldRepaint(covariant CircularGaugePainter oldDelegate) { return value != oldDelegate.value || maxValue != oldDelegate.maxValue; } }