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

1035 lines
38 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 '../../../../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 _largeArcGauge({
required double value,
required double maxValue,
required String unit,
required String label,
List<double> majorTicks = const [0, 25, 50, 75, 100, 125, 150, 175, 200, 225, 250],
}) {
return SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
painter: LargeArcGaugePainter(value: value / maxValue, maxValue: maxValue, majorTicks: majorTicks),
size: const Size(300, 180),
),
Positioned(
bottom: 10,
child: Text(
"${value.toStringAsFixed(2)} $unit",
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, 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
? _largeArcGauge(
value: status.voltage,
maxValue: 250,
unit: "V",
label: "电压",
majorTicks: const [0, 25, 50, 75, 100, 125, 150, 175, 200, 225, 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
? _largeArcGauge(
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
? _largeArcGauge(
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 LargeArcGaugePainter extends CustomPainter {
final double value;
final double maxValue;
final List<double> majorTicks;
LargeArcGaugePainter({required this.value, required this.maxValue, required this.majorTicks});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height);
final radius = size.width / 2 - 20;
// 背景弧(大半个圆,从 -135° 到 135°)
final bgPaint = Paint()
..color = const Color(0xFFE3F2FD)
..style = PaintingStyle.stroke
..strokeWidth = 20
..strokeCap = StrokeCap.round;
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -135 * math.pi / 180, 270 * math.pi / 180, false, bgPaint);
// 进度弧
final progressPaint = Paint()
..color = const Color(0xFFBBDEFB)
..style = PaintingStyle.stroke
..strokeWidth = 20
..strokeCap = StrokeCap.round;
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -135 * math.pi / 180, 270 * math.pi / 180 * value, false, progressPaint);
// 刻度
final tickPaint = Paint()
..color = Colors.black54
..strokeWidth = 2;
for (int i = 0; i <= 45; i++) {
final angle = -135 * math.pi / 180 + (270 * math.pi / 180) * (i / 45);
final x1 = center.dx + (radius - 15) * math.cos(angle);
final y1 = center.dy + (radius - 15) * 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 (majorTicks.contains(i * (maxValue / 45))) {
final x3 = center.dx + (radius - 25) * math.cos(angle);
final y3 = center.dy + (radius - 25) * math.sin(angle);
canvas.drawLine(Offset(x1, y1), Offset(x3, y3), tickPaint..strokeWidth = 4);
// 刻度值
final textValue = (i * (maxValue / 45)).toStringAsFixed(0);
final textPainter = TextPainter(
text: TextSpan(
text: textValue,
style: const TextStyle(fontSize: 16, color: Colors.black54),
),
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 needleAngle = -135 * math.pi / 180 + 270 * math.pi / 180 * value;
final needlePaint = Paint()
..color = const Color(0xFF90A4AE)
..style = PaintingStyle.fill;
final needlePath = Path()
..moveTo(center.dx, center.dy)
..lineTo(center.dx + radius * 0.8 * math.cos(needleAngle - 0.03), center.dy + radius * 0.8 * math.sin(needleAngle - 0.03))
..lineTo(center.dx + radius * 0.8 * math.cos(needleAngle + 0.03), center.dy + radius * 0.8 * math.sin(needleAngle + 0.03))
..close();
canvas.drawPath(needlePath, needlePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// ====================== 罗盘式航向角仪表盘绘制器 ======================
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;
}