Files
flutterApp/lib/components/device_status_modal.dart
Songzex 058a5d9588 对接路径规划的列表(根据场站的下的)
优化无人机机场的监控视频的显示视频流畅度提升80%
添加首页tcp指示灯的机器状态信息展示功能。(暂不支持在此页面上设备的切换显示)
2026-06-09 14:39:55 +08:00

1348 lines
42 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: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';
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();
}
@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: [
Text(
AppLocalizations.of(
context,
).translate('running_status.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
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: () {
context.read<DeviceStatusBloc>().add(
DeviceStatusReset(),
);
_resetChartData();
},
),
],
),
),
// 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;
}
}