Files
flutterApp/lib/components/device_status_modal.dart
Songzex 71aea48d9f 集成接口 开始执行任务的领域层和数据层和UI层的开发啊(待测试)
集成功能 暂停 取消功能 恢复功能的接口的领域层和数据层的开发 下一步待集成到页面上
优化功能,优化了接口异常和未知异常对页面渲染的影响对用户的体验的不好情况。具体通过弹窗友好提示!
优化更新了tcp指示灯点击出现机器状态中添加选中设备的编号 方便用户使用的明白明了!。
优化更新关闭了tcp重连操作内链条中的获取用户设备和切换设备操作项。
调整了获取无人机状态信息的更新频率 为14秒一次
2026-06-16 17:34:00 +08:00

1378 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: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:syncfusion_flutter_gauges/gauges.dart';
import '../features/devices/presentation/bloc/device_status_bloc.dart';
import '../features/devices/presentation/bloc/device_status_state.dart';
import '../features/devices/presentation/bloc/device_status_event.dart';
import '../features/devices/presentation/bloc/devices_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_state.dart';
const int DATA_TIMEOUT_SECONDS = 5;
class DeviceStatusModal extends StatefulWidget {
const DeviceStatusModal({super.key});
@override
State<DeviceStatusModal> createState() => _DeviceStatusModalState();
}
class _DeviceStatusModalState extends State<DeviceStatusModal> {
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;
@override
void initState() {
super.initState();
_startDataTimeoutTimer();
// 🔥 页面初始化时自动调用刷新
WidgetsBinding.instance.addPostFrameCallback((_) {
_onRefresh();
});
}
/// 🔥 刷新函数 - 与刷新按钮共享逻辑
void _onRefresh() {
context.read<DeviceStatusBloc>().add(DeviceStatusReset());
_resetChartData();
}
@override
void dispose() {
_dataTimeoutTimer?.cancel();
super.dispose();
}
void _startDataTimeoutTimer() {
_dataTimeoutTimer?.cancel();
_dataTimeoutTimer = Timer(Duration(seconds: DATA_TIMEOUT_SECONDS), () {
if (mounted) {
setState(() {
_isDataTimeout = true;
_resetChartData();
});
}
});
}
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;
}
List<FlSpot> _getMappedSpots(List<FlSpot> data) {
final List<FlSpot> mapped = [];
final List<FlSpot> limited = data.length > 6
? data.sublist(data.length - 6)
: List.from(data);
for (int i = 0; i < limited.length; i++) {
mapped.add(FlSpot(i.toDouble(), limited[i].y));
}
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 _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 _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 _buildCardContentView(DeviceStatusState state) {
if (_isDataTimeout || 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,
),
),
),
);
}
String _getControlModeKey(int modeValue) {
switch (modeValue) {
case 3:
return 'running_status.remote_control';
default:
return 'running_status.local_control';
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
if (!_isDataTimeout && state is DeviceStatusUpdated) {
_startDataTimeoutTimer();
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
_appendChartData(state);
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: const BoxDecoration(
color: Color(0xFF1677FF),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(
context,
).translate('running_status.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
// 🔥 显示当前TCP认证的设备号
BlocBuilder<RemoteControlCubit, RemoteControlState>(
builder: (context, state) {
var deviceS =
state.targetDevice?.deviceName ?? '未选择';
return Text(
'设备: $deviceS',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
);
},
),
],
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Status Bar
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
String qual = _isDataTimeout ? '--' : '--';
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
if (!_isDataTimeout && 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();
}
return Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
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,
),
),
],
),
);
},
),
// Tab Switch
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,
),
),
],
),
IconButton(
icon: const Icon(Icons.refresh, color: Color(0xFF1677FF)),
onPressed: _onRefresh,
),
],
),
),
// Content
Expanded(
child: Container(
margin: const EdgeInsets.all(8),
child: _isCardView
? _buildCardContentView(state)
: _buildChartContentView(state),
),
),
],
),
);
},
);
}
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,
),
),
);
}
Widget _buildChartContentView(DeviceStatusState state) {
if (_isDataTimeout || 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(),
],
),
);
}
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 _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,
double leftTitlePadding = 8.0,
double bottomTitlePadding = 8.0,
}) {
final List<double> yTicks = _calculateYTicks(
yAxisMin,
yAxisMax,
yTickCount,
);
final List<double> finalYTicks = forceShowZeroTick
? (yTicks.contains(0) ? yTicks : [...yTicks, 0]
..sort())
: yTicks;
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) {
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,
interval: yInterval,
getTitlesWidget: (value, meta) {
if (finalYTicks.any(
(tick) => (value - tick).abs() < 0.01,
)) {
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,
reservedSize: 35 + bottomTitlePadding,
getTitlesWidget: (value, meta) {
int idx = value.toInt();
if (idx >= 0 && idx < bottomLabels.length) {
return Padding(
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: []),
),
),
),
),
],
);
}
List<double> _calculateYTicks(double min, double max, int count) {
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),
),
],
);
}
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),
);
}
String _getLocationQualityKey(int qualValue) {
switch (qualValue) {
case 0:
return 'running_status.invalid';
case 4:
case 5:
return 'running_status.valid';
default:
return 'common.unknown';
}
}
}
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 backgroundPaint = Paint()
..color = const Color(0xFFE5E5E5)
..style = PaintingStyle.stroke
..strokeWidth = 12;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi * 1.25,
math.pi * 2.5,
false,
backgroundPaint,
);
final progress = value / maxValue;
final progressPaint = Paint()
..color = const Color(0xFF1677FF)
..style = PaintingStyle.stroke
..strokeWidth = 12
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi * 1.25,
math.pi * 2.5 * progress,
false,
progressPaint,
);
final tickPaint = Paint()..color = const Color(0xFF888888);
final textStyle = const TextStyle(fontSize: 10, color: Color(0xFF888888));
final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (int i = 0; i < majorTicks.length; i++) {
final tickValue = majorTicks[i];
final tickProgress = tickValue / maxValue;
final angle = -math.pi * 1.25 + math.pi * 2.5 * tickProgress;
final innerRadius = radius - 20;
final outerRadius = radius - 8;
final start = Offset(
center.dx + innerRadius * math.cos(angle),
center.dy + innerRadius * math.sin(angle),
);
final end = Offset(
center.dx + outerRadius * math.cos(angle),
center.dy + outerRadius * math.sin(angle),
);
canvas.drawLine(start, end, tickPaint);
final labelRadius = radius - 35;
final labelOffset = Offset(
center.dx + labelRadius * math.cos(angle),
center.dy + labelRadius * math.sin(angle),
);
textPainter.text = TextSpan(text: tickValue.toString(), style: textStyle);
textPainter.layout();
textPainter.paint(
canvas,
Offset(
labelOffset.dx - textPainter.width / 2,
labelOffset.dy - textPainter.height / 2,
),
);
}
}
@override
bool shouldRepaint(covariant CircularGaugePainter oldDelegate) {
return value != oldDelegate.value || maxValue != oldDelegate.maxValue;
}
}