Files
tuiche/lib/pages/sleep_report/chart/SnoreWaveform.dart
2026-01-07 15:19:16 +08:00

1228 lines
36 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:flutterflow_ui/flutterflow_ui.dart';
// import 'package:vbvs_app/common/util/FitTool.dart';
// import 'package:vbvs_app/common/util/MyUtils.dart';
// import 'package:intl/intl.dart';
// class SnoreChartContainer extends StatelessWidget {
// final List<dynamic> snoreValues;
// final List<dynamic> barData;
// final List<dynamic> showLabel;
// final int startTime;
// final int endTime;
// const SnoreChartContainer({
// required this.snoreValues,
// required this.barData,
// required this.showLabel,
// required this.startTime,
// required this.endTime,
// super.key,
// });
// @override
// Widget build(BuildContext context) {
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// SnoreBarOverlay(
// barData: barData,
// showLabel: showLabel,
// startTime: startTime,
// endTime: endTime,
// ),
// Container(height: 32.rpx),
// Container(
// height: 23.rpx,
// child: SnoreWaveform(
// snoreValues: snoreValues,
// startTime: startTime,
// endTime: endTime,
// ),
// ),
// ],
// );
// }
// }
// class SnoreBarOverlay extends StatelessWidget {
// final List<dynamic> barData;
// final List<dynamic> showLabel;
// final int startTime;
// final int endTime;
// const SnoreBarOverlay({
// required this.barData,
// required this.showLabel,
// required this.startTime,
// required this.endTime,
// super.key,
// });
// @override
// Widget build(BuildContext context) {
// const double barHeight = 50;
// return SizedBox(
// height: barHeight,
// child: CustomPaint(
// size: Size(double.infinity, barHeight),
// painter: SnoreBarPainter(
// barData: barData,
// showLabel: showLabel,
// startTime: startTime,
// endTime: endTime,
// ),
// ),
// );
// }
// }
// class SnoreBarPainter extends CustomPainter {
// final List<dynamic> barData;
// final List<dynamic> showLabel;
// final int startTime;
// final int endTime;
// SnoreBarPainter({
// required this.barData,
// required this.showLabel,
// required this.startTime,
// required this.endTime,
// });
// @override
// void paint(Canvas canvas, Size size) {
// final double width = size.width;
// final double height = size.height;
// final double pixelPerMs = width / (endTime - startTime);
// for (var item in barData) {
// final int st = item['st'];
// final int et = item['et'];
// final int type = item['type'];
// int heightInit = 1;
// final match = showLabel.firstWhere(
// (e) => e['type'] == type,
// orElse: () => null,
// );
// Color barColor = Colors.transparent;
// if (match != null) {
// final dynamic colorStr = match['color'];
// if (colorStr != null && colorStr.toString().isNotEmpty) {
// barColor = stringToColor(colorStr);
// }
// }
// final Paint barPaint = Paint()
// ..color = barColor
// ..style = PaintingStyle.fill;
// final double leftX = (st - startTime) * pixelPerMs;
// final double rightX = (et - startTime) * pixelPerMs;
// final double barWidth = rightX - leftX;
// //rem 深睡 中
// //浅睡 低
// //其他 高
// if (type == 1) {
// heightInit = 1;
// } else if (type == 2 || type == 6) {
// heightInit = 2;
// } else {
// heightInit = 3;
// }
// final double barHeight = (heightInit + 5).toDouble() * 8;
// final double top = height - barHeight;
// final rect = Rect.fromLTWH(leftX, top, barWidth, barHeight);
// canvas.drawRect(rect, barPaint);
// }
// }
// @override
// bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
// }
// class SnoreWaveform extends StatelessWidget {
// final List<dynamic> snoreValues;
// final int startTime;
// final int endTime;
// const SnoreWaveform({
// required this.snoreValues,
// required this.startTime,
// required this.endTime,
// super.key,
// });
// @override
// Widget build(BuildContext context) {
// return SizedBox(
// height: 150,
// child: LayoutBuilder(
// builder: (context, constraints) {
// return CustomPaint(
// size: Size(constraints.maxWidth, constraints.maxHeight),
// painter: SnoreWaveformPainter(
// snoreValues: snoreValues,
// startTime: startTime,
// endTime: endTime,
// ),
// );
// },
// ),
// );
// }
// }
// class SnoreWaveformPainter extends CustomPainter {
// final List<dynamic> snoreValues;
// final int startTime;
// final int endTime;
// SnoreWaveformPainter({
// required this.snoreValues,
// required this.startTime,
// required this.endTime,
// });
// @override
// void paint(Canvas canvas, Size size) {
// final double width = size.width;
// final double height = size.height;
// if (width <= 0 || height <= 0) return;
// final double totalDuration = (endTime - startTime).toDouble();
// if (totalDuration <= 0) return;
// final double pixelPerMs = width / totalDuration;
// // 过滤在时间范围内的有效事件
// final validEvents = snoreValues.where((e) {
// final int st = e['st'];
// final int et = e['et'];
// // 事件与时间范围有重叠
// return !(et <= startTime || st >= endTime);
// }).toList();
// // 计算中心线位置
// final double centerY = height / 2;
// // 统一使用一个颜色(打鼾颜色)
// final Color snoreColor = stringToColor("#8E7DEF").withOpacity(0.8);
// final Paint barPaint = Paint()
// ..color = snoreColor
// ..style = PaintingStyle.fill;
// final Paint borderPaint = Paint()
// ..color = snoreColor.withOpacity(0.9)
// ..style = PaintingStyle.stroke
// ..strokeWidth = 0.5;
// // 固定高度(上下对称)
// final double fixedBarHeight = height * 0.3; // 固定为画布高度的30%
// // 绘制每个打鼾事件(上下对称的柱状图)
// for (final event in validEvents) {
// final int st = event['st'];
// final int et = event['et'];
// // 计算绘制位置(裁剪到可视范围内)
// final double startX = (st - startTime) * pixelPerMs;
// final double endX = (et - startTime) * pixelPerMs;
// // 确保在画布范围内
// if (endX < 0 || startX > width) continue;
// final double drawStartX = startX.clamp(0, width);
// final double drawEndX = endX.clamp(0, width);
// final double drawWidth = drawEndX - drawStartX;
// if (drawWidth <= 0) continue;
// // 绘制上方的柱状图
// final double topBarTop = centerY - fixedBarHeight;
// final Rect topRect =
// Rect.fromLTWH(drawStartX, topBarTop, drawWidth, fixedBarHeight);
// canvas.drawRect(topRect, barPaint);
// canvas.drawRect(topRect, borderPaint);
// // 绘制下方的柱状图(对称)
// final double bottomBarTop = centerY;
// final Rect bottomRect =
// Rect.fromLTWH(drawStartX, bottomBarTop, drawWidth, fixedBarHeight);
// canvas.drawRect(bottomRect, barPaint);
// canvas.drawRect(bottomRect, borderPaint);
// }
// // 绘制中心线
// final Paint axisPaint = Paint()
// ..color = Colors.grey.withOpacity(0.3)
// ..strokeWidth = 0.5;
// canvas.drawLine(Offset(0, centerY), Offset(width, centerY), axisPaint);
// // 绘制时间轴标签
// final textPainter = TextPainter(
// textAlign: TextAlign.center,
// textDirection: ui.TextDirection.ltr,
// );
// final int hourMs = 60 * 60 * 1000;
// final int totalHours = (endTime - startTime) ~/ hourMs;
// // 创建开始和结束时间的DateTime对象
// final DateTime startDt = DateTime.fromMillisecondsSinceEpoch(startTime);
// final DateTime endDt = DateTime.fromMillisecondsSinceEpoch(endTime);
// // 1. 始终显示开始时间
// String label = DateFormat('HH:mm').format(startDt);
// textPainter.text = TextSpan(
// text: label,
// style: TextStyle(fontSize: 10, color: Colors.grey),
// );
// textPainter.layout();
// textPainter.paint(
// canvas,
// Offset(0 - textPainter.width / 2, height + 2),
// );
// // 2. 决定显示策略
// if (totalHours <= 8) {
// // 小时间段:显示所有整点小时(基于实际时间)
// DateTime currentHour = DateTime(
// startDt.year,
// startDt.month,
// startDt.day,
// startDt.hour + 1, // 从下一个整点开始
// 0,
// 0,
// 0,
// 0);
// // 如果开始时间本身就是整点,需要调整
// if (startDt.minute == 0 &&
// startDt.second == 0 &&
// startDt.millisecond == 0) {
// currentHour = startDt;
// }
// while (currentHour.millisecondsSinceEpoch < endTime) {
// int timeMs = currentHour.millisecondsSinceEpoch;
// // 确保时间在范围内
// if (timeMs > startTime && timeMs < endTime) {
// double x = (timeMs - startTime) * pixelPerMs;
// // 跳过太接近边界的时间点30分钟内不显示
// if (timeMs - startTime < 10 * 60 * 1000 ||
// endTime - timeMs < 10 * 60 * 1000) {
// currentHour = currentHour.add(Duration(hours: 1));
// continue;
// }
// label = "${currentHour.hour}";
// textPainter.text = TextSpan(
// text: label,
// style: TextStyle(fontSize: 10, color: Colors.grey),
// );
// textPainter.layout();
// textPainter.paint(
// canvas,
// Offset(x - textPainter.width / 2, height + 2),
// );
// }
// currentHour = currentHour.add(Duration(hours: 1));
// }
// } else {
// // 长时间段:使用自适应间隔
// int labelInterval = (totalHours / 6).ceil();
// // 计算第一个整点标签(对齐整点小时)
// DateTime firstLabelHour = DateTime(
// startDt.year,
// startDt.month,
// startDt.day,
// startDt.hour + (labelInterval - (startDt.hour % labelInterval)),
// 0,
// 0,
// 0,
// 0);
// // 如果第一个标签在开始时间之前,调整到下一个间隔
// if (firstLabelHour.millisecondsSinceEpoch <= startTime) {
// firstLabelHour = firstLabelHour.add(Duration(hours: labelInterval));
// }
// // 绘制中间标签
// DateTime currentHour = firstLabelHour;
// while (currentHour.millisecondsSinceEpoch < endTime) {
// int timeMs = currentHour.millisecondsSinceEpoch;
// // 跳过太接近边界的时间点1小时内不显示
// if (timeMs - startTime >= hourMs && endTime - timeMs >= hourMs) {
// double x = (timeMs - startTime) * pixelPerMs;
// label = "${currentHour.hour}";
// textPainter.text = TextSpan(
// text: label,
// style: TextStyle(fontSize: 10, color: Colors.grey),
// );
// textPainter.layout();
// textPainter.paint(
// canvas,
// Offset(x - textPainter.width / 2, height + 2),
// );
// }
// currentHour = currentHour.add(Duration(hours: labelInterval));
// }
// }
// // 3. 始终显示结束时间
// label = DateFormat('HH:mm').format(endDt);
// textPainter.text = TextSpan(
// text: label,
// style: TextStyle(fontSize: 10, color: Colors.grey),
// );
// textPainter.layout();
// textPainter.paint(
// canvas,
// Offset(width - textPainter.width / 2, height + 2),
// );
// }
// @override
// bool shouldRepaint(covariant SnoreWaveformPainter oldDelegate) {
// return oldDelegate.snoreValues != snoreValues ||
// oldDelegate.startTime != startTime ||
// oldDelegate.endTime != endTime;
// }
// }
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:ef/ef.dart';
import 'package:flutter/material.dart';
import 'package:flutterflow_ui/flutterflow_ui.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
import 'package:intl/intl.dart';
class SnoreChartContainer extends StatefulWidget {
final List<dynamic> snoreValues;
final List<dynamic> barData;
final List<dynamic> showLabel;
final int startTime;
final int endTime;
const SnoreChartContainer({
required this.snoreValues,
required this.barData,
required this.showLabel,
required this.startTime,
required this.endTime,
super.key,
});
@override
_SnoreChartContainerState createState() => _SnoreChartContainerState();
}
class _SnoreChartContainerState extends State<SnoreChartContainer> {
Offset? _hoverPosition;
dynamic _selectedData;
bool _isSelected = false;
int? _selectedStartTime;
Rect? _selectedRect;
void _clearSelection() {
setState(() {
_hoverPosition = null;
_selectedData = null;
_isSelected = false;
_selectedStartTime = null;
_selectedRect = null;
});
}
void _handleClick(Offset localPosition, Size size) {
final double pixelPerMs = size.width / (widget.endTime - widget.startTime);
final int clickTime =
widget.startTime + (localPosition.dx / pixelPerMs).round();
// 如果点击的是已经选中的区域,则取消选择
if (_isSelected && _selectedStartTime == clickTime) {
_clearSelection();
return;
}
// 查找匹配的数据
dynamic foundData;
Rect? foundRect;
// 首先在barData中查找睡眠阶段
for (var item in widget.barData) {
final int st = item['st'];
final int et = item['et'];
if (clickTime >= st && clickTime <= et) {
foundData = {
'type': 'sleep_stage',
'data': item,
'label': _getSleepStageLabel(item['type']),
'color': _getSleepStageColor(item['type']),
};
// 计算选中区域的位置
final double leftX = (st - widget.startTime) * pixelPerMs;
final double rightX = (et - widget.startTime) * pixelPerMs;
foundRect = Rect.fromLTWH(
leftX,
0,
rightX - leftX,
size.height,
);
break;
}
}
// 如果在barData中没找到再在snoreValues中查找
if (foundData == null) {
for (var item in widget.snoreValues) {
final int st = item['st'];
final int et = item['et'];
if (clickTime >= st && clickTime <= et) {
foundData = {
'type': 'snore_event',
'data': item,
'label': '打鼾事件',
'color': stringToColor("#8E7DEF"),
};
// 计算选中区域的位置
final double leftX = (st - widget.startTime) * pixelPerMs;
final double rightX = (et - widget.startTime) * pixelPerMs;
foundRect = Rect.fromLTWH(
leftX,
0,
rightX - leftX,
size.height,
);
break;
}
}
}
if (foundData != null) {
setState(() {
_hoverPosition = localPosition;
_selectedData = foundData;
_isSelected = true;
_selectedStartTime = clickTime;
_selectedRect = foundRect;
});
} else {
// 点击空白区域,清除选择
_clearSelection();
}
}
void _handleHover(Offset localPosition, Size size) {
if (_isSelected) return; // 如果已经有选中项,不处理悬停
final double pixelPerMs = size.width / (widget.endTime - widget.startTime);
final int hoverTime =
widget.startTime + (localPosition.dx / pixelPerMs).round();
// 查找匹配的数据
dynamic foundData;
// 首先在barData中查找睡眠阶段
for (var item in widget.barData) {
final int st = item['st'];
final int et = item['et'];
if (hoverTime >= st && hoverTime <= et) {
foundData = {
'type': 'sleep_stage',
'data': item,
'label': _getSleepStageLabel(item['type']),
'color': _getSleepStageColor(item['type']),
};
break;
}
}
// 如果在barData中没找到再在snoreValues中查找
if (foundData == null) {
for (var item in widget.snoreValues) {
final int st = item['st'];
final int et = item['et'];
if (hoverTime >= st && hoverTime <= et) {
foundData = {
'type': 'snore_event',
'data': item,
'label': '打鼾事件',
'color': stringToColor("#8E7DEF"),
};
break;
}
}
}
if (foundData != null) {
setState(() {
_hoverPosition = localPosition;
_selectedData = foundData;
});
} else {
setState(() {
_hoverPosition = null;
_selectedData = null;
});
}
}
void _handleHoverExit() {
if (!_isSelected) {
setState(() {
_hoverPosition = null;
_selectedData = null;
});
}
}
String _getSleepStageLabel(int type) {
for (var label in widget.showLabel) {
if (label['type'] == type) {
return label['name']?.toString() ?? '未知';
}
}
return '未知';
}
Color _getSleepStageColor(int type) {
for (var label in widget.showLabel) {
if (label['type'] == type) {
final dynamic colorStr = label['color'];
if (colorStr != null && colorStr.toString().isNotEmpty) {
return stringToColor(colorStr.toString());
}
}
}
return Colors.grey;
}
@override
Widget build(BuildContext context) {
return Container(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) {
final size = context.size ?? Size.zero;
if (size.width > 0 && size.height > 0) {
final localPosition = details.localPosition;
_handleClick(localPosition, size);
}
},
child: MouseRegion(
onHover: (details) {
if (!_isSelected) {
final size = context.size ?? Size.zero;
if (size.width > 0 && size.height > 0) {
final localPosition = details.localPosition;
_handleHover(localPosition, size);
}
}
},
onExit: (_) {
_handleHoverExit();
},
child: Stack(
clipBehavior: Clip.none,
children: [
// 主要图表内容
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SnoreBarOverlay(
barData: widget.barData,
showLabel: widget.showLabel,
startTime: widget.startTime,
endTime: widget.endTime,
selectedStartTime: _isSelected ? _selectedStartTime : null,
),
Container(height: 32.rpx),
Container(
height: 23.rpx,
child: SnoreWaveform(
snoreValues: widget.snoreValues,
startTime: widget.startTime,
endTime: widget.endTime,
selectedStartTime:
_isSelected ? _selectedStartTime : null,
),
),
],
),
// 高亮层
if (_selectedData != null && _selectedRect != null)
Positioned.fill(
child: CustomPaint(
painter: HoverHighlightPainter(
selectedRect: _selectedRect!,
isSelected: _isSelected,
),
),
),
// 提示框
if (_selectedData != null && _hoverPosition != null)
_buildTooltipWidget(_selectedData!, _hoverPosition!),
],
),
),
),
);
}
Widget _buildTooltipWidget(dynamic data, Offset hoverPosition) {
return Positioned(
left: hoverPosition.dx < 50 ? hoverPosition.dx : hoverPosition.dx - 100,
top: hoverPosition.dy - 100,
child: IgnorePointer(
child: _buildTooltipContent(data),
),
);
}
Widget _buildTooltipContent(dynamic data) {
final int st = data['data']['st'];
final int et = data['data']['et'];
final DateTime startTime = DateTime.fromMillisecondsSinceEpoch(st);
final DateTime endTime = DateTime.fromMillisecondsSinceEpoch(et);
final String duration = _formatDuration(et - st);
return Container(
// width: 200.rpx,
padding: EdgeInsets.all(16.rpx), // ✅ 容器内部边距,给文字留空间
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(20.rpx),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 8,
offset: Offset(0, 4),
),
],
// border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: data['color'],
shape: BoxShape.circle,
),
),
SizedBox(width: 8),
Text(
data['label'],
style: TextStyle(
color: data['color'],
fontSize: 20.rpx,
),
),
],
),
SizedBox(height: 8),
Text(
"开始".tr + '${DateFormat('HH:mm:ss').format(startTime)}',
style: TextStyle(
color: Color(0XFFFFFFFF), // 多了一个 F建议改成正确格式
fontSize: 20.rpx,
),
),
Text(
"结束".tr + '${DateFormat('HH:mm:ss').format(endTime)}',
style: TextStyle(
color: Color(0XFFFFFFFF), // 多了一个 F建议改成正确格式
fontSize: 20.rpx,
),
),
Text(
"时长".tr + '$duration',
style: TextStyle(
color: Color(0XFFFFFFFF), // 多了一个 F建议改成正确格式
fontSize: 20.rpx,
),
),
if (data['type'] == 'snore_event')
Text(
'打鼾强度: ${data['data']['value']?.toString() ?? '--'}',
style: TextStyle(
color: Color(0XFFFFFFFF), // 多了一个 F建议改成正确格式
fontSize: 20.rpx,
),
),
],
),
);
}
String _formatDuration(int milliseconds) {
final seconds = milliseconds ~/ 1000;
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes}${remainingSeconds}';
}
}
class SnoreBarOverlay extends StatelessWidget {
final List<dynamic> barData;
final List<dynamic> showLabel;
final int startTime;
final int endTime;
final int? selectedStartTime;
const SnoreBarOverlay({
required this.barData,
required this.showLabel,
required this.startTime,
required this.endTime,
this.selectedStartTime,
super.key,
});
@override
Widget build(BuildContext context) {
const double barHeight = 50;
return SizedBox(
height: barHeight,
child: CustomPaint(
size: Size(double.infinity, barHeight),
painter: SnoreBarPainter(
barData: barData,
showLabel: showLabel,
startTime: startTime,
endTime: endTime,
selectedStartTime: selectedStartTime,
),
),
);
}
}
class SnoreBarPainter extends CustomPainter {
final List<dynamic> barData;
final List<dynamic> showLabel;
final int startTime;
final int endTime;
final int? selectedStartTime;
SnoreBarPainter({
required this.barData,
required this.showLabel,
required this.startTime,
required this.endTime,
this.selectedStartTime,
});
Color _getSleepStageColor(int type) {
for (var label in showLabel) {
if (label['type'] == type) {
final dynamic colorStr = label['color'];
if (colorStr != null && colorStr.toString().isNotEmpty) {
return stringToColor(colorStr.toString());
}
}
}
return Colors.transparent;
}
@override
void paint(Canvas canvas, Size size) {
final double width = size.width;
final double height = size.height;
final double pixelPerMs = width / (endTime - startTime);
for (var item in barData) {
final int st = item['st'];
final int et = item['et'];
final int type = item['type'];
int heightInit = 1;
Color barColor = _getSleepStageColor(type);
bool isSelected = selectedStartTime != null &&
selectedStartTime! >= st &&
selectedStartTime! <= et;
if (isSelected) {
barColor = barColor.withOpacity(0.9);
}
final Paint barPaint = Paint()
..color = barColor
..style = PaintingStyle.fill;
final double leftX = (st - startTime) * pixelPerMs;
final double rightX = (et - startTime) * pixelPerMs;
final double barWidth = math.max(rightX - leftX, 1.0);
if (type == 1) {
heightInit = 1;
} else if (type == 2 || type == 6) {
heightInit = 2;
} else {
heightInit = 3;
}
final double barHeight = (heightInit + 5).toDouble() * 8;
final double top = height - barHeight;
final rect = Rect.fromLTWH(leftX, top, barWidth, barHeight);
canvas.drawRect(rect, barPaint);
if (isSelected) {
final Paint borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeJoin = StrokeJoin.round;
canvas.drawRect(rect, borderPaint);
final Paint innerGlowPaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..maskFilter = MaskFilter.blur(BlurStyle.normal, 3.0);
canvas.drawRect(rect, innerGlowPaint);
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! SnoreBarPainter ||
oldDelegate.barData != barData ||
oldDelegate.selectedStartTime != selectedStartTime;
}
}
class SnoreWaveform extends StatelessWidget {
final List<dynamic> snoreValues;
final int startTime;
final int endTime;
final int? selectedStartTime;
const SnoreWaveform({
required this.snoreValues,
required this.startTime,
required this.endTime,
this.selectedStartTime,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 150,
child: LayoutBuilder(
builder: (context, constraints) {
return CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: SnoreWaveformPainter(
snoreValues: snoreValues,
startTime: startTime,
endTime: endTime,
selectedStartTime: selectedStartTime,
),
);
},
),
);
}
}
class SnoreWaveformPainter extends CustomPainter {
final List<dynamic> snoreValues;
final int startTime;
final int endTime;
final int? selectedStartTime;
SnoreWaveformPainter({
required this.snoreValues,
required this.startTime,
required this.endTime,
this.selectedStartTime,
});
@override
void paint(Canvas canvas, Size size) {
final double width = size.width;
final double height = size.height;
if (width <= 0 || height <= 0) return;
final double totalDuration = (endTime - startTime).toDouble();
if (totalDuration <= 0) return;
final double pixelPerMs = width / totalDuration;
final validEvents = snoreValues.where((e) {
final int st = e['st'];
final int et = e['et'];
return !(et <= startTime || st >= endTime);
}).toList();
final double centerY = height / 2;
for (final event in validEvents) {
final int st = event['st'];
final int et = event['et'];
bool isSelected = selectedStartTime != null &&
selectedStartTime! >= st &&
selectedStartTime! <= et;
Color snoreColor = stringToColor("#8E7DEF").withOpacity(0.8);
if (isSelected) {
snoreColor = stringToColor("#8E7DEF").withOpacity(0.95);
}
final Paint barPaint = Paint()
..color = snoreColor
..style = PaintingStyle.fill;
final Paint borderPaint = Paint()
..color = snoreColor.withOpacity(0.9)
..style = PaintingStyle.stroke
..strokeWidth = isSelected ? 2.0 : 0.5;
final double fixedBarHeight = height * 0.3;
final double startX = (st - startTime) * pixelPerMs;
final double endX = (et - startTime) * pixelPerMs;
if (endX < 0 || startX > width) continue;
final double drawStartX = math.max(startX, 0);
final double drawEndX = math.min(endX, width);
final double drawWidth = math.max(drawEndX - drawStartX, 1.0);
if (drawWidth <= 0) continue;
final double topBarTop = centerY - fixedBarHeight;
final Rect topRect =
Rect.fromLTWH(drawStartX, topBarTop, drawWidth, fixedBarHeight);
canvas.drawRect(topRect, barPaint);
canvas.drawRect(topRect, borderPaint);
final double bottomBarTop = centerY;
final Rect bottomRect =
Rect.fromLTWH(drawStartX, bottomBarTop, drawWidth, fixedBarHeight);
canvas.drawRect(bottomRect, barPaint);
canvas.drawRect(bottomRect, borderPaint);
if (isSelected) {
final Paint highlightPaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.fill;
final Rect highlightRect = Rect.fromLTWH(
drawStartX - 2,
centerY - fixedBarHeight - 2,
drawWidth + 4,
fixedBarHeight * 2 + 4);
canvas.drawRect(highlightRect, highlightPaint);
final Paint glowPaint = Paint()
..color = stringToColor("#8E7DEF").withOpacity(0.2)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..maskFilter = MaskFilter.blur(BlurStyle.normal, 5.0);
final Rect glowRect = Rect.fromLTWH(
drawStartX - 3,
centerY - fixedBarHeight - 3,
drawWidth + 6,
fixedBarHeight * 2 + 6);
canvas.drawRect(glowRect, glowPaint);
}
}
final Paint axisPaint = Paint()
..color = Colors.grey.withOpacity(0.3)
..strokeWidth = 0.5;
canvas.drawLine(Offset(0, centerY), Offset(width, centerY), axisPaint);
final textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: ui.TextDirection.ltr,
);
final int hourMs = 60 * 60 * 1000;
final int totalHours = (endTime - startTime) ~/ hourMs;
final DateTime startDt = DateTime.fromMillisecondsSinceEpoch(startTime);
final DateTime endDt = DateTime.fromMillisecondsSinceEpoch(endTime);
String label = DateFormat('HH:mm').format(startDt);
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(0 - textPainter.width / 2, height + 2),
);
if (totalHours <= 8) {
DateTime currentHour = DateTime(startDt.year, startDt.month, startDt.day,
startDt.hour + 1, 0, 0, 0, 0);
if (startDt.minute == 0 &&
startDt.second == 0 &&
startDt.millisecond == 0) {
currentHour = startDt;
}
while (currentHour.millisecondsSinceEpoch < endTime) {
int timeMs = currentHour.millisecondsSinceEpoch;
if (timeMs > startTime && timeMs < endTime) {
double x = (timeMs - startTime) * pixelPerMs;
if (timeMs - startTime < 10 * 60 * 1000 ||
endTime - timeMs < 10 * 60 * 1000) {
currentHour = currentHour.add(Duration(hours: 1));
continue;
}
label = "${currentHour.hour}";
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, height + 2),
);
}
currentHour = currentHour.add(Duration(hours: 1));
}
} else {
int labelInterval = (totalHours / 6).ceil();
DateTime firstLabelHour = DateTime(
startDt.year,
startDt.month,
startDt.day,
startDt.hour + (labelInterval - (startDt.hour % labelInterval)),
0,
0,
0,
0);
if (firstLabelHour.millisecondsSinceEpoch <= startTime) {
firstLabelHour = firstLabelHour.add(Duration(hours: labelInterval));
}
DateTime currentHour = firstLabelHour;
while (currentHour.millisecondsSinceEpoch < endTime) {
int timeMs = currentHour.millisecondsSinceEpoch;
if (timeMs - startTime >= hourMs && endTime - timeMs >= hourMs) {
double x = (timeMs - startTime) * pixelPerMs;
label = "${currentHour.hour}";
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, height + 2),
);
}
currentHour = currentHour.add(Duration(hours: labelInterval));
}
}
label = DateFormat('HH:mm').format(endDt);
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(width - textPainter.width / 2, height + 2),
);
}
@override
bool shouldRepaint(covariant SnoreWaveformPainter oldDelegate) {
return oldDelegate.snoreValues != snoreValues ||
oldDelegate.startTime != startTime ||
oldDelegate.endTime != endTime ||
oldDelegate.selectedStartTime != selectedStartTime;
}
}
class HoverHighlightPainter extends CustomPainter {
final Rect selectedRect;
final bool isSelected;
HoverHighlightPainter({
required this.selectedRect,
required this.isSelected,
});
@override
void paint(Canvas canvas, Size size) {
if (!isSelected) return;
final Paint highlightPaint = Paint()
..color = Colors.black.withOpacity(0.1)
..style = PaintingStyle.fill;
final Rect highlightRect = Rect.fromLTWH(
selectedRect.left.clamp(0, size.width),
0,
selectedRect.width.clamp(0, size.width - selectedRect.left),
size.height,
);
canvas.drawRect(highlightRect, highlightPaint);
final Paint borderPaint = Paint()
..color = Colors.blue.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
canvas.drawRect(highlightRect, borderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}