更新
This commit is contained in:
408
lib/pages/sleep_report/chart/DotBarChart.dart
Normal file
408
lib/pages/sleep_report/chart/DotBarChart.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
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';
|
||||
|
||||
class DotBarChart extends StatefulWidget {
|
||||
final List<Map<String, dynamic>> showLabel;
|
||||
final int threshold;
|
||||
final int startTime;
|
||||
final int endTime;
|
||||
|
||||
const DotBarChart({
|
||||
Key? key,
|
||||
required this.showLabel,
|
||||
required this.threshold,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_DotBarChartState createState() => _DotBarChartState();
|
||||
}
|
||||
|
||||
class _DotBarChartState extends State<DotBarChart> {
|
||||
int? selectedIndex;
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
left: globalPosition.dx - 60.rpx,
|
||||
top: globalPosition.dy - 80.rpx,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12.rpx),
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
spreadRadius: 2.rpx,
|
||||
offset: Offset(0, 6.rpx),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'时间: ${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(data['time']))}',
|
||||
style: TextStyle(
|
||||
fontSize: 20.rpx,
|
||||
color: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.rpx),
|
||||
Text(
|
||||
'时长: ${data['times']}秒',
|
||||
style: TextStyle(
|
||||
fontSize: 20.rpx,
|
||||
color: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context)?.insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.showLabel.isEmpty) return const SizedBox();
|
||||
|
||||
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;
|
||||
|
||||
int maxSteps = 6;
|
||||
int step = (yMax / maxSteps).ceil();
|
||||
step = ((step + 9) ~/ 10) * 10;
|
||||
int displayMax = step * maxSteps;
|
||||
List<int> yLabels = List.generate(maxSteps + 1, (index) => step * index);
|
||||
|
||||
DateFormat fullFormat = DateFormat('HH:mm');
|
||||
DateFormat hourFormat = DateFormat('H');
|
||||
|
||||
int maxXLabels = 11;
|
||||
int totalPoints = widget.showLabel.length;
|
||||
List<int> xLabelIndices = [];
|
||||
|
||||
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());
|
||||
}
|
||||
xLabelIndices.add(totalPoints - 1);
|
||||
}
|
||||
|
||||
double yAxisWidth = 36.rpx;
|
||||
double xAxisHeight = 40.rpx;
|
||||
double chartHeight = 491.rpx;
|
||||
double bottomPadding = 10.rpx;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedIndex = null;
|
||||
_removeOverlay();
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: yAxisWidth,
|
||||
height: chartHeight + bottomPadding,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 6.rpx,
|
||||
child: Text(
|
||||
'秒',
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx,
|
||||
color: themeController.currentColor.sc4,
|
||||
),
|
||||
),
|
||||
),
|
||||
...List.generate(yLabels.length, (index) {
|
||||
double yLabelsAreaHeight =
|
||||
chartHeight - (30.rpx + 18.rpx) - bottomPadding;
|
||||
double y = (30.rpx + 18.rpx) +
|
||||
index * (yLabelsAreaHeight / (yLabels.length - 1));
|
||||
double textHeight = 18.rpx;
|
||||
double topPos = y - textHeight / 2;
|
||||
if (index == yLabels.length - 1) {
|
||||
topPos -= bottomPadding / 2;
|
||||
}
|
||||
return Positioned(
|
||||
right: 6.rpx,
|
||||
top: topPos,
|
||||
child: Text(
|
||||
'${yLabels.reversed.toList()[index]}',
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx,
|
||||
color: themeController.currentColor.sc4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CustomPaint(
|
||||
size: Size(double.infinity, chartHeight),
|
||||
painter: _DotBarChartPainter(
|
||||
data: widget.showLabel,
|
||||
yMax: displayMax,
|
||||
threshold: widget.threshold,
|
||||
yLabelsCount: yLabels.length,
|
||||
yAxisTopPadding: 30.rpx + 18.rpx,
|
||||
horizontalPadding: 20.rpx,
|
||||
selectedIndex: selectedIndex,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double chartWidth =
|
||||
constraints.maxWidth - 2 * 20.rpx;
|
||||
final double xStep = widget.showLabel.length > 1
|
||||
? chartWidth / (widget.showLabel.length - 1)
|
||||
: 0;
|
||||
final double drawableHeight =
|
||||
chartHeight - (30.rpx + 18.rpx);
|
||||
|
||||
return Stack(
|
||||
children:
|
||||
widget.showLabel.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
Map<String, dynamic> data = entry.value;
|
||||
int times = data['times'] ?? 0;
|
||||
double x = 20.rpx + index * xStep;
|
||||
double y = (30.rpx + 18.rpx) +
|
||||
drawableHeight * (1 - times / displayMax);
|
||||
|
||||
return Positioned(
|
||||
left: x - 20.rpx, // Increase touch area
|
||||
top: y - 20.rpx, // Increase touch area
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
_showTooltip(
|
||||
context,
|
||||
data,
|
||||
Offset(x, y),
|
||||
y,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 40.rpx,
|
||||
height: 40.rpx,
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotBarChartPainter extends CustomPainter {
|
||||
final List<Map<String, dynamic>> data;
|
||||
final int yMax;
|
||||
final int threshold;
|
||||
final int yLabelsCount;
|
||||
final double yAxisTopPadding;
|
||||
final double horizontalPadding;
|
||||
final int? selectedIndex;
|
||||
|
||||
_DotBarChartPainter({
|
||||
required this.data,
|
||||
required this.yMax,
|
||||
required this.threshold,
|
||||
required this.yLabelsCount,
|
||||
required this.yAxisTopPadding,
|
||||
required this.horizontalPadding,
|
||||
this.selectedIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double chartWidth = size.width - 2 * horizontalPadding;
|
||||
final double xStep = data.length > 1 ? chartWidth / (data.length - 1) : 0;
|
||||
final double chartHeight = size.height;
|
||||
final double drawableHeight = chartHeight - yAxisTopPadding;
|
||||
|
||||
final Paint thresholdPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..color = stringToColor("#FF7159")
|
||||
..strokeWidth = 1.rpx;
|
||||
|
||||
double thresholdY =
|
||||
yAxisTopPadding + drawableHeight * (1 - threshold / yMax);
|
||||
|
||||
drawDashedLine(
|
||||
canvas,
|
||||
Offset(0, thresholdY),
|
||||
Offset(size.width, thresholdY),
|
||||
thresholdPaint,
|
||||
8.rpx,
|
||||
6.rpx,
|
||||
);
|
||||
|
||||
final double dotRadius = 13.rpx;
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
int times = data[i]['times'] ?? 0;
|
||||
double x = horizontalPadding + i * xStep;
|
||||
double y = yAxisTopPadding + drawableHeight * (1 - times / yMax);
|
||||
|
||||
Paint dotPaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = times >= threshold
|
||||
? stringToColor("#FF7159")
|
||||
: stringToColor("#00C1AA");
|
||||
|
||||
// Draw a larger circle for selected dot
|
||||
if (i == selectedIndex) {
|
||||
Paint borderPaint = Paint()
|
||||
..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);
|
||||
}
|
||||
|
||||
final Paint solidLinePaint = Paint()
|
||||
..color = Colors.grey.withOpacity(0.7)
|
||||
..strokeWidth = 1.rpx
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final Paint dashedLinePaint = Paint()
|
||||
..color = Colors.grey.withOpacity(0.4)
|
||||
..strokeWidth = 1.rpx
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
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 {
|
||||
drawDashedLine(
|
||||
canvas,
|
||||
Offset(0, y),
|
||||
Offset(size.width, y),
|
||||
dashedLinePaint,
|
||||
8.rpx,
|
||||
6.rpx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
final double nextDx = (dx + dashWidth).clamp(start.dx, end.dx);
|
||||
canvas.drawLine(Offset(dx, y), Offset(nextDx, y), paint);
|
||||
dx = nextDx + gapWidth;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
147
lib/pages/sleep_report/chart/FatigueCircleIndicator.dart
Normal file
147
lib/pages/sleep_report/chart/FatigueCircleIndicator.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
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 FatigueCircleIndicator extends StatelessWidget {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const FatigueCircleIndicator({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
final double radius = (screenWidth * 0.127).clamp(95.rpx, double.infinity);
|
||||
|
||||
final double strokeWidth = 14.rpx;
|
||||
final double backgroundStrokeWidth = 8.rpx;
|
||||
|
||||
final String name = data["name"];
|
||||
final Color color = data["color"];
|
||||
final int percent = data["percent"];
|
||||
final String explain = data["explain"];
|
||||
final Color bottomColor = data["bottomColor"];
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 合并绘制背景与进度
|
||||
CustomPaint(
|
||||
size: Size(radius * 2, radius * 2),
|
||||
painter: _CirclePainter(
|
||||
percent: percent,
|
||||
color: color,
|
||||
bottomColor: bottomColor,
|
||||
progressStrokeWidth: strokeWidth,
|
||||
backgroundStrokeWidth: backgroundStrokeWidth,
|
||||
),
|
||||
),
|
||||
// 中心文本
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$percent%',
|
||||
style: TextStyle(
|
||||
fontSize: AppConstants().normal_text_fontSize,
|
||||
color: percent > 60
|
||||
? themeController.currentColor.sc9
|
||||
: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.rpx),
|
||||
Text(
|
||||
explain,
|
||||
style: TextStyle(
|
||||
fontSize: AppConstants().normal_text_fontSize,
|
||||
color: percent > 60
|
||||
? themeController.currentColor.sc9
|
||||
: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40.rpx),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppConstants().normal_text_fontSize,
|
||||
color: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CirclePainter extends CustomPainter {
|
||||
final int percent;
|
||||
final Color color;
|
||||
final Color bottomColor;
|
||||
final double progressStrokeWidth;
|
||||
final double backgroundStrokeWidth;
|
||||
|
||||
_CirclePainter({
|
||||
required this.percent,
|
||||
required this.color,
|
||||
required this.bottomColor,
|
||||
required this.progressStrokeWidth,
|
||||
required this.backgroundStrokeWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = (size.width - progressStrokeWidth) / 2;
|
||||
|
||||
// 背景环(底色,细)
|
||||
final backgroundPaint = Paint()
|
||||
..color = bottomColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = backgroundStrokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
90 * 3.1415926 / 180,
|
||||
2 * 3.1415926,
|
||||
false,
|
||||
backgroundPaint,
|
||||
);
|
||||
|
||||
// 进度环(进度色,粗)
|
||||
final progressPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = progressStrokeWidth
|
||||
..strokeCap = StrokeCap.butt;
|
||||
|
||||
final sweepAngle = 2 * 3.1415926 * (percent / 100);
|
||||
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
90 * 3.1415926 / 180,
|
||||
sweepAngle,
|
||||
false,
|
||||
progressPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_CirclePainter oldDelegate) {
|
||||
return oldDelegate.percent != percent ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.bottomColor != bottomColor ||
|
||||
oldDelegate.progressStrokeWidth != progressStrokeWidth ||
|
||||
oldDelegate.backgroundStrokeWidth != backgroundStrokeWidth;
|
||||
}
|
||||
}
|
||||
281
lib/pages/sleep_report/chart/HorizontalBarChart.dart
Normal file
281
lib/pages/sleep_report/chart/HorizontalBarChart.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:vbvs_app/common/util/FitTool.dart';
|
||||
import 'package:vbvs_app/common/util/MyUtils.dart';
|
||||
import 'package:vbvs_app/component/tool/ClickableContainer.dart';
|
||||
import 'package:vbvs_app/pages/device_bind/componnet/bind_dialog.dart';
|
||||
|
||||
class HorizontalBarChart extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> showLabel;
|
||||
final bool showPercent;
|
||||
|
||||
const HorizontalBarChart({
|
||||
super.key,
|
||||
required this.showLabel,
|
||||
this.showPercent = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = showLabel.map((item) {
|
||||
return BarData(
|
||||
label: item['name'],
|
||||
value: (item['percent'] ?? 0).toDouble(),
|
||||
color: item['color'] ?? Colors.grey,
|
||||
explain: item['explain'] ?? '',
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final double labelWidth = (MediaQuery.of(context).size.width * 0.5).clamp(
|
||||
MediaQuery.of(context).size.width * 0.08,
|
||||
MediaQuery.of(context).size.width * 0.22);
|
||||
|
||||
final double barHeight = 24.0.rpx;
|
||||
final double barSpacing = 40.0.rpx;
|
||||
final totalHeight = data.length * (barHeight + barSpacing) + 30.rpx;
|
||||
|
||||
return SizedBox(
|
||||
height: totalHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧标签列
|
||||
SizedBox(
|
||||
width: labelWidth,
|
||||
child: ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bar = data[index];
|
||||
return Container(
|
||||
height: barHeight + barSpacing,
|
||||
alignment: Alignment.centerRight,
|
||||
child: LabelWithSvg(
|
||||
label: bar.label,
|
||||
explain: bar.explain,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 16.rpx,
|
||||
),
|
||||
// 右侧柱状图区
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 网格线背景层
|
||||
CustomPaint(
|
||||
size: Size(double.infinity, totalHeight),
|
||||
painter: GridPainter(totalHeight: totalHeight),
|
||||
),
|
||||
|
||||
// 柱状图列表
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bar = data[index];
|
||||
return SizedBox(
|
||||
height: barHeight + barSpacing,
|
||||
child: CustomPaint(
|
||||
painter: SingleBarPainter(
|
||||
value: bar.value,
|
||||
color: bar.color,
|
||||
showPercent: showPercent,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BarData {
|
||||
final String label;
|
||||
final double value;
|
||||
final Color color;
|
||||
final String explain;
|
||||
|
||||
BarData({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.explain,
|
||||
});
|
||||
}
|
||||
|
||||
class LabelWithSvg extends StatelessWidget {
|
||||
final String label;
|
||||
final String explain;
|
||||
|
||||
const LabelWithSvg({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.explain,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle =
|
||||
TextStyle(color: themeController.currentColor.sc3, fontSize: 26.rpx);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// SizedBox(width: 8.rpx),
|
||||
ClickableContainer(
|
||||
backgroundColor: Colors.transparent,
|
||||
highlightColor: Colors.white,
|
||||
padding:
|
||||
EdgeInsetsDirectional.fromSTEB(14.rpx, 14.rpx, 14.rpx, 14.rpx),
|
||||
borderRadius: 0.rpx,
|
||||
onTap: () {
|
||||
// Get.toNamed("/deviceShareListPage", arguments: explain);
|
||||
showTipDialog(
|
||||
context,
|
||||
Container(
|
||||
child: Text(
|
||||
explain,
|
||||
style: TextStyle(
|
||||
fontSize: 26.rpx,
|
||||
color: themeController.currentColor.sc3,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 17.rpx,
|
||||
height: 17.rpx,
|
||||
child: SvgPicture.asset(
|
||||
'assets/img/icon/explain.svg',
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SingleBarPainter extends CustomPainter {
|
||||
final double value;
|
||||
final Color color;
|
||||
final bool showPercent;
|
||||
final double maxValue = 100;
|
||||
|
||||
SingleBarPainter({
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.showPercent,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final barHeight = 24.0.rpx;
|
||||
final rightPadding = 20.0.rpx;
|
||||
final chartWidth = size.width - rightPadding;
|
||||
|
||||
final left = 0.0;
|
||||
final right = left + (value / maxValue) * chartWidth;
|
||||
final rect = Rect.fromLTWH(
|
||||
left, (size.height - barHeight) / 2, right - left, barHeight);
|
||||
final paint = Paint()..color = color;
|
||||
canvas.drawRect(rect, paint);
|
||||
|
||||
if (showPercent) {
|
||||
final textStyle =
|
||||
TextStyle(color: themeController.currentColor.sc3, fontSize: 26.rpx);
|
||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||
textPainter.text =
|
||||
TextSpan(text: '${value.toStringAsFixed(0)}%', style: textStyle);
|
||||
textPainter.layout();
|
||||
canvas.save();
|
||||
canvas.clipRect(Rect.fromLTWH(
|
||||
left, (size.height - barHeight) / 2, chartWidth, barHeight));
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(right + 4.0.rpx, (size.height - textPainter.height) / 2),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class GridPainter extends CustomPainter {
|
||||
final double totalHeight;
|
||||
final int gridCount = 5;
|
||||
final double maxValue = 100;
|
||||
final double bottomPadding = 30.0.rpx;
|
||||
|
||||
GridPainter({required this.totalHeight});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final Paint gridPaint = Paint()
|
||||
..color = Colors.grey.withOpacity(0.3)
|
||||
..strokeWidth = 1.0.rpx;
|
||||
|
||||
final double rightPadding = 20.0.rpx;
|
||||
final double chartWidth = size.width - rightPadding;
|
||||
final double chartHeight = totalHeight - bottomPadding;
|
||||
|
||||
for (int i = 0; i <= gridCount; i++) {
|
||||
double dx = (i / gridCount) * chartWidth;
|
||||
_drawDashedLine(
|
||||
canvas, Offset(dx, 0), Offset(dx, chartHeight), gridPaint);
|
||||
|
||||
final percent = (i / gridCount) * maxValue;
|
||||
final TextPainter textPainter = TextPainter(
|
||||
textDirection: TextDirection.ltr,
|
||||
text: TextSpan(
|
||||
text: '${percent.toInt()}',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 18.rpx),
|
||||
),
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas, Offset(dx - textPainter.width / 2, chartHeight + 4.0.rpx));
|
||||
}
|
||||
}
|
||||
|
||||
void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
|
||||
const double dashWidth = 6.0;
|
||||
const double dashSpace = 4.0;
|
||||
|
||||
final double totalHeight = (end.dy - start.dy).abs();
|
||||
double currentY = start.dy;
|
||||
|
||||
while (currentY < end.dy) {
|
||||
final double nextY = currentY + dashWidth;
|
||||
canvas.drawLine(
|
||||
Offset(start.dx, currentY),
|
||||
Offset(start.dx, nextY > end.dy ? end.dy : nextY),
|
||||
paint,
|
||||
);
|
||||
currentY = nextY + dashSpace;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
252
lib/pages/sleep_report/chart/LineChartByRange.dart
Normal file
252
lib/pages/sleep_report/chart/LineChartByRange.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
import 'dart:math';
|
||||
|
||||
class LineChartByRange extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> showLabel;
|
||||
final int startTime;
|
||||
final int endTime;
|
||||
|
||||
const LineChartByRange({
|
||||
Key? key,
|
||||
required this.showLabel,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (showLabel.isEmpty) return const SizedBox();
|
||||
|
||||
int maxTimes =
|
||||
showLabel.map((e) => e['times'] ?? 0).reduce((a, b) => a > b ? a : b);
|
||||
int yMax = (maxTimes / 10).ceil() * 10;
|
||||
if (yMax == 0) yMax = 10;
|
||||
|
||||
DateTime minTime = DateTime.fromMillisecondsSinceEpoch(startTime);
|
||||
DateTime maxTime = DateTime.fromMillisecondsSinceEpoch(endTime);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 500.rpx,
|
||||
child: CustomPaint(
|
||||
size: Size(double.infinity, 500.rpx),
|
||||
painter: _LineChartByRangePainter(
|
||||
data: showLabel,
|
||||
yMax: yMax,
|
||||
minTime: minTime,
|
||||
maxTime: maxTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LineChartByRangePainter extends CustomPainter {
|
||||
final List<Map<String, dynamic>> data;
|
||||
final int yMax;
|
||||
final DateTime minTime;
|
||||
final DateTime maxTime;
|
||||
|
||||
_LineChartByRangePainter({
|
||||
required this.data,
|
||||
required this.yMax,
|
||||
required this.minTime,
|
||||
required this.maxTime,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
double padding = 20.rpx;
|
||||
double labelInset = 12.rpx; // X轴标签缩进距离
|
||||
|
||||
// 绘图X轴起止点,考虑内缩labelInset
|
||||
final double xStart = padding + labelInset;
|
||||
final double xEnd = size.width - padding - labelInset;
|
||||
final double chartWidth = xEnd - xStart;
|
||||
|
||||
double chartHeight = size.height - 30.rpx;
|
||||
|
||||
int totalDuration =
|
||||
maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch;
|
||||
if (totalDuration <= 0) return;
|
||||
|
||||
Paint linePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3.rpx
|
||||
..color = stringToColor("#00C1AA")
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
Paint fillCirclePaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = stringToColor("#00C1AA");
|
||||
|
||||
// 1. 先绘制数据线段及起止点圆点
|
||||
for (var item in data) {
|
||||
int start = item['startTime'];
|
||||
int end = item['endTime'];
|
||||
int times = item['times'];
|
||||
|
||||
double startX = xStart +
|
||||
chartWidth * (start - minTime.millisecondsSinceEpoch) / totalDuration;
|
||||
double endX = xStart +
|
||||
chartWidth * (end - minTime.millisecondsSinceEpoch) / totalDuration;
|
||||
double y = chartHeight * (1 - times / yMax);
|
||||
|
||||
// 画线段
|
||||
canvas.drawLine(Offset(startX, y), Offset(endX, y), linePaint);
|
||||
|
||||
// 画起点圆点
|
||||
canvas.drawCircle(Offset(startX, y), 4.rpx, fillCirclePaint);
|
||||
|
||||
// 画终点圆点
|
||||
canvas.drawCircle(Offset(endX, y), 4.rpx, fillCirclePaint);
|
||||
}
|
||||
|
||||
// 2. Y轴辅助线及文字
|
||||
Paint axisPaint = Paint()
|
||||
..color = Colors.grey.withOpacity(0.4)
|
||||
..strokeWidth = 1.rpx;
|
||||
|
||||
for (int i = 0; i <= 6; i++) {
|
||||
double y = chartHeight * i / 6;
|
||||
|
||||
if (i == 6) {
|
||||
// 实线
|
||||
canvas.drawLine(Offset(xStart, y), Offset(xEnd, y), axisPaint);
|
||||
} else {
|
||||
// 虚线
|
||||
drawDashedLine(
|
||||
canvas,
|
||||
Offset(xStart, y),
|
||||
Offset(xEnd, y),
|
||||
axisPaint,
|
||||
dashWidth: 8.rpx,
|
||||
dashSpace: 6.rpx,
|
||||
);
|
||||
}
|
||||
|
||||
// Y轴文字
|
||||
TextPainter tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '${yMax - (yMax * i / 6).round()}',
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx, color: themeController.currentColor.sc4),
|
||||
),
|
||||
textDirection: ui.TextDirection.ltr,
|
||||
);
|
||||
tp.layout();
|
||||
tp.paint(canvas, Offset(0, y - tp.height / 2));
|
||||
}
|
||||
|
||||
// 3. X轴线
|
||||
canvas.drawLine(
|
||||
Offset(xStart, chartHeight), Offset(xEnd, chartHeight), axisPaint);
|
||||
|
||||
// 4. 画X轴时间点对应的垂直虚线辅助线
|
||||
int totalHours = maxTime.difference(minTime).inHours;
|
||||
int startHour = minTime.hour;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 画左侧完整时分 (HH:mm),往内缩 labelInset
|
||||
String leftLabel = DateFormat('HH:mm').format(minTime);
|
||||
TextPainter leftTp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: leftLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx,
|
||||
color: themeController.currentColor.sc4,
|
||||
),
|
||||
),
|
||||
textDirection: ui.TextDirection.ltr,
|
||||
);
|
||||
leftTp.layout();
|
||||
leftTp.paint(canvas,
|
||||
Offset(padding + labelInset - leftTp.width / 2, chartHeight + 8.rpx));
|
||||
|
||||
// 6. 画右侧完整时分 (HH:mm),往内缩 labelInset
|
||||
String rightLabel = DateFormat('HH:mm').format(maxTime);
|
||||
TextPainter rightTp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: rightLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx,
|
||||
color: themeController.currentColor.sc4,
|
||||
),
|
||||
),
|
||||
textDirection: ui.TextDirection.ltr,
|
||||
);
|
||||
rightTp.layout();
|
||||
rightTp.paint(
|
||||
canvas,
|
||||
Offset(size.width - padding - labelInset - rightTp.width / 2,
|
||||
chartHeight + 8.rpx));
|
||||
|
||||
// 7. 中间小时数字(23, 0, 1, 2, ...)
|
||||
for (int i = 1; i < totalHours; i++) {
|
||||
double x = xStart + chartWidth * i / totalHours;
|
||||
|
||||
int hourLabelNum = (startHour + i) % 24;
|
||||
String hourLabel = '$hourLabelNum';
|
||||
|
||||
TextPainter tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: hourLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 18.rpx,
|
||||
color: themeController.currentColor.sc4,
|
||||
),
|
||||
),
|
||||
textDirection: ui.TextDirection.ltr,
|
||||
);
|
||||
tp.layout();
|
||||
|
||||
tp.paint(canvas, Offset(x - tp.width / 2, chartHeight + 8.rpx));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
|
||||
void drawDashedLine(
|
||||
Canvas canvas,
|
||||
Offset start,
|
||||
Offset end,
|
||||
Paint paint, {
|
||||
required double dashWidth,
|
||||
required double dashSpace,
|
||||
}) {
|
||||
final dx = end.dx - start.dx;
|
||||
final dy = end.dy - start.dy;
|
||||
final distance = sqrt(dx * dx + dy * dy);
|
||||
final direction = Offset(dx / distance, dy / distance);
|
||||
|
||||
double drawn = 0;
|
||||
while (drawn < distance) {
|
||||
final from = start + direction * drawn;
|
||||
final to = start + direction * (drawn + dashWidth).clamp(0, distance);
|
||||
canvas.drawLine(from, to, paint);
|
||||
drawn += dashWidth + dashSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
lib/pages/sleep_report/chart/SegmentedCirclePainter.dart
Normal file
102
lib/pages/sleep_report/chart/SegmentedCirclePainter.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
final double value;
|
||||
|
||||
SegmentData({required this.color, required this.value});
|
||||
}
|
||||
|
||||
class SegmentedCirclePainter extends CustomPainter {
|
||||
final List<SegmentData> segments;
|
||||
final double strokeWidth;
|
||||
final double gapAngle; // 每段之间的间隔角度(单位:度)
|
||||
|
||||
SegmentedCirclePainter({
|
||||
required this.segments,
|
||||
this.strokeWidth = 6.0,
|
||||
this.gapAngle = 4.0,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double radius = size.width / 2;
|
||||
final Offset center = Offset(size.width / 2, size.height / 2);
|
||||
|
||||
final Paint paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.square;
|
||||
|
||||
final double totalValue = segments.fold(0, (sum, item) => sum + item.value);
|
||||
final double totalGap = gapAngle * segments.length;
|
||||
final double totalDrawAngle = 360.0 - totalGap;
|
||||
|
||||
double startAngle = -90.0; // 从顶部开始
|
||||
|
||||
for (var segment in segments) {
|
||||
final double sweepAngle = (segment.value / totalValue) * totalDrawAngle;
|
||||
|
||||
paint.color = segment.color;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius - strokeWidth / 2),
|
||||
radians(startAngle),
|
||||
radians(sweepAngle),
|
||||
false,
|
||||
paint,
|
||||
);
|
||||
startAngle += sweepAngle + gapAngle;
|
||||
}
|
||||
}
|
||||
|
||||
double radians(double degrees) => degrees * 3.1415926 / 180;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class SegmentedCircleWithCenterWidget extends StatelessWidget {
|
||||
final List<SegmentData> segments;
|
||||
final double strokeWidth;
|
||||
final double gapAngle;
|
||||
final Widget centerWidget;
|
||||
|
||||
const SegmentedCircleWithCenterWidget({
|
||||
Key? key,
|
||||
required this.segments,
|
||||
this.strokeWidth = 6.0,
|
||||
this.gapAngle = 4.0,
|
||||
required this.centerWidget,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size(200, 200), // 设置圆的尺寸
|
||||
painter: SegmentedCirclePainter(
|
||||
segments: segments,
|
||||
strokeWidth: strokeWidth,
|
||||
gapAngle: gapAngle,
|
||||
),
|
||||
),
|
||||
centerWidget, // 放置自定义的中心 Widget
|
||||
Positioned(
|
||||
right: 60.rpx, // 放置在右侧
|
||||
child: SvgPicture.asset(
|
||||
'assets/img/icon/add.svg',
|
||||
width: 14.rpx,
|
||||
height: 22.rpx,
|
||||
color: themeController.currentColor.sc9,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/pages/sleep_report/chart/StatusBarWithIndicator.dart
Normal file
112
lib/pages/sleep_report/chart/StatusBarWithIndicator.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:vbvs_app/common/util/FitTool.dart';
|
||||
|
||||
class StatusBarWithIndicator extends StatelessWidget {
|
||||
final int selectKey;
|
||||
final List<Map<String, dynamic>> showLabel;
|
||||
final IconData icon;
|
||||
final double gap; // 每段之间的间距
|
||||
|
||||
const StatusBarWithIndicator({
|
||||
super.key,
|
||||
required this.selectKey,
|
||||
required this.showLabel,
|
||||
this.icon = Icons.favorite,
|
||||
this.gap = 8.0, // 默认 8.rpx 间距
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
final itemCount = showLabel.length;
|
||||
|
||||
// 每条线的宽度 = (总宽度 - 总间隔)/ 项数
|
||||
final totalGap = (itemCount - 1) * gap.rpx;
|
||||
final itemWidth = (totalWidth - totalGap) / itemCount;
|
||||
|
||||
// 找到选中项的 index
|
||||
final selectedIndex = showLabel.indexWhere((e) => e['key'] == selectKey);
|
||||
final iconLeft = selectedIndex >= 0
|
||||
? selectedIndex * (itemWidth + gap.rpx) + itemWidth / 2
|
||||
: 0.0;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (selectedIndex >= 0)
|
||||
Positioned(
|
||||
left: iconLeft,
|
||||
top: -20.rpx,
|
||||
child: Transform.translate(
|
||||
offset: Offset(-22.5.rpx, 0), // 图片宽度 45.rpx,居中偏移
|
||||
child: Container(
|
||||
width: 45.rpx,
|
||||
height: 76.rpx,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/img/tip_arrow.gif'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 50.rpx),
|
||||
child: Column(
|
||||
children: [
|
||||
// 条形段(带间距)
|
||||
Row(
|
||||
children: showLabel.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var item = entry.value;
|
||||
|
||||
return Container(
|
||||
width: itemWidth,
|
||||
height: 15.rpx,
|
||||
margin: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : gap.rpx,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item['color'],
|
||||
borderRadius: BorderRadius.circular(0.rpx),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
SizedBox(height: 12.rpx),
|
||||
// 名称文字
|
||||
Row(
|
||||
children: showLabel.asMap().entries.map((entry) {
|
||||
int index = entry.key;
|
||||
var item = entry.value;
|
||||
|
||||
return Container(
|
||||
width: itemWidth,
|
||||
margin: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : gap.rpx,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
item['name'],
|
||||
style: TextStyle(
|
||||
fontSize: 24.rpx,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
57
lib/pages/sleep_report/chart/VerticalBarList.dart
Normal file
57
lib/pages/sleep_report/chart/VerticalBarList.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:vbvs_app/common/util/FitTool.dart';
|
||||
|
||||
class VerticalBarList extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> showLabel;
|
||||
final double maxBarHeight;
|
||||
final double barWidth;
|
||||
|
||||
const VerticalBarList({
|
||||
super.key,
|
||||
required this.showLabel,
|
||||
this.maxBarHeight = 100.0, // 柱子的最大高度
|
||||
this.barWidth = 20.0, // 每根柱子的宽度
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: showLabel.map((item) {
|
||||
final percent = item['percent'] ?? 0;
|
||||
final color = item['color'] ?? Colors.grey;
|
||||
final name = item['name'] ?? '';
|
||||
|
||||
final barHeight = maxBarHeight * (percent / 100.0);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.rpx),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 柱子
|
||||
Container(
|
||||
width: barWidth.rpx,
|
||||
height: barHeight.rpx,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4.rpx),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.rpx),
|
||||
// 文字竖排
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 24.rpx,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user