Files
flutterApp/lib/features/home/presentation/pages/running_status_page.dart

1430 lines
55 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart';
import 'package:syncfusion_flutter_gauges/gauges.dart';
import 'package:flutter/animation.dart';
// 占位导入(请替换为你项目的实际路径)
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/network/net_message_dispatcher.dart';
import '../../../../core/network/protocol_decoder.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../devices/presentation/bloc/device_status_event.dart';
import '../../../devices/presentation/bloc/device_status_state.dart';
// 配置:数据超时时间(5秒)
const int DATA_TIMEOUT_SECONDS = 5;
class RunningStatusPage extends StatefulWidget {
const RunningStatusPage({super.key});
@override
State<RunningStatusPage> createState() => _RunningStatusPageState();
}
class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindingObserver {
// 🔥 静态实例引用,用于生命周期回调
static _RunningStatusPageState? _instance;
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; // 标记是否数据超时
// 🔥 标记是否已从 bloc 缓存初始化过图表数据,防止 BlocBuilder 重复追加
bool _hasSeededFromCache = false;
@override
void initState() {
super.initState();
// 🔥 注册静态实例
_instance = this;
debugPrint('🚀 [RunningStatusPage] initState 被调用');
// 🔥 使用 DeviceStatusBloc
// context.read<DeviceStatusBloc>().add(DeviceStatusReset());
// 🔥 关键:立即启动超时计时器,如果5秒内没收到数据就显示"暂无数据"
_startDataTimeoutTimer();
// 🔥 新增:监听应用生命周期
WidgetsBinding.instance.addObserver(this);
// 🔥 关键修复:页面进入时立即用 bloc 当前缓存数据初始化图表
// 不用等下一次 TCP 推送才显示数据
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final blocState = context.read<DeviceStatusBloc>().state;
if (blocState is DeviceStatusUpdated) {
debugPrint('🚀 [RunningStatusPage] bloc已有缓存数据,立即初始化图表');
_hasSeededFromCache = true;
_appendChartData(blocState);
_startDataTimeoutTimer(); // 重置超时计时器
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
}
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
debugPrint('🔄 [RunningStatusPage] 应用恢复,强制刷新UI');
_forceRefresh();
} else if (state == AppLifecycleState.paused) {
debugPrint('⏸️ [RunningStatusPage] 应用进入后台,暂停超时计时器');
_dataTimeoutTimer?.cancel();
}
}
@override
void dispose() {
// 🔥 移除生命周期观察者
WidgetsBinding.instance.removeObserver(this);
_instance = null;
// 销毁计时器,防止内存泄漏
_dataTimeoutTimer?.cancel();
super.dispose();
}
// 启动/重置数据超时计时器
void _startDataTimeoutTimer() {
// 先取消现有计时器
_dataTimeoutTimer?.cancel();
// 启动新计时器:超过指定时间未收到数据则标记为超时
_dataTimeoutTimer = Timer(Duration(seconds: DATA_TIMEOUT_SECONDS), () {
if (mounted) {
setState(() {
_isDataTimeout = true;
// 超时后清空历史图表数据(可选)
_resetChartData();
});
}
});
}
// 核心修复:数据点X轴坐标强制映射为0-5(对应6个标签),保证一一对应
List<FlSpot> _getMappedSpots(List<FlSpot> data) {
final List<FlSpot> mapped = [];
// 取最后6个数据
final List<FlSpot> limited = data.length > 6 ? data.sublist(data.length - 6) : List.from(data);
// 映射X轴坐标为0,1,2,3,4,5
for (int i = 0; i < limited.length; i++) {
mapped.add(FlSpot(i.toDouble(), limited[i].y));
}
// 不足6个时,补空值(X轴继续递增,y=0)
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 _refreshDeviceData() {
final deviceState = context.read<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
if (currentDevice != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).translate('running_status.data_refresh_success')), duration: const Duration(seconds: 1)));
// 刷新后重置超时状态
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
_startDataTimeoutTimer();
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).translate('running_status.no_device')), duration: const Duration(seconds: 1)));
}
}
void _appendChartData(DeviceStatusUpdated state) {
final status = state.status;
_leftTargetHistory.add(FlSpot(_timeIndex, status.leftTargetSpeed));
_rightTargetHistory.add(FlSpot(_timeIndex, status.rightTargetSpeed));
_leftMeasureHistory.add(FlSpot(_timeIndex, status.leftMeasureSpeed));
_rightMeasureHistory.add(FlSpot(_timeIndex, status.rightMeasureSpeed));
_leftCurrentHistory.add(FlSpot(_timeIndex, status.leftCurrent));
_rightCurrentHistory.add(FlSpot(_timeIndex, status.rightCurrent));
_leftTempHistory.add(FlSpot(_timeIndex, status.leftMotorTemp));
_rightTempHistory.add(FlSpot(_timeIndex, status.rightMotorTemp));
_knifeHistory.add(FlSpot(_timeIndex, double.tryParse(status.knifeCuttingSpeed) ?? 0));
_chipTempHistory.add(FlSpot(_timeIndex, status.chipTemp));
_voltageHistory.add(FlSpot(_timeIndex, status.voltage));
_timeIndex += 1;
_limit(_leftTargetHistory);
_limit(_rightTargetHistory);
_limit(_leftMeasureHistory);
_limit(_rightMeasureHistory);
_limit(_leftCurrentHistory);
_limit(_rightCurrentHistory);
_limit(_leftTempHistory);
_limit(_rightTempHistory);
_limit(_knifeHistory);
_limit(_chipTempHistory);
_limit(_voltageHistory);
}
Widget _gap({double height = 20}) {
return SizedBox(height: height);
}
// ====================== 图表卡片(统一样式) ======================
Widget _chartCard({required String title, required Widget child, 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 _compassGauge({required double yaw}) {
return SizedBox(
height: 200,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
painter: CompassGaugePainter(yaw: yaw),
size: const Size(240, 240),
),
Text(
"${yaw.toStringAsFixed(0)}°",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
),
],
),
);
}
// 新增:暂无数据占位组件
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 _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,
// 兼容版:控制刻度文字的内边距(替代axisOffset)
double leftTitlePadding = 8.0,
double bottomTitlePadding = 8.0,
}) {
// 确保0刻度被包含在刻度列表中
final List<double> yTicks = _calculateYTicks(yAxisMin, yAxisMax, yTickCount);
final List<double> finalYTicks = forceShowZeroTick
? (yTicks.contains(0) ? yTicks : [...yTicks, 0]
..sort())
: yTicks;
// 计算合法的刻度间隔(必须>0)
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) {
// 0刻度网格线加粗突出
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,
// 合法的间隔值(>0)
interval: yInterval,
getTitlesWidget: (value, meta) {
// 只显示目标刻度(包含0)
if (finalYTicks.any((tick) => (value - tick).abs() < 0.01)) {
// 用Padding控制刻度与图表的间距(兼容所有版本)
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, // 横轴固定间隔1(合法值)
// 预留刻度文字高度
reservedSize: 35 + bottomTitlePadding,
getTitlesWidget: (value, meta) {
int idx = value.toInt();
if (idx >= 0 && idx < bottomLabels.length) {
return Padding(
// 用Padding控制垂直间距,Transform避免最后一个标签截断
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: []),
),
),
),
),
],
);
}
// 刻度计算方法(确保0刻度被包含)
List<double> _calculateYTicks(double min, double max, int count) {
// 强制把最小值设为0(如果需要显示0刻度)
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)),
],
);
}
// 使用修复后的_getMappedSpots映射数据点
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),
);
}
// ====================== 图表视图 ======================
Widget _buildChartContentView(DeviceStatusState state) {
// 修改1:超时/无数据时显示暂无数据,而非loading
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(),
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_target_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_target_speed'),
lines: [_lineData(_leftTargetHistory, const Color(0xFF3F51B5)), _lineData(_rightTargetHistory, const Color(0xFF8BC34A))],
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.current_compare') + " (A)",
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.current_compare'),
lines: [_lineData(_leftCurrentHistory, const Color(0xFF3F51B5)), _lineData(_rightCurrentHistory, const Color(0xFF8BC34A))],
yAxisMax: 100,
yAxisMin: 0,
yTickCount: 6,
),
),
],
),
),
_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.motor_temp_compare') + " (°C)",
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.motor_temp_compare'),
lines: [_lineData(_leftTempHistory, const Color(0xFF3F51B5)), _lineData(_rightTempHistory, const Color(0xFF8BC34A))],
yAxisMax: 100,
yAxisMin: 0,
yTickCount: 6,
),
),
],
),
),
_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.heading_angle_chart') + " (°)",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1677FF)),
),
const SizedBox(height: 16),
_compassGauge(yaw: status.yaw),
],
),
),
_gap(),
],
),
);
}
// ====================== 卡片视图 ======================
Widget _buildCardContentView(DeviceStatusState state) {
// 修改2:卡片视图同样替换loading为暂无数据
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,
),
),
),
);
}
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, // 文字行高
),
),
);
}
// 辅助方法:根据定位质量值返回翻译键
String _getLocationQualityKey(int qualValue) {
switch (qualValue) {
case 0:
return 'running_status.invalid';
case 1:
return 'common.unknown'; // GPS单点定位 - 使用通用未知
case 2:
return 'common.unknown'; // DGPS - 使用通用未知
case 4:
return 'running_status.valid'; // RTK固定解 - 视为有效
case 5:
return 'running_status.valid'; // RTK浮点解 - 视为有效
default:
return 'common.unknown';
}
}
// 辅助方法:根据控制模式值返回翻译键
String _getControlModeKey(int modeValue) {
switch (modeValue) {
case 3:
return 'running_status.remote_control';
default:
return 'running_status.local_control';
}
}
@override
Widget build(BuildContext context) {
final deviceState = context.read<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
if (currentDevice == null) {
// 修改3:无设备时也显示暂无数据
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _buildOptimizedAppBar(), // 使用优化后的AppBar
body: _noDataWidget(),
);
}
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _buildOptimizedAppBar(), // 替换原有的SliverAppBar为优化后的AppBar
body: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
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();
// 收到新数据,重置超时计时器和状态
_startDataTimeoutTimer();
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
} else if (!_isDataTimeout && state is DeviceStatusError) {
qual = '-';
satelliteCnt = '-';
headingStatus = '-';
}
return SliverToBoxAdapter(
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 3,
child: Text(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,
),
),
],
),
),
);
},
),
SliverToBoxAdapter(
child: 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),
),
],
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1677FF),
foregroundColor: Colors.white,
elevation: 2,
shadowColor: const Color(0xFF1677FF).withOpacity(0.3),
shape: const CircleBorder(),
// 👇 圆形必须用对称内边距
padding: const EdgeInsets.all(12),
//padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
),
onPressed: _refreshDeviceData,
child: const Icon(Icons.refresh_rounded, size: 18),
),
],
),
),
),
SliverToBoxAdapter(
child: BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
debugPrint('🎨 [UI-Build] BlocBuilder 重建!当前状态类型:${state.runtimeType}');
// 检测到设备切换(状态重置)时,清空图表历史数据
if (state is DeviceStatusInitial) {
debugPrint('🧹 [UI] 检测到 Initial 状态,准备重置图表数据');
WidgetsBinding.instance.addPostFrameCallback((_) {
_resetChartData();
});
}
if (!_isDataTimeout && state is DeviceStatusUpdated) {
debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据');
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
if (_hasSeededFromCache) {
_hasSeededFromCache = false; // 只跳过第一次
debugPrint('📈 [UI] 跳过首次 BlocBuilder 追加(已由 initState 缓存初始化)');
} else {
_appendChartData(state);
}
// 🔥 关键:收到数据后立即重置超时计时器
_startDataTimeoutTimer();
// 如果之前是超时状态,现在恢复
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
}
return Container(margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state));
},
),
),
],
),
);
}
// 修改4:优化后的AppBar样式
AppBar _buildOptimizedAppBar() {
return AppBar(
backgroundColor: const Color(0xFF1677FF),
elevation: 4, // 增加阴影,提升层次感
centerTitle: true,
title: Text(
AppLocalizations.of(context).translate('running_status.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5, // 增加字间距,提升可读性
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white, size: 20), // 替换为更精致的ios风格返回图标
onPressed: () => Navigator.pop(context),
splashRadius: 24, // 优化点击反馈范围
),
// 新增:AppBar底部阴影优化
shadowColor: Colors.black12,
);
}
// 新增:清空图表历史数据
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;
}
// 🔥 新增:强制刷新UI(用于息屏恢复)
void _forceRefresh() {
if (mounted) {
debugPrint('🔄 [RunningStatusPage] 执行强制刷新');
final tcpClient = sl<TcpClient>();
if (!tcpClient.isConnected) {
debugPrint('⚠️ [RunningStatusPage] TCP未连接,尝试重连');
_reconnectTcp();
} else {
setState(() {
_isDataTimeout = false;
});
_startDataTimeoutTimer();
debugPrint('✅ [RunningStatusPage] TCP已连接,发送心跳确认');
tcpClient.sendHeartbeat();
}
}
}
Future<void> _reconnectTcp() async {
try {
final authCubit = context.read<AuthCubit>();
await authCubit.reconnectAfterResume();
if (mounted) {
setState(() {
_isDataTimeout = false;
});
_startDataTimeoutTimer();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnected')),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('❌ [RunningStatusPage] TCP重连失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).translate('running_status.tcp_reconnect_failed')),
duration: const Duration(seconds: 2),
backgroundColor: Colors.red,
),
);
}
}
}
}
// ====================== 带动画的圆形仪表盘 Widget ======================
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 bgArcPaint = Paint()
..color = const Color(0xFFE3F2FD)
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
final bgRect = Rect.fromCircle(center: center, radius: radius);
canvas.drawArc(bgRect, 135 * math.pi / 180, 270 * math.pi / 180, false, bgArcPaint);
final progressRatio = math.min(value / maxValue, 1.0);
final progressPaint = Paint()
..color = const Color(0xFFBBDEFB)
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(bgRect, 135 * math.pi / 180, 270 * math.pi / 180 * progressRatio, false, progressPaint);
final tickPaint = Paint()..color = Colors.grey.shade400;
final majorTickPaint = Paint()
..color = Colors.grey.shade600
..strokeWidth = 2;
const totalTicks = 270;
for (int i = 0; i < totalTicks; i++) {
final angle = 135 * math.pi / 180 + (i * math.pi * 270 / 180) / totalTicks;
final tickStart = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 4);
final tickEnd = center + Offset(math.cos(angle), math.sin(angle)) * radius;
tickPaint.strokeWidth = 1;
canvas.drawLine(tickStart, tickEnd, tickPaint);
final tickValue = (i / totalTicks) * maxValue;
if (majorTicks.any((tick) => (tickValue - tick).abs() < 0.1)) {
final majorTickStart = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 8);
canvas.drawLine(majorTickStart, tickEnd, majorTickPaint);
final textValue = tickValue.toStringAsFixed(0);
final textPainter = TextPainter(
text: TextSpan(
text: textValue,
style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.w500),
),
textDirection: TextDirection.ltr,
)..layout();
final textOffset = center + Offset(math.cos(angle), math.sin(angle)) * (radius - 20);
final textX = textOffset.dx - textPainter.width / 2;
final textY = textOffset.dy - textPainter.height / 2;
textPainter.paint(canvas, Offset(textX, textY));
}
}
final needleAngle = 135 * math.pi / 180 + 270 * math.pi / 180 * progressRatio;
final needlePaint = Paint()
..color = Colors.grey.shade600
..style = PaintingStyle.fill;
final needlePath = Path()
..moveTo(center.dx + (radius - 10) * math.cos(needleAngle), center.dy + (radius - 10) * math.sin(needleAngle))
..lineTo(center.dx + 6 * math.cos(needleAngle - math.pi / 2), center.dy + 6 * math.sin(needleAngle - math.pi / 2))
..lineTo(center.dx + 6 * math.cos(needleAngle + math.pi / 2), center.dy + 6 * math.sin(needleAngle + math.pi / 2))
..close();
canvas.drawPath(needlePath, needlePaint);
final centerDotPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(center, 3, centerDotPaint);
}
@override
bool shouldRepaint(covariant CircularGaugePainter oldDelegate) {
return oldDelegate.value != value || oldDelegate.maxValue != maxValue || oldDelegate.majorTicks != majorTicks;
}
}
// ====================== 罗盘式航向角仪表盘绘制器 ======================
class CompassGaugePainter extends CustomPainter {
final double yaw;
CompassGaugePainter({required this.yaw});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 20;
// 绘制外圆
final outerPaint = Paint()
..color = const Color(0xFF1677FF)
..style = PaintingStyle.stroke
..strokeWidth = 10;
canvas.drawCircle(center, radius, outerPaint);
// 绘制刻度
final tickPaint = Paint()
..color = Colors.grey
..strokeWidth = 2;
for (int i = 0; i < 36; i++) {
// 修复1:刻度角度映射 - 让0°=北(N),180°=南(S),90°=东(E),270°=西(W)
final degree = i * 10;
final angle = (degree - 90) * math.pi / 180; // 0°(北)对应-90弧度,180°(南)对应90弧度
final x1 = center.dx + (radius - 10) * math.cos(angle);
final y1 = center.dy + (radius - 10) * math.sin(angle);
final x2 = center.dx + radius * math.cos(angle);
final y2 = center.dy + radius * math.sin(angle);
canvas.drawLine(Offset(x1, y1), Offset(x2, y2), tickPaint);
if (i % 9 == 0) {
// 绘制主刻度
final x3 = center.dx + (radius - 20) * math.cos(angle);
final y3 = center.dy + (radius - 20) * math.sin(angle);
canvas.drawLine(Offset(x1, y1), Offset(x3, y3), tickPaint..strokeWidth = 4);
// 绘制方向文字(N/E/S/W)和角度数值
String direction = "";
if (degree == 0)
direction = "N";
else if (degree == 90)
direction = "E";
else if (degree == 180)
direction = "S";
else if (degree == 270)
direction = "W";
// 绘制方向文字
if (direction.isNotEmpty) {
final textPainter = TextPainter(
text: TextSpan(
text: direction,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
),
textDirection: TextDirection.ltr,
)..layout();
final textX = center.dx + (radius - 40) * math.cos(angle) - textPainter.width / 2;
final textY = center.dy + (radius - 40) * math.sin(angle) - textPainter.height / 2;
textPainter.paint(canvas, Offset(textX, textY));
}
// 绘制角度数值(如90、180、270)
final textPainter = TextPainter(
text: TextSpan(
text: degree.toString(),
style: const TextStyle(fontSize: 12, color: Colors.black54),
),
textDirection: TextDirection.ltr,
)..layout();
final textX = center.dx + (radius - 60) * math.cos(angle) - textPainter.width / 2;
final textY = center.dy + (radius - 60) * math.sin(angle) - textPainter.height / 2;
textPainter.paint(canvas, Offset(textX, textY));
}
}
// 绘制中心角度文字(如180°)
final centerTextPainter = TextPainter(
text: TextSpan(
text: "${yaw.toStringAsFixed(0)}°",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
),
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
)..layout();
final centerTextX = center.dx - centerTextPainter.width / 2;
final centerTextY = center.dy - centerTextPainter.height / 2;
centerTextPainter.paint(canvas, Offset(centerTextX, centerTextY));
// ========== 核心修复:指针角度计算 ==========
// 修复2:指针角度映射 - 让yaw=180°时指向正南(S)
final needleDegree = yaw;
final needleAngle = (needleDegree - 90) * math.pi / 180; // 与刻度角度映射一致
// 指针绘制样式
final needlePaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
// 指针路径:粗端在中心,细端指向外部
final needlePath = Path()
..moveTo(center.dx - 8, center.dy) // 中心左侧8px
..lineTo(center.dx + 8, center.dy) // 中心右侧8px
..lineTo(center.dx + radius * 0.8 * math.cos(needleAngle), center.dy + radius * 0.8 * math.sin(needleAngle))
..close();
canvas.drawPath(needlePath, needlePaint);
// 中心圆点美化
final centerDotPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(center, 4, centerDotPaint);
final centerBorderPaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, 4, centerBorderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}