1046 lines
39 KiB
Dart
1046 lines
39 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:syncfusion_flutter_gauges/gauges.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 '../../../devices/presentation/bloc/devices_cubit.dart';
|
||
import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||
import '../../../devices/presentation/bloc/device_status_state.dart';
|
||
|
||
class RunningStatusPage extends StatefulWidget {
|
||
const RunningStatusPage({super.key});
|
||
|
||
@override
|
||
State<RunningStatusPage> createState() => _RunningStatusPageState();
|
||
}
|
||
|
||
class _RunningStatusPageState extends State<RunningStatusPage> {
|
||
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;
|
||
|
||
// 兼容低版本的 takeLast 方法
|
||
List<T> _takeLast<T>(List<T> list, int count) {
|
||
if (list.length <= count) {
|
||
return List.from(list);
|
||
}
|
||
return list.sublist(list.length - count);
|
||
}
|
||
|
||
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(const SnackBar(content: Text("数据刷新成功!"), duration: Duration(seconds: 1)));
|
||
} else {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("暂无设备,无法刷新"), duration: Duration(seconds: 1)));
|
||
}
|
||
}
|
||
|
||
String _parseLocationQuality(int locationValue) {
|
||
switch (locationValue) {
|
||
case 0:
|
||
return '无效';
|
||
case 1:
|
||
return 'GPS 单点定位';
|
||
case 2:
|
||
return 'DGPS 伪距差分或 SBAS';
|
||
case 4:
|
||
return 'RTK 固定解';
|
||
case 5:
|
||
return 'RTK 浮点解';
|
||
default:
|
||
return '未知($locationValue)';
|
||
}
|
||
}
|
||
|
||
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}) {
|
||
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: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
|
||
),
|
||
const SizedBox(height: 16),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ====================== 圆形仪表盘(匹配目标样式) ======================
|
||
Widget _circularGauge({
|
||
required double value,
|
||
required double maxValue,
|
||
required String unit,
|
||
required String label,
|
||
List<double> majorTicks = const [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
|
||
}) {
|
||
return SizedBox(
|
||
height: 220,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
CustomPaint(
|
||
painter: CircularGaugePainter(value: value, maxValue: maxValue, majorTicks: majorTicks),
|
||
size: const Size(280, 280),
|
||
),
|
||
Positioned(
|
||
bottom: 20,
|
||
child: Text(
|
||
"${value.toStringAsFixed(2)} $unit",
|
||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: Colors.grey),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ====================== 罗盘式航向角仪表盘 ======================
|
||
Widget _compassGauge({required double yaw}) {
|
||
return SizedBox(
|
||
height: 250,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
CustomPaint(
|
||
painter: CompassGaugePainter(yaw: yaw),
|
||
size: const Size(280, 280),
|
||
),
|
||
Text(
|
||
"${yaw.toStringAsFixed(0)}°",
|
||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ====================== 折线图(统一样式) ======================
|
||
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"],
|
||
List<double> leftTicks = const [0, 1000, 2000, 3000],
|
||
}) {
|
||
// 获取实际显示的点位数量
|
||
final int showSpotCount = lines.isNotEmpty ? lines.first.spots.length : 0;
|
||
// 计算底部标签的间隔,避免标签过多溢出
|
||
final double bottomInterval = showSpotCount > 6 ? showSpotCount / 6 : 1;
|
||
|
||
return Column(
|
||
children: [
|
||
if (lines.length > 1)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
_legendItem(color: const Color(0xFF3F51B5), label: "左轮"),
|
||
const SizedBox(width: 24),
|
||
_legendItem(color: const Color(0xFF8BC34A), label: "右轮"),
|
||
],
|
||
),
|
||
),
|
||
// 用Expanded适配宽度,避免溢出
|
||
Expanded(
|
||
child: SizedBox(
|
||
height: 200,
|
||
child: LineChart(
|
||
LineChartData(
|
||
minY: yAxisMin,
|
||
maxY: yAxisMax,
|
||
gridData: FlGridData(
|
||
show: true,
|
||
horizontalInterval: (yAxisMax - yAxisMin) / leftTicks.length,
|
||
getDrawingHorizontalLine: (value) => const FlLine(color: Color(0xFFE5E5E5), strokeWidth: 1),
|
||
drawVerticalLine: false,
|
||
),
|
||
titlesData: FlTitlesData(
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
interval: (yAxisMax - yAxisMin) / leftTicks.length,
|
||
getTitlesWidget: (value, meta) {
|
||
if (leftTicks.contains(value)) {
|
||
return Text(value.toStringAsFixed(0), style: const TextStyle(fontSize: 12, color: Colors.grey));
|
||
}
|
||
return const SizedBox.shrink();
|
||
},
|
||
// 限制左侧标签宽度
|
||
reservedSize: 40,
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
interval: bottomInterval,
|
||
// 限制底部标签高度和宽度
|
||
reservedSize: 30,
|
||
getTitlesWidget: (value, meta) {
|
||
int idx = value.toInt();
|
||
if (idx >= 0 && idx < bottomLabels.length) {
|
||
return Container(
|
||
width: 40,
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
bottomLabels[idx],
|
||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
);
|
||
}
|
||
return const SizedBox.shrink();
|
||
},
|
||
),
|
||
),
|
||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
),
|
||
borderData: FlBorderData(show: false),
|
||
lineBarsData: lines,
|
||
// 设置图表边距,避免内容贴边
|
||
minX: 0,
|
||
maxX: bottomLabels.length - 1.toDouble(),
|
||
extraLinesData: ExtraLinesData(horizontalLines: []),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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<FlSpot> data, Color color) {
|
||
return LineChartBarData(
|
||
spots: _takeLast(data, 6),
|
||
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) {
|
||
if (state is! DeviceStatusUpdated) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
final status = state.status;
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
children: [
|
||
// 电压仪表盘/折线图
|
||
_chartCard(
|
||
title: "电压 (V)",
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _voltageGaugeMode = true),
|
||
child: Text(
|
||
"仪表盘",
|
||
style: TextStyle(
|
||
color: _voltageGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: _voltageGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _voltageGaugeMode = false),
|
||
child: Text(
|
||
"折线图",
|
||
style: TextStyle(
|
||
color: !_voltageGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: !_voltageGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
_voltageGaugeMode
|
||
? _circularGauge(value: status.voltage, maxValue: 250, unit: "V", label: "电压", majorTicks: const [0, 50, 100, 150, 200, 250])
|
||
: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "电压",
|
||
lines: [_lineData(_voltageHistory, const Color(0xFF4CAF50))],
|
||
yAxisMax: 250,
|
||
leftTicks: const [0, 50, 100, 150, 200, 250],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_gap(),
|
||
|
||
// 芯片温度仪表盘/折线图
|
||
_chartCard(
|
||
title: "芯片温度 (°C)",
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _chipTempGaugeMode = true),
|
||
child: Text(
|
||
"仪表盘",
|
||
style: TextStyle(
|
||
color: _chipTempGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: _chipTempGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _chipTempGaugeMode = false),
|
||
child: Text(
|
||
"折线图",
|
||
style: TextStyle(
|
||
color: !_chipTempGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: !_chipTempGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
_chipTempGaugeMode
|
||
? _circularGauge(
|
||
value: status.chipTemp,
|
||
maxValue: 100,
|
||
unit: "°C",
|
||
label: "芯片温度",
|
||
majorTicks: const [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
|
||
)
|
||
: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "芯片温度",
|
||
lines: [_lineData(_chipTempHistory, const Color(0xFF4CAF50))],
|
||
yAxisMax: 100,
|
||
leftTicks: const [0, 20, 40, 60, 80, 100],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_gap(),
|
||
|
||
// 割刀速度仪表盘/折线图
|
||
_chartCard(
|
||
title: "割刀速度 (rpm)",
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _knifeSpeedGaugeMode = true),
|
||
child: Text(
|
||
"仪表盘",
|
||
style: TextStyle(
|
||
color: _knifeSpeedGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: _knifeSpeedGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _knifeSpeedGaugeMode = false),
|
||
child: Text(
|
||
"折线图",
|
||
style: TextStyle(
|
||
color: !_knifeSpeedGaugeMode ? const Color(0xFF1677FF) : Colors.grey,
|
||
fontSize: 14,
|
||
fontWeight: !_knifeSpeedGaugeMode ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
_knifeSpeedGaugeMode
|
||
? _circularGauge(
|
||
value: double.tryParse(status.knifeCuttingSpeed) ?? 0,
|
||
maxValue: 3000,
|
||
unit: "rpm",
|
||
label: "割刀速度",
|
||
majorTicks: const [0, 500, 1000, 1500, 2000, 2500, 3000],
|
||
)
|
||
: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "割刀速度",
|
||
lines: [_lineData(_knifeHistory, const Color(0xFF4CAF50))],
|
||
yAxisMax: 3000,
|
||
yAxisMin: -3000,
|
||
leftTicks: const [-3000, -2000, -1000, 0, 1000, 2000, 3000],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_gap(),
|
||
// 测量速度对比(左轮 + 右轮在一张图)
|
||
_chartCard(
|
||
title: "左右轮测量速度对比 (rpm)",
|
||
child: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "左右轮测量速度对比",
|
||
lines: [
|
||
_lineData(_leftMeasureHistory, const Color(0xFF3F51B5)), // 左轮
|
||
_lineData(_rightMeasureHistory, const Color(0xFF8BC34A)), // 右轮
|
||
],
|
||
yAxisMax: 3000,
|
||
yAxisMin: -3000,
|
||
leftTicks: const [-3000, -2000, -1000, 0, 1000, 2000, 3000],
|
||
),
|
||
),
|
||
),
|
||
|
||
_gap(),
|
||
// 目标速度对比(左轮 + 右轮在一张图)
|
||
_chartCard(
|
||
title: "左右轮目标速度对比 (rpm)",
|
||
child: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "左右轮目标速度对比",
|
||
lines: [
|
||
_lineData(_leftTargetHistory, const Color(0xFF3F51B5)), // 左轮
|
||
_lineData(_rightTargetHistory, const Color(0xFF8BC34A)), // 右轮
|
||
],
|
||
yAxisMax: 3000,
|
||
yAxisMin: -3000,
|
||
leftTicks: const [-3000, -2000, -1000, 0, 1000, 2000, 3000],
|
||
),
|
||
),
|
||
),
|
||
|
||
_gap(),
|
||
|
||
// 电流对比
|
||
_chartCard(
|
||
title: "电流对比 (A)",
|
||
child: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "电流对比",
|
||
lines: [_lineData(_leftCurrentHistory, const Color(0xFF3F51B5)), _lineData(_rightCurrentHistory, const Color(0xFF8BC34A))],
|
||
yAxisMax: 100,
|
||
leftTicks: const [0, 20, 40, 60, 80, 100],
|
||
),
|
||
),
|
||
),
|
||
_gap(),
|
||
|
||
// 电机温度对比
|
||
_chartCard(
|
||
title: "电机温度对比 (°C)",
|
||
child: SizedBox(
|
||
height: 200,
|
||
child: _styledLineChart(
|
||
title: "电机温度对比",
|
||
lines: [_lineData(_leftTempHistory, const Color(0xFF3F51B5)), _lineData(_rightTempHistory, const Color(0xFF8BC34A))],
|
||
yAxisMax: 100,
|
||
leftTicks: const [0, 20, 40, 60, 80, 100],
|
||
),
|
||
),
|
||
),
|
||
|
||
// 航向角仪表盘
|
||
_chartCard(
|
||
title: "航向角 (°)",
|
||
child: _compassGauge(yaw: status.yaw),
|
||
),
|
||
_gap(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ====================== 卡片视图 ======================
|
||
Widget _buildCardContentView(DeviceStatusState state) {
|
||
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 const Center(
|
||
child: Text("数据加载失败", style: TextStyle(color: Colors.red, fontSize: 16)),
|
||
);
|
||
} else {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
}
|
||
|
||
Widget _buildMotorTable(DeviceStatusUpdated state) {
|
||
final status = state.status;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Text("电机参数", style: TextStyle(fontSize: 18, 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("左轮", isHeader: true), _buildTableCell("右轮", isHeader: true)],
|
||
),
|
||
TableRow(
|
||
children: [
|
||
_buildTableCell("目标速度\n(rpm)"),
|
||
_buildTableCell(status.leftTargetSpeed.toStringAsFixed(2)),
|
||
_buildTableCell(status.rightTargetSpeed.toStringAsFixed(2)),
|
||
],
|
||
),
|
||
TableRow(
|
||
children: [
|
||
_buildTableCell("测量速度\n(rpm)"),
|
||
_buildTableCell(status.leftMeasureSpeed.toStringAsFixed(2)),
|
||
_buildTableCell(status.rightMeasureSpeed.toStringAsFixed(2)),
|
||
],
|
||
),
|
||
TableRow(
|
||
children: [
|
||
_buildTableCell("电流(A)"),
|
||
_buildTableCell(status.leftCurrent.toStringAsFixed(2)),
|
||
_buildTableCell(status.rightCurrent.toStringAsFixed(2)),
|
||
],
|
||
),
|
||
TableRow(
|
||
children: [
|
||
_buildTableCell("电机温度(°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: [
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Text("其他参数", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||
),
|
||
Table(
|
||
border: TableBorder.all(color: const Color(0xFFE5E5E5)),
|
||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||
children: [
|
||
TableRow(
|
||
decoration: const BoxDecoration(color: Color(0xFFF5F5F5)),
|
||
children: [_buildTableCell("名称", isHeader: true), _buildTableCell("数值", isHeader: true)],
|
||
),
|
||
TableRow(children: [_buildTableCell("俯仰角(°)"), _buildTableCell(status.pitch.toStringAsFixed(2))]),
|
||
TableRow(children: [_buildTableCell("翻滚角(°)"), _buildTableCell(status.roll.toStringAsFixed(2))]),
|
||
TableRow(children: [_buildTableCell("航向角(°)"), _buildTableCell(status.yaw.toStringAsFixed(2))]),
|
||
TableRow(children: [_buildTableCell("电量(%)"), _buildTableCell(status.battery)]),
|
||
TableRow(children: [_buildTableCell("芯片温度(°C)"), _buildTableCell(status.chipTemp.toStringAsFixed(2))]),
|
||
TableRow(children: [_buildTableCell("割刀速度(rpm)"), _buildTableCell(status.knifeCuttingSpeed)]),
|
||
TableRow(children: [_buildTableCell("经度(°)"), _buildTableCell(status.longitude.toStringAsFixed(6))]),
|
||
TableRow(children: [_buildTableCell("纬度(°)"), _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 Text(
|
||
title,
|
||
style: TextStyle(
|
||
color: isActive ? const Color(0xFF1677FF) : const Color(0xFF666666),
|
||
fontSize: 16,
|
||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||
decoration: isActive ? TextDecoration.underline : null,
|
||
decorationColor: const Color(0xFF1677FF),
|
||
decorationThickness: 2,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final deviceState = context.read<DevicesCubit>().state;
|
||
final currentDevice = deviceState.selectedDevice;
|
||
|
||
if (currentDevice == null) {
|
||
return const Scaffold(body: Center(child: Text("加载中...")));
|
||
}
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF7F7F7),
|
||
body: CustomScrollView(
|
||
physics: const BouncingScrollPhysics(),
|
||
slivers: [
|
||
SliverAppBar(
|
||
backgroundColor: const Color(0xFF1677FF),
|
||
pinned: true,
|
||
centerTitle: true,
|
||
elevation: 0,
|
||
title: const Text(
|
||
"设备数据监控",
|
||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||
),
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
),
|
||
|
||
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
|
||
builder: (context, state) {
|
||
String qual = '--';
|
||
String satelliteCnt = '--';
|
||
String headingStatus = "--";
|
||
|
||
if (state is DeviceStatusUpdated) {
|
||
headingStatus = state.status.headingStatus == 0 ? '未初始化' : '已初始化';
|
||
int qualValue = 0;
|
||
try {
|
||
qualValue = int.parse(state.status.qual.toString());
|
||
} catch (e) {
|
||
qualValue = 0;
|
||
}
|
||
qual = _parseLocationQuality(qualValue);
|
||
satelliteCnt = state.status.satelliteCnt.toString();
|
||
} else if (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("航向角状态:$headingStatus", style: const TextStyle(fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||
),
|
||
Expanded(
|
||
flex: 4,
|
||
child: Text(
|
||
"定位质量:$qual",
|
||
style: const TextStyle(fontSize: 12),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
Expanded(
|
||
flex: 2,
|
||
child: Text(
|
||
"卫星数:$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: 8),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _isCardView = true),
|
||
child: _buildTab("卡片", isActive: _isCardView),
|
||
),
|
||
const SizedBox(width: 24),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _isCardView = false),
|
||
child: _buildTab("图表", isActive: !_isCardView),
|
||
),
|
||
],
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF1677FF),
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
),
|
||
onPressed: _refreshDeviceData,
|
||
child: const Text("刷新数据", style: TextStyle(fontSize: 14)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
SliverToBoxAdapter(
|
||
child: BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
|
||
builder: (context, state) {
|
||
if (state is DeviceStatusUpdated) {
|
||
_appendChartData(state);
|
||
}
|
||
return Container(
|
||
margin: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||
child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ====================== 圆形仪表盘绘制器(匹配目标样式) ======================
|
||
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;
|
||
|
||
// 1. 绘制背景圆弧(浅蓝底色,270°范围:从135°到405°,对应左下到右下的半圆)
|
||
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);
|
||
|
||
// 2. 绘制进度圆弧(浅蓝色,根据数值比例绘制)
|
||
final progressRatio = math.min(value / maxValue, 1.0); // 限制最大为1
|
||
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);
|
||
|
||
// 3. 绘制刻度(主刻度+副刻度)
|
||
final tickPaint = Paint()..color = Colors.grey.shade400;
|
||
final majorTickPaint = Paint()
|
||
..color = Colors.grey.shade600
|
||
..strokeWidth = 2;
|
||
|
||
// 总刻度数(每1°一个副刻度,每10°一个主刻度)
|
||
const totalTicks = 270;
|
||
for (int i = 0; i < totalTicks; i++) {
|
||
// 计算刻度角度(从135°开始,到405°结束)
|
||
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);
|
||
|
||
// 主刻度(长刻度,匹配majorTicks的数值)
|
||
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: 14, 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));
|
||
}
|
||
}
|
||
|
||
// 4. 绘制指针
|
||
final needleAngle = 135 * math.pi / 180 + 270 * math.pi / 180 * progressRatio;
|
||
final needlePaint = Paint()
|
||
..color = Colors.grey.shade400
|
||
..style = PaintingStyle.fill;
|
||
|
||
// 指针形状(三角形)
|
||
final needlePath = Path()
|
||
..moveTo(center.dx, center.dy)
|
||
..lineTo(center.dx + (radius - 20) * math.cos(needleAngle - 0.02), center.dy + (radius - 20) * math.sin(needleAngle - 0.02))
|
||
..lineTo(center.dx + (radius - 20) * math.cos(needleAngle + 0.02), center.dy + (radius - 20) * math.sin(needleAngle + 0.02))
|
||
..close();
|
||
canvas.drawPath(needlePath, needlePaint);
|
||
|
||
// 5. 绘制指针中心圆点
|
||
final centerDotPaint = Paint()
|
||
..color = Colors.grey.shade400
|
||
..style = PaintingStyle.fill;
|
||
canvas.drawCircle(center, 6, centerDotPaint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CircularGaugePainter oldDelegate) {
|
||
return oldDelegate.value != value || oldDelegate.maxValue != maxValue;
|
||
}
|
||
}
|
||
|
||
// ====================== 罗盘式航向角仪表盘绘制器 ======================
|
||
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 = 12;
|
||
|
||
canvas.drawCircle(center, radius, outerPaint);
|
||
|
||
// 刻度
|
||
final tickPaint = Paint()
|
||
..color = Colors.grey
|
||
..strokeWidth = 2;
|
||
|
||
for (int i = 0; i < 36; i++) {
|
||
final angle = (i * 10 - 90) * math.pi / 180;
|
||
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);
|
||
|
||
// 主刻度(45°间隔)
|
||
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);
|
||
|
||
// 方向文字
|
||
final direction = i == 0
|
||
? "N"
|
||
: i == 9
|
||
? "E"
|
||
: i == 18
|
||
? "S"
|
||
: i == 27
|
||
? "W"
|
||
: "";
|
||
if (direction.isNotEmpty) {
|
||
final textPainter = TextPainter(
|
||
text: TextSpan(
|
||
text: direction,
|
||
style: const TextStyle(fontSize: 18, 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));
|
||
}
|
||
|
||
// 角度值
|
||
final angleValue = (i * 10).toString();
|
||
final textPainter = TextPainter(
|
||
text: TextSpan(
|
||
text: angleValue,
|
||
style: const TextStyle(fontSize: 14, 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));
|
||
}
|
||
}
|
||
|
||
// 指针
|
||
final needleAngle = (yaw - 90) * math.pi / 180;
|
||
final needlePaint = Paint()
|
||
..color = Colors.red
|
||
..style = PaintingStyle.fill;
|
||
|
||
final needlePath = Path()
|
||
..moveTo(center.dx, center.dy)
|
||
..lineTo(center.dx + radius * 0.7 * math.cos(needleAngle - 0.03), center.dy + radius * 0.7 * math.sin(needleAngle - 0.03))
|
||
..lineTo(center.dx + radius * 0.7 * math.cos(needleAngle + 0.03), center.dy + radius * 0.7 * math.sin(needleAngle + 0.03))
|
||
..close();
|
||
|
||
canvas.drawPath(needlePath, needlePaint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||
}
|