Files
tuiche/lib/pages/sleep_report/chart/DotBarChart.dart
2025-05-27 23:09:31 +08:00

454 lines
15 KiB
Dart

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;
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.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);
DateTime startDate = DateTime.fromMillisecondsSinceEpoch(widget.startTime);
DateTime endDate = DateTime.fromMillisecondsSinceEpoch(widget.endTime);
// 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));
}
// 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,
});
}
}
}
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,
top: y - 20.rpx,
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: CustomPaint(
size: Size(double.infinity, xAxisHeight),
painter: _XAxisPainter(
hourLabels: hourLabels,
textColor: themeController.currentColor.sc4,
fontSize: 18.rpx,
startTime: widget.startTime,
endTime: widget.endTime,
),
),
),
),
],
),
);
}
}
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;
}
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;
}