Files
feature-next-arch/lib/features/home/presentation/pages/running_status_page.dart
2026-03-04 21:21:43 +08:00

1183 lines
44 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 '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 '../../../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;
// 核心修复:数据点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(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, 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 _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: "左轮"),
const SizedBox(width: 24),
_legendItem(color: const Color(0xFF8BC34A), label: "右轮"),
],
),
),
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) {
if (state is! DeviceStatusUpdated) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF1677FF), // 统一使用项目主题蓝色
strokeWidth: 4, // 加粗加载圈,视觉更清晰
),
);
}
final status = state.status;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_chartCard(
title: "电压 (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: "电压",
lines: [_lineData(_voltageHistory, const Color(0xFF4CAF50))],
yAxisMax: 250,
yAxisMin: 0,
yTickCount: 6,
),
),
),
_gap(),
_chartCard(
title: "芯片温度 (°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: "芯片温度",
lines: [_lineData(_chipTempHistory, const Color(0xFF4CAF50))],
yAxisMax: 100,
yAxisMin: 0,
yTickCount: 6,
),
),
),
_gap(),
_chartCard(
title: "割刀速度 (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: "割刀速度",
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(
"左右轮测量速度对比 (rpm)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: _styledLineChart(
title: "左右轮测量速度对比",
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(
"左右轮目标速度对比 (rpm)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: _styledLineChart(
title: "左右轮目标速度对比",
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(
"电流对比 (A)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: _styledLineChart(
title: "电流对比",
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(
"电机温度对比 (°C)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: _styledLineChart(
title: "电机温度对比",
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(
"航向角 (°)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
_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(
color: Color(0xFF1677FF), // 统一使用项目主题蓝色
strokeWidth: 4, // 加粗加载圈,视觉更清晰
),
);
}
}
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: 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("左轮", 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: 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("名称", 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(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state));
},
),
),
],
),
);
}
}
// ====================== 带动画的圆形仪表盘 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;
}