Files
tuiche/lib/pages/sleep_report/chart/DotBarChart.dart
2025-05-20 14:00:44 +08:00

409 lines
13 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';
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;
}