import 'dart:async'; import 'dart:convert'; 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:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart'; import 'package:syncfusion_flutter_gauges/gauges.dart'; import 'package:flutter/animation.dart'; // 占位导入(请替换为你项目的实际路径) import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/network/net_message_dispatcher.dart'; import '../../../../core/network/protocol_decoder.dart'; import '../../../../core/network/tcp/tcp_client.dart'; import '../../../auth/presentation/bloc/auth_cubit.dart'; import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../devices/presentation/bloc/device_status_bloc.dart'; import '../../../devices/presentation/bloc/device_status_event.dart'; import '../../../devices/presentation/bloc/device_status_state.dart'; // 配置:数据超时时间(15秒) const int DATA_TIMEOUT_SECONDS = 15; class RunningStatusPage extends StatefulWidget { const RunningStatusPage({super.key}); @override State createState() => _RunningStatusPageState(); } class _RunningStatusPageState extends State with WidgetsBindingObserver { // 🔥 静态实例引用,用于生命周期回调 static _RunningStatusPageState? _instance; 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; // 标记是否数据超时 // 🔥 标记是否已从 bloc 缓存初始化过图表数据,防止 BlocBuilder 重复追加 bool _hasSeededFromCache = false; @override void initState() { super.initState(); // 🔥 注册静态实例 _instance = this; debugPrint('🚀 [RunningStatusPage] initState 被调用'); // 🔥 使用 DeviceStatusBloc // context.read().add(DeviceStatusReset()); // 🔥 关键:立即启动超时计时器,如果5秒内没收到数据就显示"暂无数据" _startDataTimeoutTimer(); // 🔥 新增:监听应用生命周期 WidgetsBinding.instance.addObserver(this); // 🔥 关键修复:页面进入时立即用 bloc 当前缓存数据初始化图表 // 不用等下一次 TCP 推送才显示数据 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final blocState = context.read().state; if (blocState is DeviceStatusUpdated) { debugPrint('🚀 [RunningStatusPage] bloc已有缓存数据,立即初始化图表'); _hasSeededFromCache = true; _appendChartData(blocState); _startDataTimeoutTimer(); // 重置超时计时器 if (_isDataTimeout) { setState(() => _isDataTimeout = false); } } }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugPrint('🔄 [RunningStatusPage] 应用恢复,强制刷新UI'); _forceRefresh(); } else if (state == AppLifecycleState.paused) { debugPrint('⏸️ [RunningStatusPage] 应用进入后台,暂停超时计时器'); _dataTimeoutTimer?.cancel(); } } @override void dispose() { // 🔥 移除生命周期观察者 WidgetsBinding.instance.removeObserver(this); _instance = null; // 销毁计时器,防止内存泄漏 _dataTimeoutTimer?.cancel(); super.dispose(); } // 启动/重置数据超时计时器 void _startDataTimeoutTimer() { // 先取消现有计时器 _dataTimeoutTimer?.cancel(); // 启动新计时器:超过指定时间未收到数据则标记为超时 _dataTimeoutTimer = Timer(Duration(seconds: DATA_TIMEOUT_SECONDS), () { if (mounted) { setState(() { _isDataTimeout = true; // 超时后清空历史图表数据(可选) _resetChartData(); }); } }); } // 核心修复:数据点X轴坐标强制映射为0-5(对应6个标签),保证一一对应 List _getMappedSpots(List data) { final List mapped = []; // 取最后6个数据 final List limited = data.length > 6 ? data.sublist(data.length - 6) : List.from(data); // 映射X轴坐标为0,1,2,3,4,5 for (int i = 0; i < limited.length; i++) { mapped.add(FlSpot(i.toDouble(), limited[i].y)); } // 不足6个时,补空值(X轴继续递增,y=0) 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 _refreshDeviceData() { final deviceState = context.read().state; final currentDevice = deviceState.selectedDevice; if (currentDevice != null) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).translate('running_status.data_refresh_success')), duration: const Duration(seconds: 1))); // 刷新后重置超时状态 if (_isDataTimeout) { setState(() => _isDataTimeout = false); } _startDataTimeoutTimer(); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).translate('running_status.no_device')), duration: const Duration(seconds: 1))); } } 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 _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 _compassGauge({required double yaw}) { return SizedBox( height: 200, child: Stack( alignment: Alignment.center, children: [ CustomPaint( painter: CompassGaugePainter(yaw: yaw), size: const Size(240, 240), ), Text( "${yaw.toStringAsFixed(0)}°", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), ), ], ), ); } // 新增:暂无数据占位组件 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 _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, // 兼容版:控制刻度文字的内边距(替代axisOffset) double leftTitlePadding = 8.0, double bottomTitlePadding = 8.0, }) { // 确保0刻度被包含在刻度列表中 final List yTicks = _calculateYTicks(yAxisMin, yAxisMax, yTickCount); final List finalYTicks = forceShowZeroTick ? (yTicks.contains(0) ? yTicks : [...yTicks, 0] ..sort()) : yTicks; // 计算合法的刻度间隔(必须>0) 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) { // 0刻度网格线加粗突出 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, // 合法的间隔值(>0) interval: yInterval, getTitlesWidget: (value, meta) { // 只显示目标刻度(包含0) if (finalYTicks.any((tick) => (value - tick).abs() < 0.01)) { // 用Padding控制刻度与图表的间距(兼容所有版本) 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, // 横轴固定间隔1(合法值) // 预留刻度文字高度 reservedSize: 35 + bottomTitlePadding, getTitlesWidget: (value, meta) { int idx = value.toInt(); if (idx >= 0 && idx < bottomLabels.length) { return Padding( // 用Padding控制垂直间距,Transform避免最后一个标签截断 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: []), ), ), ), ), ], ); } // 刻度计算方法(确保0刻度被包含) List _calculateYTicks(double min, double max, int count) { // 强制把最小值设为0(如果需要显示0刻度) 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)), ], ); } // 使用修复后的_getMappedSpots映射数据点 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), ); } // ====================== 图表视图 ====================== Widget _buildChartContentView(DeviceStatusState state) { // 修改1:超时/无数据时显示暂无数据,而非loading 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(), 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_target_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_target_speed'), lines: [_lineData(_leftTargetHistory, const Color(0xFF3F51B5)), _lineData(_rightTargetHistory, const Color(0xFF8BC34A))], 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.current_compare') + " (A)", 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.current_compare'), lines: [_lineData(_leftCurrentHistory, const Color(0xFF3F51B5)), _lineData(_rightCurrentHistory, const Color(0xFF8BC34A))], yAxisMax: 100, yAxisMin: 0, yTickCount: 6, ), ), ], ), ), _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.motor_temp_compare') + " (°C)", 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.motor_temp_compare'), lines: [_lineData(_leftTempHistory, const Color(0xFF3F51B5)), _lineData(_rightTempHistory, const Color(0xFF8BC34A))], yAxisMax: 100, yAxisMin: 0, yTickCount: 6, ), ), ], ), ), _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.heading_angle_chart') + " (°)", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)), ), const SizedBox(height: 16), _compassGauge(yaw: status.yaw), ], ), ), _gap(), ], ), ); } // ====================== 卡片视图 ====================== Widget _buildCardContentView(DeviceStatusState state) { // 修改2:卡片视图同样替换loading为暂无数据 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(status.leftTargetSpeed.toStringAsFixed(2)), _buildTableCell(status.rightTargetSpeed.toStringAsFixed(2)), ], ), TableRow( children: [ _buildTableCell(AppLocalizations.of(context).translate('running_status.measure_speed') + "\n(rpm)"), _buildTableCell(status.leftMeasureSpeed.toStringAsFixed(2)), _buildTableCell(status.rightMeasureSpeed.toStringAsFixed(2)), ], ), TableRow( children: [ _buildTableCell(AppLocalizations.of(context).translate('running_status.current') + "(A)"), _buildTableCell(status.leftCurrent.toStringAsFixed(2)), _buildTableCell(status.rightCurrent.toStringAsFixed(2)), ], ), TableRow( children: [ _buildTableCell(AppLocalizations.of(context).translate('running_status.motor_temp') + "(°C)"), _buildTableCell(status.leftMotorTemp.toStringAsFixed(2)), _buildTableCell(status.rightMotorTemp.toStringAsFixed(2)), ], ), ], ), ], ); } 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(status.pitch.toStringAsFixed(2))]), TableRow(children: [_buildTableCell(AppLocalizations.of(context).translate('running_status.roll_angle') + "(°)"), _buildTableCell(status.roll.toStringAsFixed(2))]), 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(status.battery)]), TableRow(children: [_buildTableCell(AppLocalizations.of(context).translate('running_status.chip_temp') + "(°C)"), _buildTableCell(status.chipTemp.toStringAsFixed(2))]), TableRow(children: [_buildTableCell(AppLocalizations.of(context).translate('running_status.knife_speed') + "(rpm)"), _buildTableCell(status.knifeCuttingSpeed)]), TableRow(children: [_buildTableCell(AppLocalizations.of(context).translate('running_status.control_mode')), _buildTableCell(AppLocalizations.of(context).translate(_getControlModeKey(int.parse(status.controlMode))))]), 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, ), ), ), ); } 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, // 文字行高 ), ), ); } // 辅助方法:根据定位质量值返回翻译键 String _getLocationQualityKey(int qualValue) { switch (qualValue) { case 0: return 'running_status.invalid'; case 1: return 'common.unknown'; // GPS单点定位 - 使用通用未知 case 2: return 'common.unknown'; // DGPS - 使用通用未知 case 4: return 'running_status.valid'; // RTK固定解 - 视为有效 case 5: return 'running_status.valid'; // RTK浮点解 - 视为有效 default: return 'common.unknown'; } } // 辅助方法:根据控制模式值返回翻译键 String _getControlModeKey(int modeValue) { switch (modeValue) { case 3: return 'running_status.remote_control'; default: return 'running_status.local_control'; } } @override Widget build(BuildContext context) { final deviceState = context.read().state; final currentDevice = deviceState.selectedDevice; if (currentDevice == null) { // 修改3:无设备时也显示暂无数据 return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _buildOptimizedAppBar(), // 使用优化后的AppBar body: _noDataWidget(), ); } return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _buildOptimizedAppBar(), // 替换原有的SliverAppBar为优化后的AppBar body: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ BlocBuilder( builder: (context, state) { // 新增:超时状态下显示-- String qual = _isDataTimeout ? '--' : '--'; String satelliteCnt = _isDataTimeout ? '--' : '--'; String headingStatus = _isDataTimeout ? "--" : "--"; if (state is DeviceStatusUpdated) { headingStatus = state.status.headingStatus == 0 ? AppLocalizations.of(context).translate('running_status.not_initialized') : AppLocalizations.of(context).translate('running_status.initialized'); int qualValue = 0; try { qualValue = int.parse(state.status.qual.toString()); } catch (e) { qualValue = 0; } qual = AppLocalizations.of(context).translate(_getLocationQualityKey(qualValue)); satelliteCnt = state.status.satelliteCnt.toString(); // 收到新数据,重置超时计时器和状态 _startDataTimeoutTimer(); _isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState } else if (!_isDataTimeout && state is DeviceStatusError) { qual = '-'; satelliteCnt = '-'; headingStatus = '-'; } return SliverToBoxAdapter( child: Container( color: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), 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, ), ), ], ), ), ); }, ), SliverToBoxAdapter( child: 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), ), ], ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF1677FF), foregroundColor: Colors.white, elevation: 2, shadowColor: const Color(0xFF1677FF).withOpacity(0.3), shape: const CircleBorder(), // 👇 圆形必须用对称内边距 padding: const EdgeInsets.all(12), //padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), ), onPressed: _refreshDeviceData, child: const Icon(Icons.refresh_rounded, size: 18), ), ], ), ), ), SliverToBoxAdapter( child: BlocBuilder( builder: (context, state) { debugPrint('🎨 [UI-Build] BlocBuilder 重建!当前状态类型:${state.runtimeType}'); // 检测到设备切换(状态重置)时,清空图表历史数据 if (state is DeviceStatusInitial) { debugPrint('🧹 [UI] 检测到 Initial 状态,准备重置图表数据'); WidgetsBinding.instance.addPostFrameCallback((_) { _resetChartData(); }); } if (state is DeviceStatusUpdated) { debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据'); // 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发 if (_hasSeededFromCache) { _hasSeededFromCache = false; // 只跳过第一次 debugPrint('📈 [UI] 跳过首次 BlocBuilder 追加(已由 initState 缓存初始化)'); } else { _appendChartData(state); } // 🔥 关键:收到数据后立即重置超时计时器 _startDataTimeoutTimer(); _isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState } return Container(margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state)); }, ), ), ], ), ); } // 修改4:优化后的AppBar样式 AppBar _buildOptimizedAppBar() { return AppBar( backgroundColor: const Color(0xFF1677FF), elevation: 4, // 增加阴影,提升层次感 centerTitle: true, title: Text( AppLocalizations.of(context).translate('running_status.title'), style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5, // 增加字间距,提升可读性 ), ), leading: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Colors.white, size: 20), // 替换为更精致的ios风格返回图标 onPressed: () => Navigator.pop(context), splashRadius: 24, // 优化点击反馈范围 ), // 新增:AppBar底部阴影优化 shadowColor: Colors.black12, ); } // 新增:清空图表历史数据 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; } // 🔥 新增:强制刷新UI(用于息屏恢复) void _forceRefresh() { if (mounted) { debugPrint('🔄 [RunningStatusPage] 执行强制刷新'); final tcpClient = sl(); if (!tcpClient.isConnected) { debugPrint('⚠️ [RunningStatusPage] TCP未连接,尝试重连'); _reconnectTcp(); } else { setState(() { _isDataTimeout = false; }); _startDataTimeoutTimer(); debugPrint('✅ [RunningStatusPage] TCP已连接,发送心跳确认'); tcpClient.sendHeartbeat(); } } } Future _reconnectTcp() async { try { final authCubit = context.read(); await authCubit.reconnectAfterResume(); if (mounted) { setState(() { _isDataTimeout = false; }); _startDataTimeoutTimer(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnected')), duration: const Duration(seconds: 2), backgroundColor: Colors.green, ), ); } } catch (e) { debugPrint('❌ [RunningStatusPage] TCP重连失败: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnect_failed')), duration: const Duration(seconds: 2), backgroundColor: Colors.red, ), ); } } } } // ====================== 带动画的圆形仪表盘 Widget ====================== 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 bgArcPaint = Paint() ..color = const Color(0xFFE3F2FD) ..style = PaintingStyle.stroke ..strokeWidth = 8 ..strokeCap = StrokeCap.round; final bgRect = Rect.fromCircle(center: center, radius: radius); canvas.drawArc(bgRect, 135 * math.pi / 180, 270 * math.pi / 180, false, bgArcPaint); final progressRatio = math.min(value / maxValue, 1.0); final progressPaint = Paint() ..color = const Color(0xFFBBDEFB) ..style = PaintingStyle.stroke ..strokeWidth = 8 ..strokeCap = StrokeCap.round; canvas.drawArc(bgRect, 135 * math.pi / 180, 270 * math.pi / 180 * progressRatio, false, progressPaint); final tickPaint = Paint()..color = Colors.grey.shade400; final majorTickPaint = Paint() ..color = Colors.grey.shade600 ..strokeWidth = 2; const totalTicks = 270; for (int i = 0; i < totalTicks; i++) { final angle = 135 * math.pi / 180 + (i * math.pi * 270 / 180) / totalTicks; final tickStart = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 4); final tickEnd = center + Offset(math.cos(angle), math.sin(angle)) * radius; tickPaint.strokeWidth = 1; canvas.drawLine(tickStart, tickEnd, tickPaint); final tickValue = (i / totalTicks) * maxValue; if (majorTicks.any((tick) => (tickValue - tick).abs() < 0.1)) { final majorTickStart = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 8); canvas.drawLine(majorTickStart, tickEnd, majorTickPaint); final textValue = tickValue.toStringAsFixed(0); final textPainter = TextPainter( text: TextSpan( text: textValue, style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.w500), ), textDirection: TextDirection.ltr, )..layout(); final textOffset = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 20); final textX = textOffset.dx - textPainter.width / 2; final textY = textOffset.dy - textPainter.height / 2; textPainter.paint(canvas, Offset(textX, textY)); } } final needleAngle = 135 * math.pi / 180 + 270 * math.pi / 180 * progressRatio; final needlePaint = Paint() ..color = Colors.grey.shade600 ..style = PaintingStyle.fill; final needlePath = Path() ..moveTo(center.dx + (radius - 10) * math.cos(needleAngle), center.dy + (radius - 10) * math.sin(needleAngle)) ..lineTo(center.dx + 6 * math.cos(needleAngle - math.pi / 2), center.dy + 6 * math.sin(needleAngle - math.pi / 2)) ..lineTo(center.dx + 6 * math.cos(needleAngle + math.pi / 2), center.dy + 6 * math.sin(needleAngle + math.pi / 2)) ..close(); canvas.drawPath(needlePath, needlePaint); final centerDotPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; canvas.drawCircle(center, 3, centerDotPaint); } @override bool shouldRepaint(covariant CircularGaugePainter oldDelegate) { return oldDelegate.value != value || oldDelegate.maxValue != maxValue || oldDelegate.majorTicks != majorTicks; } } // ====================== 罗盘式航向角仪表盘绘制器 ====================== class CompassGaugePainter extends CustomPainter { final double yaw; CompassGaugePainter({required this.yaw}); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2 - 20; // 绘制外圆 final outerPaint = Paint() ..color = const Color(0xFF1677FF) ..style = PaintingStyle.stroke ..strokeWidth = 10; canvas.drawCircle(center, radius, outerPaint); // 绘制刻度 final tickPaint = Paint() ..color = Colors.grey ..strokeWidth = 2; for (int i = 0; i < 36; i++) { // 修复1:刻度角度映射 - 让0°=北(N),180°=南(S),90°=东(E),270°=西(W) final degree = i * 10; final angle = (degree - 90) * math.pi / 180; // 0°(北)对应-90弧度,180°(南)对应90弧度 final x1 = center.dx + (radius - 10) * math.cos(angle); final y1 = center.dy + (radius - 10) * math.sin(angle); final x2 = center.dx + radius * math.cos(angle); final y2 = center.dy + radius * math.sin(angle); canvas.drawLine(Offset(x1, y1), Offset(x2, y2), tickPaint); if (i % 9 == 0) { // 绘制主刻度 final x3 = center.dx + (radius - 20) * math.cos(angle); final y3 = center.dy + (radius - 20) * math.sin(angle); canvas.drawLine(Offset(x1, y1), Offset(x3, y3), tickPaint..strokeWidth = 4); // 绘制方向文字(N/E/S/W)和角度数值 String direction = ""; if (degree == 0) direction = "N"; else if (degree == 90) direction = "E"; else if (degree == 180) direction = "S"; else if (degree == 270) direction = "W"; // 绘制方向文字 if (direction.isNotEmpty) { final textPainter = TextPainter( text: TextSpan( text: direction, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black), ), textDirection: TextDirection.ltr, )..layout(); final textX = center.dx + (radius - 40) * math.cos(angle) - textPainter.width / 2; final textY = center.dy + (radius - 40) * math.sin(angle) - textPainter.height / 2; textPainter.paint(canvas, Offset(textX, textY)); } // 绘制角度数值(如90、180、270) final textPainter = TextPainter( text: TextSpan( text: degree.toString(), style: const TextStyle(fontSize: 12, color: Colors.black54), ), textDirection: TextDirection.ltr, )..layout(); final textX = center.dx + (radius - 60) * math.cos(angle) - textPainter.width / 2; final textY = center.dy + (radius - 60) * math.sin(angle) - textPainter.height / 2; textPainter.paint(canvas, Offset(textX, textY)); } } // 绘制中心角度文字(如180°) final centerTextPainter = TextPainter( text: TextSpan( text: "${yaw.toStringAsFixed(0)}°", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), ), textDirection: TextDirection.ltr, textAlign: TextAlign.center, )..layout(); final centerTextX = center.dx - centerTextPainter.width / 2; final centerTextY = center.dy - centerTextPainter.height / 2; centerTextPainter.paint(canvas, Offset(centerTextX, centerTextY)); // ========== 核心修复:指针角度计算 ========== // 修复2:指针角度映射 - 让yaw=180°时指向正南(S) final needleDegree = yaw; final needleAngle = (needleDegree - 90) * math.pi / 180; // 与刻度角度映射一致 // 指针绘制样式 final needlePaint = Paint() ..color = Colors.red ..style = PaintingStyle.fill; // 指针路径:粗端在中心,细端指向外部 final needlePath = Path() ..moveTo(center.dx - 8, center.dy) // 中心左侧8px ..lineTo(center.dx + 8, center.dy) // 中心右侧8px ..lineTo(center.dx + radius * 0.8 * math.cos(needleAngle), center.dy + radius * 0.8 * math.sin(needleAngle)) ..close(); canvas.drawPath(needlePath, needlePaint); // 中心圆点美化 final centerDotPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; canvas.drawCircle(center, 4, centerDotPaint); final centerBorderPaint = Paint() ..color = Colors.red ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawCircle(center, 4, centerBorderPaint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }