Files
feature-next-arch/lib/components/device_status_modal.dart

1379 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';
import '../core/logging/log_time.dart';
const int DATA_TIMEOUT_SECONDS = 15;
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();
debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据');
_startDataTimeoutTimer();
// 🔥 页面初始化时自动调用刷新
WidgetsBinding.instance.addPostFrameCallback((_) {
_onRefresh();
});
}
/// 🔥 刷新函数 - 与刷新按钮共享逻辑
void _onRefresh() {
context.read<DeviceStatusBloc>().add(DeviceStatusReset());
_resetChartData();
}
@override
void dispose() {
debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)');
_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 (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 (state is DeviceStatusUpdated) {
_startDataTimeoutTimer(); // 每次收到数据重置超时计时器
_isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止)
_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 (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 (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;
}
}