2026-06-09 14:39:55 +08:00
|
|
|
|
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';
|
2026-06-16 17:34:00 +08:00
|
|
|
|
import 'package:get_it/get_it.dart';
|
2026-06-09 14:39:55 +08:00
|
|
|
|
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';
|
2026-06-16 17:34:00 +08:00
|
|
|
|
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';
|
2026-09-01 08:59:20 +08:00
|
|
|
|
import '../core/logging/log_time.dart';
|
2026-06-09 14:39:55 +08:00
|
|
|
|
|
2026-08-07 08:49:29 +08:00
|
|
|
|
const int DATA_TIMEOUT_SECONDS = 15;
|
2026-06-09 14:39:55 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-09-01 08:59:20 +08:00
|
|
|
|
debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据');
|
2026-06-09 14:39:55 +08:00
|
|
|
|
_startDataTimeoutTimer();
|
2026-06-16 17:34:00 +08:00
|
|
|
|
// 🔥 页面初始化时自动调用刷新
|
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
|
_onRefresh();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 🔥 刷新函数 - 与刷新按钮共享逻辑
|
|
|
|
|
|
void _onRefresh() {
|
|
|
|
|
|
context.read<DeviceStatusBloc>().add(DeviceStatusReset());
|
|
|
|
|
|
_resetChartData();
|
2026-06-09 14:39:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-09-01 08:59:20 +08:00
|
|
|
|
debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)');
|
2026-06-09 14:39:55 +08:00
|
|
|
|
_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) {
|
2026-08-07 08:49:29 +08:00
|
|
|
|
if (state is DeviceStatusInitial) {
|
2026-06-09 14:39:55 +08:00
|
|
|
|
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)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
|
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.measure_speed') +
|
|
|
|
|
|
"\n(rpm)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
|
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.current') +
|
|
|
|
|
|
"(A)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
|
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.motor_temp') +
|
|
|
|
|
|
"(°C)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
|
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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') +
|
|
|
|
|
|
"(°)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.roll_angle') +
|
|
|
|
|
|
"(°)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
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') +
|
|
|
|
|
|
"(%)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.chip_temp') +
|
|
|
|
|
|
"(°C)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.knife_speed') +
|
|
|
|
|
|
"(rpm)",
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
TableRow(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_buildTableCell(
|
|
|
|
|
|
AppLocalizations.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).translate('running_status.control_mode'),
|
|
|
|
|
|
),
|
2026-09-02 16:58:45 +08:00
|
|
|
|
_buildTableCell('--'),
|
2026-06-09 14:39:55 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
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) {
|
2026-08-07 08:49:29 +08:00
|
|
|
|
if (state is DeviceStatusUpdated) {
|
|
|
|
|
|
_startDataTimeoutTimer(); // 每次收到数据重置超时计时器
|
|
|
|
|
|
_isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止)
|
2026-06-09 14:39:55 +08:00
|
|
|
|
_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: [
|
2026-06-16 17:34:00 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-06-09 14:39:55 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
IconButton(
|
|
|
|
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Status Bar
|
|
|
|
|
|
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
|
|
|
|
|
|
builder: (context, state) {
|
2026-09-02 16:58:45 +08:00
|
|
|
|
// 🔥 headingStatus/satelliteCnt 来自 realtime/post(尚未推送)
|
|
|
|
|
|
String satelliteCnt = '--';
|
|
|
|
|
|
String headingStatus = '--';
|
|
|
|
|
|
// 🔥 定位质量来自 location/post 的 fix_status
|
|
|
|
|
|
String qual = '--';
|
2026-08-07 08:49:29 +08:00
|
|
|
|
if (state is DeviceStatusUpdated) {
|
2026-09-02 16:58:45 +08:00
|
|
|
|
qual = _getLocationQualityText(state.status.qual);
|
2026-06-09 14:39:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)),
|
2026-06-16 17:34:00 +08:00
|
|
|
|
onPressed: _onRefresh,
|
2026-06-09 14:39:55 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// 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) {
|
2026-08-07 08:49:29 +08:00
|
|
|
|
if (state is DeviceStatusInitial) {
|
2026-06-09 14:39:55 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 16:58:45 +08:00
|
|
|
|
/// 🔥 fix_status 枚举翻译(来自 MQTT location/post)
|
|
|
|
|
|
String _getLocationQualityText(int qualValue) {
|
2026-06-09 14:39:55 +08:00
|
|
|
|
switch (qualValue) {
|
2026-09-02 16:58:45 +08:00
|
|
|
|
case 0: return '无定位';
|
|
|
|
|
|
case 1: return '单点定位';
|
|
|
|
|
|
case 2: return '差分定位';
|
|
|
|
|
|
case 3: return '惯性导航';
|
|
|
|
|
|
case 4: return 'RTK固定解';
|
|
|
|
|
|
case 5: return 'RTK浮点解';
|
|
|
|
|
|
case 6: return '单点(无航向)';
|
|
|
|
|
|
case 7: return '差分(无航向)';
|
|
|
|
|
|
case 8: return 'RTK固定(无航向)';
|
|
|
|
|
|
case 9: return 'RTK浮点(无航向)';
|
|
|
|
|
|
default: return '--';
|
2026-06-09 14:39:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|