main
1import 'dart:math' as math;
2import 'dart:ui';
3
4import 'package:blood_pressure_app/model/blood_pressure_analyzer.dart';
5import 'package:blood_pressure_app/model/storage/settings_store.dart';
6import 'package:collection/collection.dart';
7import 'package:flutter/material.dart';
8import 'package:health_data_store/health_data_store.dart';
9import 'package:provider/provider.dart';
10
11/// A graph that displays the averages blood pressure values across by time in
12/// the familiar shape of a clock.
13class ClockBpGraph extends StatelessWidget {
14 /// Create a clock shaped graph of average by time.
15 const ClockBpGraph({super.key, required this.measurements});
16
17 /// All measurements used to generate the graph.
18 final List<BloodPressureRecord> measurements;
19
20 @override
21 Widget build(BuildContext context) {
22 final analyzer = BloodPressureAnalyser(measurements);
23 final groups = analyzer.groupAnalysers();
24 return SizedBox.square(
25 dimension: MediaQuery.of(context).size.width,
26 child: Padding(
27 padding: const EdgeInsets.all(24.0),
28 child: CustomPaint(
29 painter: _RadarChartPainter(
30 brightness: Theme.of(context).brightness,
31 labels: List.generate(groups.length, (i) => i.toString()),
32 values: [
33 (context.watch<Settings>().sysColor, groups
34 .map((e) => (e.avgSys ?? analyzer.avgSys)?.mmHg ?? 0).toList(growable: false)),
35 (context.watch<Settings>().diaColor, groups
36 .map((e) => (e.avgDia ?? analyzer.avgDia)?.mmHg ?? 0).toList(growable: false)),
37 (context.watch<Settings>().pulColor, groups
38 .map((e) => e.avgPul ?? analyzer.avgPul ?? 0).toList(growable: false)),
39 ]
40 ),
41 ),
42 ),
43 );
44 }
45}
46
47class _RadarChartPainter extends CustomPainter {
48 /// Create a new radar chart painter.
49 ///
50 /// Each value must be as many data points as there are labels.
51 _RadarChartPainter({
52 required this.brightness,
53 required this.labels,
54 required this.values,
55 }) : assert(labels.length >= 3),
56 assert(!values.any((v) => v.$2.length != labels.length)) {
57 _maxValue = values.map((v) =>v.$2).flattened.max;
58 }
59
60 final Brightness brightness;
61
62 final List<String> labels;
63
64 final List<(Color, List<int>)> values;
65
66 /// Highest number in [values].
67 late final int _maxValue;
68
69 static const double _kPadding = 20.0;
70 static const double _kHelperCircleInterval = 60.0;
71
72 @override
73 void paint(Canvas canvas, Size size) {
74 final decoPaint = Paint()
75 ..style = PaintingStyle.stroke
76 ..strokeWidth = 3.0
77 ..color = (brightness == Brightness.dark ? Colors.white : Colors.black).withOpacity(0.3);
78
79 final maxRadius = size.shortestSide / 2;
80
81 // static decorations
82 double circleRadius = maxRadius - _kPadding;
83 while (circleRadius > 10.0) {
84 canvas.drawCircle(size.center(Offset.zero), circleRadius, decoPaint);
85 circleRadius -= _kHelperCircleInterval;
86 }
87
88 // compute directions & add remaining decorations
89 const fullCircleCircumference = 2 * math.pi;
90 final sectionWidthDeg = fullCircleCircumference / labels.length;
91 final List<double> angles = [];
92 for (int i = 0; i < labels.length; i++) {
93 angles.add(i * sectionWidthDeg);
94 canvas.drawLine(
95 size.center(Offset.zero),
96 size.center(_offset(i * sectionWidthDeg, maxRadius)),
97 decoPaint,
98 );
99 }
100
101 // draw content
102 for (final dataRow in values) {
103 Path? path;
104 for (int i = 0; i < labels.length; i++) {
105 final pos = size.center(_offsetFromValue(angles[i], maxRadius, dataRow.$2[i]));
106 if (path == null) {
107 path = Path();
108 path.moveTo(pos.dx, pos.dy);
109 } else {
110 path.lineTo(pos.dx, pos.dy);
111 }
112 }
113 final startPos = size.center(_offsetFromValue(angles[0], maxRadius, dataRow.$2[0]));
114 path!.lineTo(startPos.dx, startPos.dy); // connect to start
115
116 canvas.drawPath(path, Paint() // fill
117 ..color = dataRow.$1.withOpacity(0.4));
118 canvas.drawPath(path, Paint() // stroke around
119 ..color = dataRow.$1
120 ..strokeWidth = 5.0
121 ..strokeJoin = StrokeJoin.round
122 ..style = PaintingStyle.stroke);
123 }
124
125 // draw labels on top of content
126 final textStyle = TextStyle(
127 color: brightness == Brightness.dark ? Colors.white : Colors.black,
128 backgroundColor: (brightness == Brightness.dark ? Colors.black : Colors.white).withOpacity(0.6),
129 fontSize: 24.0
130 );
131 for (int i = 0; i < labels.length; i += 2) {
132 _drawTextInsideBounds(canvas, i * sectionWidthDeg, size, labels[i], textStyle);
133 }
134 }
135
136 /// Draws a given [text] at the end of [angle], but withing [size].
137 void _drawTextInsideBounds(Canvas canvas, double angle, Size size, String text, TextStyle style) {
138 final builder = ParagraphBuilder(style.getParagraphStyle());
139 builder.pushStyle(style.getTextStyle());
140 builder.addText(text);
141 final paragraph = builder.build();
142 paragraph.layout(ParagraphConstraints(width: size.width));
143
144 Offset off = _offset(angle, size.shortestSide / 2);
145 off = size.center(off);
146 // center at pos
147 off = Offset(off.dx - (paragraph.minIntrinsicWidth / 2), off.dy - (paragraph.height / 2));
148 if ((off.dy + paragraph.height) > size.height) { // right overflow
149 off = Offset(off.dx, off.dy - ((off.dy + paragraph.height) - size.height));
150 }
151 if ((off.dx + paragraph.minIntrinsicWidth) > size.width) { // right overflow
152 off = Offset(off.dx - ((off.dx + paragraph.minIntrinsicWidth) - size.width), off.dy);
153 }
154
155 canvas.drawParagraph(paragraph, off);
156 }
157
158 Offset _offsetFromValue(double angle, double fullRadius, int value) {
159 final percent = value / _maxValue;
160 final r = fullRadius * percent;
161 return _offset(angle, r);
162 }
163
164 /// Rotate so up is 0deg and transform to [Offset] from center.
165 Offset _offset(double angle, double radius) => Offset(
166 radius * math.cos(angle - 0.5 * math.pi),
167 radius * math.sin(angle - 0.5 * math.pi),
168 );
169
170 @override
171 bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _RadarChartPainter
172 || oldDelegate.brightness != brightness
173 || oldDelegate.labels != labels
174 || oldDelegate.values != values;
175}