Files
tuiche/lib/pages/sleep_report/chart/SnoreChart.dart
2026-01-08 11:56:17 +08:00

765 lines
23 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:ui' as ui;
// //柱形图显示
// import 'package:flutter/material.dart';
// import 'package:intl/intl.dart';
// import 'package:vbvs_app/common/color/appConstants.dart';
// import 'package:vbvs_app/common/util/FitTool.dart';
// import 'package:vbvs_app/common/util/MyUtils.dart';
// class BarData {
// final int st; // 起始时间(毫秒)
// final int et; // 结束时间(毫秒)
// final double value; // 柱子高度
// final int id;
// final String name;
// final Color color;
// BarData({
// required this.st,
// required this.et,
// required this.value,
// required this.id,
// required this.name,
// required this.color,
// });
// }
// class BarChartWidget extends StatefulWidget {
// final List<BarData> data;
// final int startTime; // 毫秒时间戳
// final int endTime; // 毫秒时间戳
// final double maxYValue; // Y轴最大值
// final int yStepCount; // Y轴分段数
// const BarChartWidget({
// super.key,
// required this.data,
// required this.startTime,
// required this.endTime,
// required this.maxYValue,
// this.yStepCount = 5,
// });
// @override
// State<BarChartWidget> createState() => _BarChartWidgetState();
// }
// class _BarChartWidgetState extends State<BarChartWidget> {
// BarData? selectedBar;
// void _handleTapOrDrag(Offset localPosition, Size size) {
// final chartWidth = size.width - 30.rpx;
// final totalDuration = widget.endTime - widget.startTime;
// for (final d in widget.data) {
// final left =
// ((d.st - widget.startTime) / totalDuration) * chartWidth + 30.rpx;
// final right =
// ((d.et - widget.startTime) / totalDuration) * chartWidth + 30.rpx;
// if (localPosition.dx >= left && localPosition.dx <= right) {
// setState(() {
// selectedBar = d;
// });
// return;
// }
// }
// setState(() {
// selectedBar = null;
// });
// }
// @override
// Widget build(BuildContext context) {
// return LayoutBuilder(builder: (context, constraints) {
// return GestureDetector(
// behavior: HitTestBehavior.opaque,
// onPanDown: (details) =>
// _handleTapOrDrag(details.localPosition, constraints.biggest),
// onPanUpdate: (details) =>
// _handleTapOrDrag(details.localPosition, constraints.biggest),
// onTapDown: (details) =>
// _handleTapOrDrag(details.localPosition, constraints.biggest),
// child: CustomPaint(
// size: Size(constraints.maxWidth, 250.rpx), // 使用约束的最大宽度
// painter: BarChartPainter(
// widget.data,
// widget.startTime,
// widget.endTime,
// maxYValue: widget.maxYValue,
// yStepCount: widget.yStepCount,
// selectedBar: selectedBar,
// ),
// ),
// );
// });
// }
// }
// class BarChartPainter extends CustomPainter {
// final List<BarData> data;
// final int startTime;
// final int endTime;
// final double maxYValue;
// final int yStepCount;
// final BarData? selectedBar;
// final double topPadding = 0;
// final double bottomPadding = 0;
// final double leftPadding = 30.rpx;
// BarChartPainter(
// this.data,
// this.startTime,
// this.endTime, {
// required this.maxYValue,
// this.yStepCount = 5,
// this.selectedBar,
// });
// @override
// void paint(Canvas canvas, Size size) {
// final chartWidth = size.width - leftPadding;
// final chartHeight = size.height - topPadding - bottomPadding;
// final totalDuration = endTime - startTime;
// final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
// final stepValue = maxYValue / yStepCount;
// // Y轴刻度
// // for (int i = 0; i <= yStepCount; i++) {
// // final value = stepValue * i;
// // final y = topPadding + chartHeight - (value / maxYValue) * chartHeight;
// // final dashPaint = Paint()
// // ..color = Colors.grey.withOpacity(0.4)
// // ..strokeWidth = 1.rpx;
// // drawDashedLine(
// // canvas, Offset(leftPadding, y), Offset(size.width, y), dashPaint);
// // textPainter.text = TextSpan(
// // text: value.toStringAsFixed(0),
// // style: TextStyle(
// // fontSize: 18.rpx,
// // color: themeController.currentColor.sc4,
// // ),
// // );
// // textPainter.layout();
// // textPainter.paint(
// // canvas,
// // Offset(leftPadding - textPainter.width - 4, y - textPainter.height / 2),
// // );
// // }
// // Y轴刻度
// for (int i = 0; i <= yStepCount; i++) {
// final value = stepValue * i;
// final y = topPadding + chartHeight - (value / maxYValue) * chartHeight;
// // 判断是否是基线i == 0
// final bool isBaseline = i == 0;
// if (isBaseline) {
// // 基线画实线
// final baselinePaint = Paint()
// ..color = Colors.grey.withOpacity(0.6)
// ..strokeWidth = 1.rpx
// ..style = PaintingStyle.stroke;
// canvas.drawLine(
// Offset(leftPadding, y),
// Offset(size.width, y),
// baselinePaint,
// );
// } else {
// // 其他刻度画虚线
// final dashPaint = Paint()
// ..color = Colors.grey.withOpacity(0.4)
// ..strokeWidth = 1.rpx;
// drawDashedLine(
// canvas,
// Offset(leftPadding, y),
// Offset(size.width, y),
// dashPaint,
// );
// }
// // 绘制刻度文字
// textPainter.text = TextSpan(
// text: value.toStringAsFixed(0),
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// );
// textPainter.layout();
// textPainter.paint(
// canvas,
// Offset(leftPadding - textPainter.width - 4, y - textPainter.height / 2),
// );
// }
// // X轴刻度 - 参考横线图的24小时制
// final startDate = DateTime.fromMillisecondsSinceEpoch(startTime);
// final endDate = DateTime.fromMillisecondsSinceEpoch(endTime);
// final xAxisY = topPadding + chartHeight; // 这是最底部的Y坐标
// // 计算总小时数
// final totalHours = endDate.difference(startDate).inHours + 1;
// final startHour = startDate.hour;
// // 绘制X轴主线实线
// // final xAxisPaint = Paint()
// // ..color = Colors.grey.withOpacity(0.4)
// // ..strokeWidth = 1.rpx;
// // canvas.drawLine(
// // Offset(leftPadding, xAxisY),
// // Offset(size.width, xAxisY),
// // xAxisPaint,
// // );
// // 绘制左右两侧时间标签HH:mm格式
// final leftLabel = DateFormat('HH:mm').format(startDate);
// textPainter.text = TextSpan(
// text: leftLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// );
// textPainter.layout();
// textPainter.paint(
// canvas, Offset(leftPadding - textPainter.width / 2, xAxisY + 8.rpx));
// final rightLabel = DateFormat('HH:mm').format(endDate);
// textPainter.text = TextSpan(
// text: rightLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// );
// textPainter.layout();
// textPainter.paint(
// canvas, Offset(size.width - textPainter.width / 2, xAxisY + 8.rpx));
// // 绘制中间小时刻度(只显示小时数字)
// for (int i = 1; i < totalHours; i++) {
// final double x = leftPadding + chartWidth * i / totalHours;
// int hourLabelNum = (startHour + i) % 24;
// final hourLabel = '$hourLabelNum';
// textPainter.text = TextSpan(
// text: hourLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// );
// textPainter.layout();
// textPainter.paint(
// canvas, Offset(x - textPainter.width / 2, xAxisY + 8.rpx));
// }
// // 柱子绘制 & 提示信息缓存
// Offset? tipOffset;
// Size? tipSize;
// String? tipText;
// Color tipColor = Colors.black;
// for (final d in data) {
// final left =
// ((d.st - startTime) / totalDuration) * chartWidth + leftPadding;
// final right =
// ((d.et - startTime) / totalDuration) * chartWidth + leftPadding;
// final barHeight = (d.value / maxYValue) * chartHeight;
// final top = topPadding + chartHeight - barHeight;
// final barPaint = Paint()..color = d.color;
// canvas.drawRect(
// Rect.fromLTRB(left, top, right, topPadding + chartHeight),
// barPaint,
// );
// // 缓存 tip 信息
// if (selectedBar == d) {
// tipText =
// '${d.name}\n${d.value.toStringAsFixed(1)}次\n${MyUtils.formatToHHmm(d.st)}';
// final tp = TextPainter(
// text: TextSpan(
// text: tipText,
// style: TextStyle(fontSize: 16.rpx, color: Colors.white),
// ),
// textAlign: TextAlign.center,
// textDirection: ui.TextDirection.ltr,
// );
// tp.layout();
// final tipWidth = tp.width + 20.rpx;
// final tipHeight = tp.height + 10.rpx;
// final tipLeft = left + (right - left) / 2 - tipWidth / 2;
// final tipTop = top - tipHeight - 10.rpx;
// // 确保tip不会超出画布顶部
// final double adjustedTipTop = tipTop < 0 ? 0.0 : tipTop;
// tipOffset = Offset(tipLeft, adjustedTipTop);
// tipSize = Size(tipWidth, tipHeight);
// tipColor = Colors.black.withOpacity(0.8);
// }
// }
// // 绘制 tip在柱子之上
// if (tipText != null && tipOffset != null && tipSize != null) {
// final rect = RRect.fromRectAndRadius(
// Rect.fromLTWH(
// tipOffset.dx, tipOffset.dy, tipSize.width, tipSize.height),
// Radius.circular(8.rpx),
// );
// final tipBgPaint = Paint()..color = tipColor;
// canvas.drawRRect(rect, tipBgPaint);
// final tipTextPainter = TextPainter(
// text: TextSpan(
// text: tipText,
// style: TextStyle(fontSize: 16.rpx, color: Colors.white),
// ),
// textAlign: TextAlign.center,
// textDirection: ui.TextDirection.ltr,
// );
// tipTextPainter.layout();
// tipTextPainter.paint(
// canvas,
// Offset(tipOffset.dx + 10.rpx, tipOffset.dy + 5.rpx),
// );
// }
// }
// @override
// bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
// void drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint,
// {double dashWidth = 5, double dashSpace = 3}) {
// double totalLength = (end.dx - start.dx).abs();
// double dashCount = (totalLength / (dashWidth + dashSpace)).floorToDouble();
// double dx = start.dx;
// final dy = start.dy;
// for (int i = 0; i < dashCount; i++) {
// final from = Offset(dx, dy);
// final to = Offset(dx + dashWidth, dy);
// canvas.drawLine(from, to, paint);
// dx += dashWidth + dashSpace;
// }
// }
// }
import 'dart:ui' as ui;
//柱形图显示
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:vbvs_app/common/color/appConstants.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
class BarData {
final int st; // 起始时间(毫秒)
final int et; // 结束时间(毫秒)
final double value; // 柱子高度
final int id;
final String name;
final Color color;
BarData({
required this.st,
required this.et,
required this.value,
required this.id,
required this.name,
required this.color,
});
}
class BarChartWidget extends StatefulWidget {
final List<BarData> data;
final int startTime; // 毫秒时间戳
final int endTime; // 毫秒时间戳
final double maxYValue; // Y轴最大值
final int yStepCount; // Y轴分段数
const BarChartWidget({
super.key,
required this.data,
required this.startTime,
required this.endTime,
required this.maxYValue,
this.yStepCount = 5,
});
@override
State<BarChartWidget> createState() => _BarChartWidgetState();
}
class _BarChartWidgetState extends State<BarChartWidget> {
BarData? selectedBar;
void _handleTapOrDrag(Offset localPosition, Size size) {
final chartWidth = size.width - 30.rpx;
final totalDuration = widget.endTime - widget.startTime;
for (final d in widget.data) {
final left =
((d.st - widget.startTime) / totalDuration) * chartWidth + 30.rpx;
final right =
((d.et - widget.startTime) / totalDuration) * chartWidth + 30.rpx;
if (localPosition.dx >= left && localPosition.dx <= right) {
setState(() {
selectedBar = d;
});
return;
}
}
setState(() {
selectedBar = null;
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (details) =>
_handleTapOrDrag(details.localPosition, constraints.biggest),
onPanUpdate: (details) =>
_handleTapOrDrag(details.localPosition, constraints.biggest),
onTapDown: (details) =>
_handleTapOrDrag(details.localPosition, constraints.biggest),
child: CustomPaint(
size: Size(constraints.maxWidth, 250.rpx), // 使用约束的最大宽度
painter: BarChartPainter(
widget.data,
widget.startTime,
widget.endTime,
maxYValue: widget.maxYValue,
yStepCount: widget.yStepCount,
selectedBar: selectedBar,
),
),
);
});
}
}
class BarChartPainter extends CustomPainter {
final List<BarData> data;
final int startTime;
final int endTime;
final double maxYValue;
final int yStepCount;
final BarData? selectedBar;
final double topPadding = 0;
final double bottomPadding = 0;
final double leftPadding = 30.rpx;
BarChartPainter(
this.data,
this.startTime,
this.endTime, {
required this.maxYValue,
this.yStepCount = 5,
this.selectedBar,
});
@override
void paint(Canvas canvas, Size size) {
final chartWidth = size.width - leftPadding;
final chartHeight = size.height - topPadding - bottomPadding;
final totalDuration = endTime - startTime;
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
final stepValue = maxYValue / yStepCount;
// Y轴刻度
for (int i = 0; i <= yStepCount; i++) {
final value = stepValue * i;
final y = topPadding + chartHeight - (value / maxYValue) * chartHeight;
// 判断是否是基线i == 0
final bool isBaseline = i == 0;
if (isBaseline) {
// 基线画实线
final baselinePaint = Paint()
..color = Colors.grey.withOpacity(0.6)
..strokeWidth = 1.rpx
..style = PaintingStyle.stroke;
canvas.drawLine(
Offset(leftPadding, y),
Offset(size.width, y),
baselinePaint,
);
} else {
// 其他刻度画虚线
final dashPaint = Paint()
..color = Colors.grey.withOpacity(0.4)
..strokeWidth = 1.rpx;
drawDashedLine(
canvas,
Offset(leftPadding, y),
Offset(size.width, y),
dashPaint,
);
}
// 绘制刻度文字
textPainter.text = TextSpan(
text: value.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(leftPadding - textPainter.width - 4, y - textPainter.height / 2),
);
}
// X轴刻度 - 基于实际时间而不是均匀分布
final startDate = DateTime.fromMillisecondsSinceEpoch(startTime);
final endDate = DateTime.fromMillisecondsSinceEpoch(endTime);
final xAxisY = topPadding + chartHeight; // 这是最底部的Y坐标
// 计算总小时数
final int hourMs = 60 * 60 * 1000;
final int totalHours = (endTime - startTime) ~/ hourMs;
// 绘制左右两侧时间标签HH:mm格式
final leftLabel = DateFormat('HH:mm').format(startDate);
textPainter.text = TextSpan(
text: leftLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
textPainter.layout();
textPainter.paint(
canvas, Offset(leftPadding - textPainter.width / 2, xAxisY + 8.rpx));
final rightLabel = DateFormat('HH:mm').format(endDate);
textPainter.text = TextSpan(
text: rightLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
textPainter.layout();
textPainter.paint(
canvas, Offset(size.width - textPainter.width / 2, xAxisY + 8.rpx));
// 绘制中间小时刻度 - 参考SnoreChartContainer的逻辑
if (totalHours <= 8) {
// 显示每个整点
DateTime currentHour = DateTime(
startDate.year,
startDate.month,
startDate.day,
startDate.hour,
);
// 如果起始时间不是整点,从下一个整点开始
if (startDate.minute > 0 ||
startDate.second > 0 ||
startDate.millisecond > 0) {
currentHour = currentHour.add(Duration(hours: 1));
}
while (currentHour.millisecondsSinceEpoch < endTime) {
final int timeMs = currentHour.millisecondsSinceEpoch;
if (timeMs > startTime && timeMs < endTime) {
// 检查是否太靠近边界
if (timeMs - startTime < 10 * 60 * 1000 ||
endTime - timeMs < 10 * 60 * 1000) {
currentHour = currentHour.add(Duration(hours: 1));
continue;
}
// 计算从起始时间到当前整点的分钟数
final double x =
leftPadding + ((timeMs - startTime) / totalDuration) * chartWidth;
final hourLabel = '${currentHour.hour}';
textPainter.text = TextSpan(
text: hourLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, xAxisY + 8.rpx),
);
}
currentHour = currentHour.add(Duration(hours: 1));
}
} else {
// 超过8小时跳着显示
final int labelInterval = (totalHours / 6).ceil(); // 分成大约6个标签
DateTime firstLabelHour = DateTime(
startDate.year,
startDate.month,
startDate.day,
startDate.hour + (labelInterval - (startDate.hour % labelInterval)),
0,
0,
0,
0,
);
// 确保第一个标签在开始时间之后
if (firstLabelHour.millisecondsSinceEpoch <= startTime) {
firstLabelHour = firstLabelHour.add(Duration(hours: labelInterval));
}
DateTime currentHour = firstLabelHour;
while (currentHour.millisecondsSinceEpoch < endTime) {
final int timeMs = currentHour.millisecondsSinceEpoch;
// 确保标签离边界足够远
if (timeMs - startTime >= hourMs && endTime - timeMs >= hourMs) {
final double x =
leftPadding + ((timeMs - startTime) / totalDuration) * chartWidth;
final hourLabel = '${currentHour.hour}';
textPainter.text = TextSpan(
text: hourLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, xAxisY + 8.rpx),
);
}
currentHour = currentHour.add(Duration(hours: labelInterval));
}
}
// 柱子绘制 & 提示信息缓存
Offset? tipOffset;
Size? tipSize;
String? tipText;
Color tipColor = Colors.black;
for (final d in data) {
final left =
((d.st - startTime) / totalDuration) * chartWidth + leftPadding;
final right =
((d.et - startTime) / totalDuration) * chartWidth + leftPadding;
final barHeight = (d.value / maxYValue) * chartHeight;
final top = topPadding + chartHeight - barHeight;
final barPaint = Paint()..color = d.color;
canvas.drawRect(
Rect.fromLTRB(left, top, right, topPadding + chartHeight),
barPaint,
);
// 缓存 tip 信息
if (selectedBar == d) {
tipText =
'${d.name}\n${d.value.toStringAsFixed(1)}\n${MyUtils.formatToHHmm(d.st)}';
final tp = TextPainter(
text: TextSpan(
text: tipText,
style: TextStyle(fontSize: 16.rpx, color: Colors.white),
),
textAlign: TextAlign.center,
textDirection: ui.TextDirection.ltr,
);
tp.layout();
final tipWidth = tp.width + 20.rpx;
final tipHeight = tp.height + 10.rpx;
final tipLeft = left + (right - left) / 2 - tipWidth / 2;
final tipTop = top - tipHeight - 10.rpx;
// 确保tip不会超出画布顶部
final double adjustedTipTop = tipTop < 0 ? 0.0 : tipTop;
tipOffset = Offset(tipLeft, adjustedTipTop);
tipSize = Size(tipWidth, tipHeight);
tipColor = Colors.black.withOpacity(0.8);
}
}
// 绘制 tip在柱子之上
if (tipText != null && tipOffset != null && tipSize != null) {
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(
tipOffset.dx, tipOffset.dy, tipSize.width, tipSize.height),
Radius.circular(8.rpx),
);
final tipBgPaint = Paint()..color = tipColor;
canvas.drawRRect(rect, tipBgPaint);
final tipTextPainter = TextPainter(
text: TextSpan(
text: tipText,
style: TextStyle(fontSize: 16.rpx, color: Colors.white),
),
textAlign: TextAlign.center,
textDirection: ui.TextDirection.ltr,
);
tipTextPainter.layout();
tipTextPainter.paint(
canvas,
Offset(tipOffset.dx + 10.rpx, tipOffset.dy + 5.rpx),
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
void drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint,
{double dashWidth = 5, double dashSpace = 3}) {
double totalLength = (end.dx - start.dx).abs();
double dashCount = (totalLength / (dashWidth + dashSpace)).floorToDouble();
double dx = start.dx;
final dy = start.dy;
for (int i = 0; i < dashCount; i++) {
final from = Offset(dx, dy);
final to = Offset(dx + dashWidth, dy);
canvas.drawLine(from, to, paint);
dx += dashWidth + dashSpace;
}
}
}