1425 lines
55 KiB
Dart
1425 lines
55 KiB
Dart
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<RunningStatusPage> createState() => _RunningStatusPageState();
|
||
}
|
||
|
||
class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindingObserver {
|
||
// 🔥 静态实例引用,用于生命周期回调
|
||
static _RunningStatusPageState? _instance;
|
||
|
||
bool _isCardView = true;
|
||
|
||
// 仪表盘/折线图切换状态
|
||
bool _voltageGaugeMode = true;
|
||
bool _chipTempGaugeMode = true;
|
||
bool _knifeSpeedGaugeMode = false;
|
||
|
||
// 历史数据
|
||
final List<FlSpot> _leftMeasureHistory = [];
|
||
final List<FlSpot> _rightMeasureHistory = [];
|
||
final List<FlSpot> _leftTargetHistory = [];
|
||
final List<FlSpot> _rightTargetHistory = [];
|
||
final List<FlSpot> _leftCurrentHistory = [];
|
||
final List<FlSpot> _rightCurrentHistory = [];
|
||
final List<FlSpot> _leftTempHistory = [];
|
||
final List<FlSpot> _rightTempHistory = [];
|
||
final List<FlSpot> _knifeHistory = [];
|
||
final List<FlSpot> _chipTempHistory = [];
|
||
final List<FlSpot> _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<DeviceStatusBloc>().add(DeviceStatusReset());
|
||
|
||
// 🔥 关键:立即启动超时计时器,如果5秒内没收到数据就显示"暂无数据"
|
||
_startDataTimeoutTimer();
|
||
|
||
// 🔥 新增:监听应用生命周期
|
||
WidgetsBinding.instance.addObserver(this);
|
||
|
||
// 🔥 关键修复:页面进入时立即用 bloc 当前缓存数据初始化图表
|
||
// 不用等下一次 TCP 推送才显示数据
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
final blocState = context.read<DeviceStatusBloc>().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<FlSpot> _getMappedSpots(List<FlSpot> data) {
|
||
final List<FlSpot> mapped = [];
|
||
// 取最后6个数据
|
||
final List<FlSpot> 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<FlSpot> list, {int max = 30}) {
|
||
if (list.length > max) list.removeAt(0);
|
||
}
|
||
|
||
void _refreshDeviceData() {
|
||
final deviceState = context.read<DevicesCubit>().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<double> 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<LineChartBarData> lines,
|
||
required double yAxisMax,
|
||
double yAxisMin = 0,
|
||
List<String> 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<double> yTicks = _calculateYTicks(yAxisMin, yAxisMax, yTickCount);
|
||
final List<double> 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<double> _calculateYTicks(double min, double max, int count) {
|
||
// 强制把最小值设为0(如果需要显示0刻度)
|
||
if (min > 0) {
|
||
min = 0;
|
||
}
|
||
|
||
final List<double> 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<FlSpot> 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<DevicesCubit>().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<DeviceStatusBloc, DeviceStatusState>(
|
||
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<DeviceStatusBloc, DeviceStatusState>(
|
||
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<TcpClient>();
|
||
if (!tcpClient.isConnected) {
|
||
debugPrint('⚠️ [RunningStatusPage] TCP未连接,尝试重连');
|
||
_reconnectTcp();
|
||
} else {
|
||
setState(() {
|
||
_isDataTimeout = false;
|
||
});
|
||
_startDataTimeoutTimer();
|
||
|
||
debugPrint('✅ [RunningStatusPage] TCP已连接,发送心跳确认');
|
||
tcpClient.sendHeartbeat();
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _reconnectTcp() async {
|
||
try {
|
||
final authCubit = context.read<AuthCubit>();
|
||
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<double> majorTicks;
|
||
|
||
const AnimatedCircularGauge({super.key, required this.value, required this.maxValue, required this.unit, required this.majorTicks});
|
||
|
||
@override
|
||
State<AnimatedCircularGauge> createState() => _AnimatedCircularGaugeState();
|
||
}
|
||
|
||
class _AnimatedCircularGaugeState extends State<AnimatedCircularGauge> with SingleTickerProviderStateMixin {
|
||
late AnimationController _animationController;
|
||
late Animation<double> _valueAnimation;
|
||
double _previousValue = 0.0;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800));
|
||
_previousValue = widget.value;
|
||
_valueAnimation = Tween<double>(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<double>(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<double> 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;
|
||
}
|