更新睡眠报告

This commit is contained in:
wyf
2025-05-27 23:09:31 +08:00
parent e0fef11b33
commit 98cd7f4e6a
54 changed files with 4450 additions and 1160 deletions

View File

@@ -50,7 +50,7 @@ class _DataShowWidgetState extends State<DataShowWidget> {
children: [
// 放入传入的 widget1
Container(
width: MediaQuery.sizeOf(context).width * 0.4, // 固定宽度
width: MediaQuery.sizeOf(context).width * 0.35, // 固定宽度
decoration: BoxDecoration(),
child: Align(
alignment: widget.alignment == MainAxisAlignment.start

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
import 'dart:ui' as ui;
class DotBarChart extends StatefulWidget {
final List<Map<String, dynamic>> showLabel;
@@ -39,7 +40,6 @@ class _DotBarChartState extends State<DotBarChart> {
void _showTooltip(BuildContext context, Map<String, dynamic> data,
Offset position, double dotY) {
_removeOverlay();
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final Offset globalPosition = renderBox.localToGlobal(position);
@@ -55,11 +55,6 @@ class _DotBarChartState extends State<DotBarChart> {
color: themeController.currentColor.sc5,
borderRadius: BorderRadius.circular(8.rpx),
boxShadow: [
// BoxShadow(
// color: Colors.black.withOpacity(0.6),
// blurRadius: 10.rpx,
// offset: Offset(0, 4.rpx),
// ),
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 12.rpx,
@@ -92,7 +87,6 @@ class _DotBarChartState extends State<DotBarChart> {
),
),
);
Overlay.of(context)?.insert(_overlayEntry!);
}
@@ -103,7 +97,6 @@ class _DotBarChartState extends State<DotBarChart> {
int maxTimes = widget.showLabel
.map((e) => e['times'] ?? 0)
.reduce((a, b) => a > b ? a : b);
int yMax = (maxTimes / 10).ceil() * 10;
if (yMax == 0) yMax = 10;
@@ -113,23 +106,35 @@ class _DotBarChartState extends State<DotBarChart> {
int displayMax = step * maxSteps;
List<int> yLabels = List.generate(maxSteps + 1, (index) => step * index);
DateFormat fullFormat = DateFormat('HH:mm');
DateFormat hourFormat = DateFormat('H');
DateTime startDate = DateTime.fromMillisecondsSinceEpoch(widget.startTime);
DateTime endDate = DateTime.fromMillisecondsSinceEpoch(widget.endTime);
int maxXLabels = 11;
int totalPoints = widget.showLabel.length;
List<int> xLabelIndices = [];
// Generate hourly timestamps
List<int> hourlyTimestamps = [];
DateTime currentHour = DateTime(
startDate.year, startDate.month, startDate.day, startDate.hour);
while (currentHour.isBefore(endDate) ||
currentHour.isAtSameMomentAs(endDate)) {
hourlyTimestamps.add(currentHour.millisecondsSinceEpoch);
currentHour = currentHour.add(const Duration(hours: 1));
}
if (totalPoints <= maxXLabels) {
xLabelIndices = List.generate(totalPoints, (i) => i);
} else {
xLabelIndices.add(0);
int middleCount = maxXLabels - 2;
double stepX = (totalPoints - 1) / (middleCount + 1);
for (int i = 1; i <= middleCount; i++) {
xLabelIndices.add((stepX * i).round());
// Calculate positions for hourly labels
List<Map<String, dynamic>> hourLabels = [];
if (widget.showLabel.isNotEmpty) {
int firstDataTime = widget.showLabel.first['time'];
int lastDataTime = widget.showLabel.last['time'];
double totalDuration = (lastDataTime - firstDataTime).toDouble();
for (int timestamp in hourlyTimestamps) {
if (timestamp >= firstDataTime && timestamp <= lastDataTime) {
double position = (timestamp - firstDataTime) / totalDuration;
hourLabels.add({
'time': timestamp,
'position': position,
});
}
}
xLabelIndices.add(totalPoints - 1);
}
double yAxisWidth = 36.rpx;
@@ -225,19 +230,14 @@ class _DotBarChartState extends State<DotBarChart> {
drawableHeight * (1 - times / displayMax);
return Positioned(
left: x - 20.rpx, // Increase touch area
top: y - 20.rpx, // Increase touch area
left: x - 20.rpx,
top: y - 20.rpx,
child: GestureDetector(
onTap: () {
setState(() {
selectedIndex = index;
});
_showTooltip(
context,
data,
Offset(x, y),
y,
);
_showTooltip(context, data, Offset(x, y), y);
},
child: Container(
width: 40.rpx,
@@ -259,33 +259,15 @@ class _DotBarChartState extends State<DotBarChart> {
padding: EdgeInsets.only(left: yAxisWidth),
child: SizedBox(
height: xAxisHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(widget.showLabel.length, (index) {
String label = '';
if (xLabelIndices.contains(index)) {
DateTime dt = DateTime.fromMillisecondsSinceEpoch(
widget.showLabel[index]['time']);
if (index == 0 || index == totalPoints - 1) {
label = fullFormat.format(dt);
} else {
label = dt.hour.toString();
}
}
return Expanded(
child: Padding(
padding: EdgeInsets.only(top: 14.rpx),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
),
);
}),
child: CustomPaint(
size: Size(double.infinity, xAxisHeight),
painter: _XAxisPainter(
hourLabels: hourLabels,
textColor: themeController.currentColor.sc4,
fontSize: 18.rpx,
startTime: widget.startTime,
endTime: widget.endTime,
),
),
),
),
@@ -328,7 +310,6 @@ class _DotBarChartPainter extends CustomPainter {
double thresholdY =
yAxisTopPadding + drawableHeight * (1 - threshold / yMax);
drawDashedLine(
canvas,
Offset(0, thresholdY),
@@ -339,7 +320,6 @@ class _DotBarChartPainter extends CustomPainter {
);
final double dotRadius = 13.rpx;
for (int i = 0; i < data.length; i++) {
int times = data[i]['times'] ?? 0;
double x = horizontalPadding + i * xStep;
@@ -357,10 +337,8 @@ class _DotBarChartPainter extends CustomPainter {
..style = PaintingStyle.stroke
..color = Colors.white
..strokeWidth = 3.rpx;
canvas.drawCircle(Offset(x, y), dotRadius + 1.rpx, borderPaint);
}
canvas.drawCircle(Offset(x, y), dotRadius, dotPaint);
}
@@ -376,7 +354,6 @@ class _DotBarChartPainter extends CustomPainter {
for (int i = 0; i < yLabelsCount; i++) {
double y = yAxisTopPadding + i * (drawableHeight / (yLabelsCount - 1));
if (i == yLabelsCount - 1) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), solidLinePaint);
} else {
@@ -392,8 +369,14 @@ class _DotBarChartPainter extends CustomPainter {
}
}
void drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint,
double dashWidth, double gapWidth) {
void drawDashedLine(
Canvas canvas,
Offset start,
Offset end,
Paint paint,
double dashWidth,
double gapWidth,
) {
double dx = start.dx;
final double y = start.dy;
while (dx < end.dx) {
@@ -406,3 +389,65 @@ class _DotBarChartPainter extends CustomPainter {
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
class _XAxisPainter extends CustomPainter {
final List<Map<String, dynamic>> hourLabels;
final Color textColor;
final double fontSize;
final int startTime;
final int endTime;
_XAxisPainter({
required this.hourLabels,
required this.textColor,
required this.fontSize,
required this.startTime,
required this.endTime,
});
@override
void paint(Canvas canvas, Size size) {
final textStyle = TextStyle(
color: textColor,
fontSize: fontSize,
);
final textPainter = TextPainter(
textDirection: ui.TextDirection.ltr,
textAlign: TextAlign.center,
);
// Draw start time (leftmost)
final startText = DateFormat('HH:mm')
.format(DateTime.fromMillisecondsSinceEpoch(startTime));
final startTextSpan = TextSpan(text: startText, style: textStyle);
textPainter.text = startTextSpan;
textPainter.layout();
textPainter.paint(canvas, Offset(0, 14.rpx));
// Draw end time (rightmost)
final endText = DateFormat('HH:mm')
.format(DateTime.fromMillisecondsSinceEpoch(endTime));
final endTextSpan = TextSpan(text: endText, style: textStyle);
textPainter.text = endTextSpan;
textPainter.layout();
textPainter.paint(canvas, Offset(size.width - textPainter.width, 14.rpx));
// Draw hourly labels in between
for (var label in hourLabels) {
final position = label['position'] * size.width;
final time = DateTime.fromMillisecondsSinceEpoch(label['time']);
final hourText = DateFormat('h').format(time);
final textSpan = TextSpan(text: hourText, style: textStyle);
textPainter.text = textSpan;
textPainter.layout();
final offset = Offset(
position - textPainter.width / 2,
14.rpx, // Padding from bottom
);
textPainter.paint(canvas, offset);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class GradientLine extends StatelessWidget {
final double height;
final Color color;
const GradientLine({
Key? key,
this.height = 4.0,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return CustomPaint(
size: Size(constraints.maxWidth, height),
painter: _GradientLinePainter(color: color),
);
},
);
}
}
class _GradientLinePainter extends CustomPainter {
final Color color;
_GradientLinePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..shader = LinearGradient(
colors: [
color.withOpacity(0.0), // 左端透明
color.withOpacity(1.0), // 中间最深
color.withOpacity(0.0), // 右端透明
],
stops: [0.0, 0.5, 1.0],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
class SegmentData {
final Color color;
@@ -63,6 +62,7 @@ class SegmentedCircleWithCenterWidget extends StatelessWidget {
final double strokeWidth;
final double gapAngle;
final Widget centerWidget;
final int trend;
const SegmentedCircleWithCenterWidget({
Key? key,
@@ -70,6 +70,7 @@ class SegmentedCircleWithCenterWidget extends StatelessWidget {
this.strokeWidth = 6.0,
this.gapAngle = 4.0,
required this.centerWidget,
required this.trend,
}) : super(key: key);
@override
@@ -87,16 +88,27 @@ class SegmentedCircleWithCenterWidget extends StatelessWidget {
),
centerWidget, // 放置自定义的中心 Widget
Positioned(
bottom: 200.rpx,
right: 60.rpx, // 放置在右侧
child: SvgPicture.asset(
'assets/img/icon/score_down.svg',
width: 14.rpx,
height: 22.rpx,
color: themeController.currentColor.sc9,
_getTrendIcon(trend),
width: trend != 0 ? 14.rpx : 18.rpx,
height: trend != 0 ? 22.rpx : 6.rpx,
// color: themeController.currentColor.sc9,
),
),
],
);
}
String _getTrendIcon(int? trend) {
switch (trend) {
case 0:
return 'assets/img/icon/score_equal.svg';
case 1:
return 'assets/img/icon/score_up.svg';
default:
return 'assets/img/icon/score_down.svg';
}
}
}

View File

@@ -1,17 +1,20 @@
import 'package:flutter/material.dart';
import 'package:ef/ef.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.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 SleepRadarChart extends StatelessWidget {
final Map<String, double> today;
final Map<String, double> yesterday;
final List<Map<String, dynamic>> data;
const SleepRadarChart({
Key? key,
required this.today,
required this.yesterday,
}) : super(key: key);
const SleepRadarChart({Key? key, required this.data}) : super(key: key);
// const SleepRadarChart({
// Key? key,
// required this.today,
// required this.yesterday,
// }) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -27,74 +30,48 @@ class SleepRadarChart extends StatelessWidget {
}
Widget _buildRadarChart() {
return AspectRatio(
aspectRatio: 1.3,
child: RadarChart(
RadarChartData(
dataSets: [
// 今日数据
RadarDataSet(
dataEntries: [
RadarEntry(value: today['type1']!), // 呼吸暂停
RadarEntry(value: today['type2']!), // 入睡时间
RadarEntry(value: today['type3']!), // 离床次数
RadarEntry(value: today['type4']!), // 深睡比例
RadarEntry(value: today['type5']!), // 睡眠时长
],
borderColor: stringToColor("#00C1AA"),
borderWidth: 2,
fillColor: Colors.transparent,
entryRadius: 0,
),
// 昨日数据
RadarDataSet(
dataEntries: [
RadarEntry(value: yesterday['type1']!), // 呼吸暂停
RadarEntry(value: yesterday['type2']!), // 入睡时间
RadarEntry(value: yesterday['type3']!), // 离床次数
RadarEntry(value: yesterday['type4']!), // 深睡比例
RadarEntry(value: yesterday['type5']!), // 睡眠时长
],
borderColor: stringToColor("#FFD251"),
borderWidth: 2,
fillColor: Colors.transparent,
entryRadius: 0,
),
],
radarBackgroundColor: stringToColor("#343844"),
radarBorderData:
BorderSide(color: themeController.currentColor.sc4, width: 1),
radarShape: RadarShape.polygon,
titlePositionPercentageOffset: 0.2,
titleTextStyle: TextStyle(
fontSize: AppConstants().normal_text_fontSize,
color: themeController.currentColor.sc3),
getTitle: (index, angle) {
switch (index) {
case 0:
return RadarChartTitle(text: '呼吸暂停');
case 1:
return RadarChartTitle(text: '入睡时间');
case 2:
return RadarChartTitle(text: '离床次数');
case 3:
return RadarChartTitle(text: '深睡比例');
case 4:
return RadarChartTitle(text: '睡眠时长');
default:
return const RadarChartTitle(text: '');
}
},
tickCount: 5,
ticksTextStyle:
const TextStyle(color: Colors.transparent, fontSize: 10),
// ticksColor: Colors.grey.shade300,
gridBorderData: BorderSide(color: Colors.transparent, width: 1),
tickBorderData:
BorderSide(color: themeController.currentColor.sc4, width: 1),
),
swapAnimationDuration: const Duration(milliseconds: 400),
return AspectRatio(
aspectRatio: 1.3,
child: RadarChart(
RadarChartData(
dataSets: [
// 今日数据
RadarDataSet(
dataEntries: data.map((e) => RadarEntry(value: (e['t'] as num).toDouble())).toList(),
borderColor: stringToColor("#00C1AA"),
borderWidth: 2,
fillColor: Colors.transparent,
entryRadius: 0,
),
// 昨日数据
RadarDataSet(
dataEntries: data.map((e) => RadarEntry(value: (e['y'] as num).toDouble())).toList(),
borderColor: stringToColor("#FFD251"),
borderWidth: 2,
fillColor: Colors.transparent,
entryRadius: 0,
),
],
radarBackgroundColor: stringToColor("#343844"),
radarBorderData: BorderSide(
color: themeController.currentColor.sc4, width: 0.5.rpx),
radarShape: RadarShape.polygon,
titlePositionPercentageOffset: 0.2,
titleTextStyle: TextStyle(
fontSize: AppConstants().normal_text_fontSize,
color: themeController.currentColor.sc3),
getTitle: (index, angle) {
return RadarChartTitle(text: data[index]['name'] ?? '未知'.tr);
},
tickCount: 5,
ticksTextStyle: const TextStyle(color: Colors.transparent, fontSize: 10),
gridBorderData: BorderSide(color: Colors.transparent, width: 1),
tickBorderData: BorderSide(
color: themeController.currentColor.sc4, width: 0.5.rpx),
),
);
}
swapAnimationDuration: const Duration(milliseconds: 400),
),
);
}
}

View File

@@ -0,0 +1,273 @@
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'];
// 查找匹配的颜色
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;
final double barHeight = (type + 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: CustomPaint(
size: Size(double.infinity, 150),
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;
final double centerY = height / 2;
final double totalDuration = (endTime - startTime).toDouble();
final double pixelPerMs = width / totalDuration;
final Paint wavePaint = Paint()
..color = stringToColor("#8E7DEF")
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final Path upperPath = Path();
final Path lowerPath = Path();
const double scaleY = 0.5; //波形图比例
for (int i = 0; i < snoreValues.length; i++) {
final timestamp = snoreValues[i]["st"];
final value = snoreValues[i]["value"]?.toDouble() ?? 0;
final x = (timestamp - startTime) * pixelPerMs;
final y = centerY - value * scaleY;
final yMirror = centerY + value * scaleY;
if (i == 0) {
upperPath.moveTo(x, y);
lowerPath.moveTo(x, yMirror);
} else {
upperPath.lineTo(x, y);
lowerPath.lineTo(x, yMirror);
}
}
canvas.drawPath(upperPath, wavePaint);
canvas.drawPath(lowerPath, wavePaint);
final Paint axisPaint = Paint()
..color = Colors.grey
..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;
// 循环绘制整点小时标签(不包含终点)
for (int t = startTime; t < endTime; t += hourMs) {
double x = (t - startTime) * pixelPerMs;
DateTime dt = DateTime.fromMillisecondsSinceEpoch(t);
String label;
if (t == startTime) {
label = DateFormat('HH:mm').format(dt); // 起点显示 HH:mm
} else {
label = DateFormat('h').format(dt); // 中间显示小时不带前导0
}
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, height + 20.rpx),
);
}
// 单独绘制终点时间标签,确保显示具体时分
{
double x = (endTime - startTime) * pixelPerMs;
DateTime dt = DateTime.fromMillisecondsSinceEpoch(endTime);
String label = DateFormat('HH:mm').format(dt);
textPainter.text = TextSpan(
text: label,
style: TextStyle(fontSize: 10, color: Colors.grey),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, height + 20.rpx),
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}