This commit is contained in:
wyf
2025-05-22 08:56:27 +08:00
parent 489e907e00
commit 8a418c9c98
39 changed files with 5964 additions and 144 deletions

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.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 AdviceComponnetWidget extends StatefulWidget {
final String title; // 建议标题
final String description; // 建议说明
const AdviceComponnetWidget({
super.key,
required this.title,
required this.description,
});
@override
State<AdviceComponnetWidget> createState() => _AdviceComponnetWidgetState();
}
class _AdviceComponnetWidgetState extends State<AdviceComponnetWidget> {
@override
void setState(VoidCallback callback) {
super.setState(callback);
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(),
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
// 显示标题
Container(
width: double.infinity,
decoration: BoxDecoration(
color: stringToColor("#313541"),
borderRadius: BorderRadius.circular(20.rpx),
),
child: Padding(
padding: EdgeInsetsDirectional.fromSTEB(
28.rpx, 30.rpx, 28.rpx, 30.rpx),
child: Container(
width: double.infinity,
decoration: BoxDecoration(),
child: Text(
widget.title, // 使用传入的标题
style: TextStyle(
color: themeController.currentColor.sc3,
),
),
),
),
),
// 显示描述
Container(
width: double.infinity,
decoration: BoxDecoration(),
child: Padding(
padding: EdgeInsetsDirectional.fromSTEB(
28.rpx, 30.rpx, 28.rpx, 30.rpx),
child: Container(
width: double.infinity,
decoration: BoxDecoration(),
child: RichText(
text: TextSpan(
style: TextStyle(
color: themeController.currentColor.sc3,
fontSize: 26.rpx, // 设置文字大小
height: 1.3, // 设置行高,控制文字上下间距
),
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle, // 图标和文字垂直居中
child: Padding(
padding: EdgeInsets.only(
bottom: 4.rpx, // 调整底部间距
right: 10.rpx, // 适当调整图标右边距
top: 4.rpx, // 增加顶部间距
),
child: SvgPicture.asset(
'assets/img/icon/ai.svg', // 替换为你的 SVG 文件路径
width: 37.rpx, // 设置适中的 SVG 图标大小
height: 31.rpx, // 使图标和文字大小一致
color:
themeController.currentColor.sc2, // 设置 SVG 颜色
),
),
),
TextSpan(
text: widget.description, // 使用传入的描述
style: TextStyle(
color: themeController.currentColor.sc3,
fontSize: AppConstants().normal_text_fontSize, // 文字大小
),
),
],
),
),
),
),
)
],
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
class DataShowWidget extends StatefulWidget {
final Widget widget1; // 第一个传入的 widget
final Widget widget2; // 第二个传入的 widget
final Widget widget3; // 第三个传入的 widget
final Widget widget4; // 第四个传入的 widget
final MainAxisAlignment alignment; // 控制 Row 的对齐方式
const DataShowWidget({
super.key,
required this.widget1,
required this.widget2,
required this.widget3,
required this.widget4,
this.alignment = MainAxisAlignment.start, // 默认左对齐
});
@override
State<DataShowWidget> createState() => _DataShowWidgetState();
}
class _DataShowWidgetState extends State<DataShowWidget> {
@override
void setState(VoidCallback callback) {
super.setState(callback);
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: 66.rpx,
decoration: BoxDecoration(),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal, // 设置横向滚动
child: Row(
mainAxisAlignment: widget.alignment, // 根据传入的 alignment 控制对齐方式
children: [
// 放入传入的 widget1
Container(
width: MediaQuery.sizeOf(context).width * 0.4, // 固定宽度
decoration: BoxDecoration(),
child: Align(
alignment: widget.alignment == MainAxisAlignment.start
? Alignment.centerLeft
: widget.alignment == MainAxisAlignment.center
? Alignment.center
: Alignment.centerRight, // 根据传入的 alignment 设置对齐
child: widget.widget1, // 显示传入的 widget1
),
),
// 放入传入的 widget2
Container(
width: MediaQuery.sizeOf(context).width * 0.15, // 固定宽度
decoration: BoxDecoration(),
child: Align(
alignment: widget.alignment == MainAxisAlignment.start
? Alignment.centerLeft
: widget.alignment == MainAxisAlignment.center
? Alignment.center
: Alignment.centerRight, // 同样设置对齐
child: widget.widget2, // 显示传入的 widget2
),
),
// 放入传入的 widget3
Container(
width: MediaQuery.sizeOf(context).width * 0.2, // 固定宽度
decoration: BoxDecoration(),
child: Align(
alignment: widget.alignment == MainAxisAlignment.start
? Alignment.centerLeft
: widget.alignment == MainAxisAlignment.center
? Alignment.center
: Alignment.centerRight, // 同样设置对齐
child: widget.widget3, // 显示传入的 widget3
),
),
// 放入传入的 widget4
Container(
width: MediaQuery.sizeOf(context).width * 0.15, // 固定宽度
decoration: BoxDecoration(),
child: Align(
alignment: widget.alignment == MainAxisAlignment.start
? Alignment.centerLeft
: widget.alignment == MainAxisAlignment.center
? Alignment.center
: Alignment.centerRight, // 同样设置对齐
child: widget.widget4, // 显示传入的 widget4
),
),
],
),
),
);
}
}

View File

@@ -204,7 +204,7 @@ class SingleBarPainter extends CustomPainter {
TextStyle(color: themeController.currentColor.sc3, fontSize: 26.rpx);
final textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text =
TextSpan(text: '${value.toStringAsFixed(0)}%', style: textStyle);
TextSpan(text: '${value.toStringAsFixed(0)}', style: textStyle);
textPainter.layout();
canvas.save();
canvas.clipRect(Rect.fromLTWH(

View File

@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:ui' as ui;
class LineChart extends StatelessWidget {
final int startTime;
final int endTime;
final double minValue;
final double maxValue;
final List<Map<String, dynamic>> dataPoints;
LineChart({
required this.startTime,
required this.endTime,
required this.minValue,
required this.maxValue,
required this.dataPoints,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(double.infinity, 300),
painter: LineChartPainter(
startTime: startTime,
endTime: endTime,
minValue: minValue,
maxValue: maxValue,
dataPoints: dataPoints,
),
);
}
}
class LineChartPainter extends CustomPainter {
final int startTime;
final int endTime;
final double minValue;
final double maxValue;
final List<Map<String, dynamic>> dataPoints;
LineChartPainter({
required this.startTime,
required this.endTime,
required this.minValue,
required this.maxValue,
required this.dataPoints,
});
@override
void paint(Canvas canvas, Size size) {
Paint paint = Paint()..style = PaintingStyle.stroke;
double chartWidth = size.width;
double chartHeight = size.height;
// 时间轴刻度设置
DateFormat timeFormatStartEnd = DateFormat('HH:mm');
DateFormat timeFormatMiddle = DateFormat('h');
// 绘制Y轴刻度
double yAxisHeight = chartHeight - 40; // 留一些空间给X轴
double yAxisStep = yAxisHeight / 4;
paint.color = Colors.grey;
paint.strokeWidth = 1;
canvas.drawLine(Offset(30, 0), Offset(30, chartHeight), paint); // Y轴
// 绘制Y轴的刻度线
paint.color = Colors.grey;
for (int i = 0; i < 5; i++) {
double y = i * yAxisStep;
if (i == 0) {
paint.color = Colors.grey; // 0线
canvas.drawLine(Offset(25, y), Offset(35, y), paint);
} else if (i == 1) {
paint.color = Colors.red; // 最小值线
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 1;
canvas.drawLine(Offset(25, y), Offset(35, y), paint);
} else if (i == 2) {
paint.color = Colors.grey; // 最大值与最小值中间线
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 1;
_drawDashedLine(canvas, paint, 25, y, 35, y); // Custom dashed line
} else {
paint.color = Colors.red; // 最大值线
canvas.drawLine(Offset(25, y), Offset(35, y), paint);
}
}
// 绘制X轴时间刻度
DateTime startDate = DateTime.fromMillisecondsSinceEpoch(startTime);
DateTime endDate = DateTime.fromMillisecondsSinceEpoch(endTime);
double xAxisStep = (chartWidth - 60) /
(endDate.millisecondsSinceEpoch - startDate.millisecondsSinceEpoch);
for (DateTime date = startDate;
date.isBefore(endDate);
date = date.add(Duration(hours: 1))) {
String timeLabel = (date == startDate || date == endDate)
? timeFormatStartEnd.format(date)
: timeFormatMiddle.format(date);
paint.color = Colors.black;
// Draw text using TextPainter
_drawText(
canvas, timeLabel, Offset(30, yAxisHeight)); // Position dynamically
}
// 绘制折线图数据点
Path path = Path();
for (var i = 0; i < dataPoints.length; i++) {
var point = dataPoints[i];
DateTime pointTime = DateTime.fromMillisecondsSinceEpoch(point['time']);
double x = (pointTime.millisecondsSinceEpoch -
startDate.millisecondsSinceEpoch) *
xAxisStep +
30;
double y = chartHeight -
(point['value'] - minValue) * yAxisHeight / (maxValue - minValue);
path.lineTo(x, y);
}
paint.color = Colors.green; // Line color based on range
paint.strokeWidth = 2;
canvas.drawPath(path, paint);
}
// Custom method to draw dashed line
void _drawDashedLine(Canvas canvas, Paint paint, double startX, double startY,
double endX, double endY) {
double dashWidth = 5;
double dashSpace = 3;
double distance = (endX - startX).abs();
double dashCount = (distance / (dashWidth + dashSpace)).floorToDouble();
for (int i = 0; i < dashCount; i++) {
double startXDash = startX + (i * (dashWidth + dashSpace));
double endXDash = startXDash + dashWidth;
canvas.drawLine(
Offset(startXDash, startY), Offset(endXDash, endY), paint);
}
}
// Custom method to draw text
void _drawText(Canvas canvas, String text, Offset offset) {
TextPainter textPainter = TextPainter(
text: TextSpan(
text: text, style: TextStyle(color: Colors.black, fontSize: 12)),
textDirection: ui.TextDirection.ltr,
)..layout();
textPainter.paint(canvas, offset);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@@ -154,19 +154,19 @@ class _LineChartByRangePainter extends CustomPainter {
int totalHours = maxTime.difference(minTime).inHours;
int startHour = minTime.hour;
for (int i = 1; i < totalHours; i++) {
double x = xStart + chartWidth * i / totalHours;
// for (int i = 1; i < totalHours; i++) {
// double x = xStart + chartWidth * i / totalHours;
// 垂直虚线
drawDashedLine(
canvas,
Offset(x, 0),
Offset(x, chartHeight),
axisPaint,
dashWidth: 4.rpx,
dashSpace: 4.rpx,
);
}
// // 垂直虚线
// drawDashedLine(
// canvas,
// Offset(x, 0),
// Offset(x, chartHeight),
// axisPaint,
// dashWidth: 4.rpx,
// dashSpace: 4.rpx,
// );
// }
// 5. 画左侧完整时分 (HH:mm),往内缩 labelInset
String leftLabel = DateFormat('HH:mm').format(minTime);

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:math';
class RadarChart extends StatelessWidget {
final List<List<double>> data; // 存储多个数据集
final List<String> labels; // 每个角的标签
final double maxValue; // 数据的最大值,用来统一尺度
const RadarChart({
Key? key,
required this.data,
required this.labels,
this.maxValue = 100,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(300, 300), // 图表的大小
painter: RadarChartPainter(
data: data,
labels: labels,
maxValue: maxValue,
),
);
}
}
class RadarChartPainter extends CustomPainter {
final List<List<double>> data;
final List<String> labels;
final double maxValue;
RadarChartPainter({
required this.data,
required this.labels,
required this.maxValue,
});
@override
void paint(Canvas canvas, Size size) {
Paint paintLine = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 2;
Paint axisPaint = Paint()
..color = Colors.grey.withOpacity(0.5)
..strokeWidth = 1;
double centerX = size.width / 2;
double centerY = size.height / 2;
double radius = size.width / 2;
int numOfPoints = labels.length;
// 绘制雷达图的轴线
for (int i = 0; i < numOfPoints; i++) {
double angle = (2 * pi / numOfPoints) * i;
double x = centerX + radius * cos(angle);
double y = centerY + radius * sin(angle);
// 画轴线
canvas.drawLine(Offset(centerX, centerY), Offset(x, y), axisPaint);
// 绘制标签
TextPainter tp = TextPainter(
text: TextSpan(
text: labels[i],
style: TextStyle(color: Colors.black, fontSize: 12),
),
textDirection: ui.TextDirection.ltr,
);
tp.layout();
tp.paint(canvas, Offset(x + 8, y - 8)); // 设置标签位置
}
// 绘制多个数据集
for (int i = 0; i < data.length; i++) {
Paint fillPaint = Paint()
..color = Colors.primaries[i % Colors.primaries.length].withOpacity(0.3)
..style = PaintingStyle.fill;
Paint linePaint = Paint()
..color = Colors.primaries[i % Colors.primaries.length]
..style = PaintingStyle.stroke
..strokeWidth = 2;
List<Offset> points = [];
for (int j = 0; j < numOfPoints; j++) {
double angle = (2 * pi / numOfPoints) * j;
double pointRadius = (data[i][j] / maxValue) * radius;
double x = centerX + pointRadius * cos(angle);
double y = centerY + pointRadius * sin(angle);
points.add(Offset(x, y));
}
// 画出数据连接线
Path path = Path()..addPolygon(points, true);
canvas.drawPath(path, linePaint);
canvas.drawPath(path, fillPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,118 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
class ScatterPlotChart extends StatelessWidget {
final List<ScatterSpot> points;
final int xMax;
final int yMax;
final Color pointColor;
final int divisions;
ScatterPlotChart({
required this.points,
required this.xMax,
required this.yMax,
required this.pointColor,
required this.divisions,
});
@override
Widget build(BuildContext context) {
// 计算向上取整后的最大值
double xMaxCeil = (xMax / 100).ceil() * 100.0;
double yMaxCeil = (yMax / 100).ceil() * 100.0;
return SizedBox(
child: ScatterChart(
ScatterChartData(
backgroundColor: Colors.transparent,
gridData: FlGridData(
show: true,
horizontalInterval: yMaxCeil / divisions,
verticalInterval: xMaxCeil / divisions,
getDrawingHorizontalLine: (value) {
return FlLine(
color: themeController.currentColor.sc4, // 设置网格线颜色
strokeWidth: 0.5,
);
},
getDrawingVerticalLine: (value) {
return FlLine(
color: themeController.currentColor.sc4, // 设置网格线颜色
strokeWidth: 0.5,
);
},
),
titlesData: FlTitlesData(
show: true,
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 60.rpx, // 给 y 轴标签更多空间
getTitlesWidget: (double value, TitleMeta meta) {
return Padding(
padding: EdgeInsets.only(right: 14.rpx), // 右侧加间距
child: Text(
value.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right, // 右对齐
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (double value, TitleMeta meta) {
return Text(
value.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
);
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(
color: themeController.currentColor.sc4,
width: 0.5,
),
),
// 修改散点的大小和颜色
scatterSpots: points.map((point) {
return ScatterSpot(
point.x, // x 坐标
point.y, // y 坐标
dotPainter: FlDotCirclePainter(
radius: 3.rpx, // 自定义大小
color: pointColor, // 自定义颜色
),
);
}).toList(),
minX: 0,
maxX: xMaxCeil,
minY: 0,
maxY: yMaxCeil,
),
),
);
}
}

View File

@@ -89,7 +89,7 @@ class SegmentedCircleWithCenterWidget extends StatelessWidget {
Positioned(
right: 60.rpx, // 放置在右侧
child: SvgPicture.asset(
'assets/img/icon/add.svg',
'assets/img/icon/score_down.svg',
width: 14.rpx,
height: 22.rpx,
color: themeController.currentColor.sc9,

View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:vbvs_app/common/color/appConstants.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
class SleepRadarChart extends StatelessWidget {
final Map<String, double> today;
final Map<String, double> yesterday;
const SleepRadarChart({
Key? key,
required this.today,
required this.yesterday,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 雷达图
_buildRadarChart(),
],
),
);
}
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),
),
);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:ui' as ui;
import 'package:vbvs_app/common/util/MyUtils.dart';
class TimeLineChart extends StatelessWidget {
final List<DataPoint> points;
final double yMin;
final double yMax;
final int startTime;
final int endTime;
final double width;
final double height;
const TimeLineChart({
super.key,
required this.points,
required this.yMin,
required this.yMax,
required this.startTime,
required this.endTime,
this.width = 400,
this.height = 300,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(width, height),
painter: _TimeLineChartPainter(
points: points,
yMin: yMin,
yMax: yMax,
startTime: startTime,
endTime: endTime,
),
);
}
}
class DataPoint {
final int timestamp;
final double value;
DataPoint(this.timestamp, this.value);
}
class _TimeLineChartPainter extends CustomPainter {
final List<DataPoint> points;
final double yMin;
final double yMax;
final int startTime;
final int endTime;
_TimeLineChartPainter({
required this.points,
required this.yMin,
required this.yMax,
required this.startTime,
required this.endTime,
});
@override
void paint(Canvas canvas, Size size) {
_drawYAxis(canvas, size);
_drawXAxis(canvas, size);
_drawLine(canvas, size);
}
void _drawXAxis(Canvas canvas, Size size) {
const margin = 40.0;
final paint = Paint()..color = Colors.black;
final textStyle = const TextStyle(color: Colors.black, fontSize: 12);
// Draw X axis line
canvas.drawLine(
Offset(margin, size.height - margin),
Offset(size.width - margin, size.height - margin),
paint,
);
// Generate time ticks
final timeFormatStartEnd = DateFormat('HH:mm');
final timeFormatMiddle = DateFormat('h');
final startDateTime = DateTime.fromMillisecondsSinceEpoch(startTime);
final endDateTime = DateTime.fromMillisecondsSinceEpoch(endTime);
List<DateTime> hourTicks = [];
DateTime current = DateTime(
startDateTime.year,
startDateTime.month,
startDateTime.day,
startDateTime.hour,
).add(const Duration(hours: 1));
while (current.isBefore(endDateTime)) {
if (current.isAfter(startDateTime)) {
hourTicks.add(current);
}
current = current.add(const Duration(hours: 1));
}
void drawTick(DateTime time, bool isEdge) {
final x = margin +
((time.millisecondsSinceEpoch - startTime) / (endTime - startTime)) *
(size.width - 2 * margin);
final text = isEdge
? timeFormatStartEnd.format(time)
: timeFormatMiddle.format(time);
_drawText(
canvas,
text,
Offset(x, size.height - margin + 20),
TextAlign.center,
);
}
drawTick(startDateTime, true);
drawTick(endDateTime, true);
for (var tick in hourTicks) {
drawTick(tick, false);
}
}
void _drawYAxis(Canvas canvas, Size size) {
const margin = 40.0;
final midValue = (yMax + yMin) / 2;
// 计算三条虚线之间的垂直间距
final lineSpacing = (size.height - 2 * margin) / 3; // 让三条线之间的间距相等
// 新增的 y=0 实线的垂直位置
final zeroLinePosition = margin + lineSpacing * 3; // 确保 y=0 位于三条虚线下方
void drawLine(double value, Color color,
{bool isDashed = false, bool isSolid = false}) {
final y =
(value - yMax) / (yMin - yMax) * (size.height - 2 * margin) + margin;
final path = Path();
path.moveTo(margin, y);
path.lineTo(size.width - margin, y);
final paint = Paint()
..color = color
..strokeWidth = (color != Colors.grey) ? 2 : 1
..style = PaintingStyle.stroke;
if (isDashed) {
Path dashedPath = _createDashedPath(path, dashWidth: 5, dashSpace: 5);
canvas.drawPath(dashedPath, paint);
} else if (isSolid) {
// 对于实线,直接绘制
canvas.drawPath(path, paint);
} else {
// 默认使用虚线绘制
canvas.drawPath(path, paint);
}
}
// 绘制 y=0 的灰色实线,并将其放置在三条虚线的下方
if (yMin < 0 && yMax > 0) {
drawLine(0, Colors.grey, isSolid: true); // 灰色实线绘制 y=0 线
}
// 绘制最小值、中间值、最大值的虚线
drawLine(yMin, themeController.currentColor.sc9, isDashed: true);
drawLine(midValue, themeController.currentColor.sc4, isDashed: true);
drawLine(yMax, themeController.currentColor.sc9, isDashed: true);
}
Path _createDashedPath(Path path,
{required double dashWidth, required double dashSpace}) {
final Path dashedPath = Path();
final ui.PathMetrics metrics = path.computeMetrics();
for (ui.PathMetric metric in metrics) {
double distance = 0;
while (distance < metric.length) {
dashedPath.addPath(
metric.extractPath(distance, distance + dashWidth),
Offset.zero,
);
distance += dashWidth + dashSpace;
}
}
return dashedPath;
}
void _drawLine(Canvas canvas, Size size) {
const margin = 40.0;
final sortedPoints = points
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
Path? currentPath;
Paint currentPaint = _createPaint(Colors.green);
for (int i = 0; i < sortedPoints.length - 1; i++) {
final p1 = sortedPoints[i];
final p2 = sortedPoints[i + 1];
final x1 = margin +
((p1.timestamp - startTime) / (endTime - startTime)) *
(size.width - 2 * margin);
final y1 = margin +
(1 - (p1.value - yMin) / (yMax - yMin)) * (size.height - 2 * margin);
final x2 = margin +
((p2.timestamp - startTime) / (endTime - startTime)) *
(size.width - 2 * margin);
final y2 = margin +
(1 - (p2.value - yMin) / (yMax - yMin)) * (size.height - 2 * margin);
final shouldBeGreen = p1.value >= yMin &&
p1.value <= yMax &&
p2.value >= yMin &&
p2.value <= yMax;
// 根据当前线段的状态来决定是否切换颜色和虚线状态
if (shouldBeGreen != (currentPaint.color == Colors.green)) {
if (currentPath != null) {
canvas.drawPath(currentPath, currentPaint);
}
currentPath = Path();
currentPaint = _createPaint(shouldBeGreen ? Colors.green : Colors.red);
}
currentPath ??= Path();
if (i == 0) currentPath.moveTo(x1, y1);
currentPath.lineTo(x2, y2);
}
// 绘制剩余路径
if (currentPath != null) {
if (currentPaint.color == Colors.red) {
// 如果是红色线,绘制虚线
final dashedPath =
_createDashedPath(currentPath, dashWidth: 5, dashSpace: 5);
canvas.drawPath(dashedPath, currentPaint);
} else {
canvas.drawPath(currentPath, currentPaint);
}
}
}
Paint _createPaint(Color color) => Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
void _drawText(Canvas canvas, String text, Offset offset, TextAlign align) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: const TextStyle(color: Colors.black, fontSize: 12),
),
textDirection: ui.TextDirection.ltr,
)..layout();
final centeredOffset = offset.translate(
-textPainter.width / 2,
-textPainter.height / 2,
);
textPainter.paint(canvas, centeredOffset);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'dart:math';
import 'package:vbvs_app/common/util/MyUtils.dart';
class TimeSeriesChart extends StatelessWidget {
final int startTime;
final int endTime;
final double yMin;
final double yMax;
final List<TimeSeriesPoint> dataPoints;
TimeSeriesChart({
required this.startTime,
required this.endTime,
required this.yMin,
required this.yMax,
required this.dataPoints,
});
@override
Widget build(BuildContext context) {
final midValue = (yMax + yMin) / 2;
final xLabels = _generateXLabels();
// Prepare spots and segments
List<FlSpot> spots = [];
List<Color> lineColors = [];
for (int i = 0; i < dataPoints.length; i++) {
final point = dataPoints[i];
final xValue = _convertTimeToXValue(point.timestamp);
final yValue = point.value;
spots.add(FlSpot(xValue, yValue));
if (yValue >= yMin && yValue <= yMax) {
lineColors.add(Colors.green); // Color for points within range
} else {
lineColors.add(Colors.red); // Color for points outside range
}
}
return AspectRatio(
aspectRatio: 2,
child: LineChart(
LineChartData(
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (List<LineBarSpot> touchedSpots) {
return touchedSpots.map((spot) {
final time = DateTime.fromMillisecondsSinceEpoch(
_convertXValueToTime(spot.x),
);
return LineTooltipItem(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}\n${spot.y.toStringAsFixed(0)}',
const TextStyle(color: Colors.black),
);
}).toList();
},
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) {
if (value == 0) {
return FlLine(
color: themeController.currentColor.sc4,
strokeWidth: 1,
);
} else if (value == yMin) {
return FlLine(
color: themeController.currentColor.sc9,
strokeWidth: 1,
dashArray: [5, 5],
);
} else if (value == yMax) {
return FlLine(
color: themeController.currentColor.sc9,
strokeWidth: 1,
dashArray: [5, 5],
);
} else if (value == midValue) {
return FlLine(
color: themeController.currentColor.sc4,
strokeWidth: 1,
dashArray: [5, 5],
);
}
return FlLine(
color: Colors.grey.withOpacity(0.1),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < xLabels.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
xLabels[index].label,
style: TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
);
}
return const Text('');
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value == 0) {
return Padding(
padding: EdgeInsets.only(right: 14.rpx),
child: Text(
value.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
);
} else if (value == yMin) {
return Padding(
padding: EdgeInsets.only(right: 14.rpx),
child: Text(
yMin.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
);
} else if (value == midValue) {
return Padding(
padding: EdgeInsets.only(right: 14.rpx),
child: Text(
midValue.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
);
} else if (value == yMax) {
return Padding(
padding: EdgeInsets.only(right: 14.rpx),
child: Text(
yMax.toStringAsFixed(0),
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
);
}
return const Text('');
},
reservedSize: 40,
),
),
),
borderData: FlBorderData(
show: false,
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
minX: 0,
maxX: xLabels.length - 1,
minY: min(0, yMin) - (yMax - yMin) * 0.2,
maxY: yMax + (yMax - yMin) * 0.2,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: themeController.currentColor.sc2,
barWidth: 2,
isStrokeCapRound: true,
dotData: FlDotData(show: false), // Disable dots
belowBarData: BarAreaData(show: false),
),
],
),
),
);
}
List<XLabel> _generateXLabels() {
final labels = <XLabel>[];
final startDate = DateTime.fromMillisecondsSinceEpoch(startTime);
final endDate = DateTime.fromMillisecondsSinceEpoch(endTime);
labels.add(XLabel(
time: startTime,
label:
'${startDate.hour.toString().padLeft(2, '0')}:${startDate.minute.toString().padLeft(2, '0')}',
));
DateTime current = DateTime(
startDate.year,
startDate.month,
startDate.day,
startDate.hour + 1,
);
while (current.isBefore(endDate)) {
labels.add(XLabel(
time: current.millisecondsSinceEpoch,
label: current.hour.toString(),
));
current = current.add(Duration(hours: 1));
}
labels.add(XLabel(
time: endTime,
label:
'${endDate.hour.toString().padLeft(2, '0')}:${endDate.minute.toString().padLeft(2, '0')}',
));
return labels;
}
double _convertTimeToXValue(int timestamp) {
final totalDuration = endTime - startTime;
final pointDuration = timestamp - startTime;
final xLabels = _generateXLabels();
return (pointDuration / totalDuration) * (xLabels.length - 1);
}
int _convertXValueToTime(double xValue) {
final xLabels = _generateXLabels();
final totalDuration = endTime - startTime;
final ratio = xValue / (xLabels.length - 1);
return startTime + (totalDuration * ratio).round();
}
}
class TimeSeriesPoint {
final int timestamp;
final double value;
TimeSeriesPoint(this.timestamp, this.value);
}
class XLabel {
final int time;
final String label;
XLabel({required this.time, required this.label});
}