更新
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;
|
||||
}
|
||||
Reference in New Issue
Block a user