main
1import 'dart:math' as math;
2import 'dart:ui' as ui;
3
4import 'package:blood_pressure_app/model/horizontal_graph_line.dart';
5import 'package:blood_pressure_app/model/storage/storage.dart';
6import 'package:blood_pressure_app/screens/loading_screen.dart';
7import 'package:collection/collection.dart';
8import 'package:flutter/material.dart';
9import 'package:blood_pressure_app/l10n/app_localizations.dart';
10import 'package:health_data_store/health_data_store.dart';
11import 'package:intl/intl.dart';
12import 'package:provider/provider.dart';
13
14/// A graph of [BloodPressureRecord] values.
15///
16/// Note that this can't follow the users preferred unit as this would not allow
17/// to put all data on one graph
18class BloodPressureValueGraph extends StatelessWidget {
19 /// Create a new graph of [BloodPressureRecord] values.
20 const BloodPressureValueGraph({super.key, // TODO: intakes ??
21 required this.records,
22 required this.colors,
23 required this.intakes,
24 });
25
26 /// Data to draw lines and determine decorations from.
27 ///
28 /// Must be more than two and sorted.
29 final List<BloodPressureRecord> records;
30
31 /// Notes that should render as colored lines if present.
32 final List<Note> colors;
33
34 /// Intake dates get painted as tiny colored medicines at the bottom of the
35 /// graph.
36 final List<MedicineIntake> intakes;
37
38 @override
39 Widget build(BuildContext context) {
40 if (records.sysGraph().length < 2
41 && records.diaGraph().length < 2
42 && records.pulGraph().length < 2) {
43 return Center(
44 child: Text(AppLocalizations.of(context)!.errNotEnoughDataToGraph),
45 );
46 }
47 final r = records.toList();
48 r.sort((a, b) => a.time.compareTo(b.time));
49 final settings = context.watch<Settings>();
50 return Padding(
51 padding: const EdgeInsets.only(top: 4.0),
52 child: TweenAnimationBuilder(
53 tween: Tween(begin: 0.0, end: 1.0),
54 curve: Curves.slowMiddle,
55 duration: Duration(milliseconds: settings.animationSpeed),
56 builder: (BuildContext context, double value, Widget? child) => CustomPaint(
57 painter: _ValueGraphPainter(
58 brightness: Theme.of(context).brightness,
59 settings: settings,
60 labelStyle: Theme.of(context).textTheme.bodySmall ?? TextStyle(),
61 records: r,
62 colors: colors,
63 progress: value,
64 intakes: intakes,
65 ),
66 ),
67 ),
68 );
69 }
70}
71
72class _ValueGraphPainter extends CustomPainter {
73 _ValueGraphPainter({
74 required this.brightness,
75 required this.settings,
76 required this.labelStyle,
77 required this.records,
78 required this.colors,
79 required this.intakes,
80 required this.progress,
81 }): assert(1.0 >= progress && progress >= 0.0);
82
83 final Settings settings;
84
85 final ui.Brightness brightness;
86
87 final TextStyle labelStyle;
88
89 /// Ordered list of all records to display.
90 ///
91 /// Must be at least 2 records long.
92 final List<BloodPressureRecord> records;
93
94 final List<Note> colors;
95
96 final List<MedicineIntake> intakes;
97
98 static const double _kLeftLegendWidth = 35.0;
99 static const double _kBottomLegendHeight = 50.0;
100
101 /// Percentage of data line rendering (from 0 to 1).
102 final double progress;
103
104 void _paintDecorations(Canvas canvas, Size size, DateTimeRange range, double minY, double maxY) {
105 assert(size.width > _kLeftLegendWidth && size.height > _kBottomLegendHeight);
106
107 final graphBorderLinesPaint = Paint()
108 ..color = brightness == Brightness.dark ? Colors.white : Colors.black
109 ..strokeWidth = 1.0
110 ..strokeCap = ui.StrokeCap.round;
111 final graphDecoLinesPaint = Paint()
112 ..color = brightness == Brightness.dark ? Colors.white60 : Colors.black45
113 ..strokeCap = ui.StrokeCap.round
114 ..strokeJoin = ui.StrokeJoin.round;
115
116 // draw border
117 final bottomLeftOfGraph = Offset(_kLeftLegendWidth, size.height - _kBottomLegendHeight);
118 canvas.drawLine(bottomLeftOfGraph, Offset(size.width, size.height - _kBottomLegendHeight), graphBorderLinesPaint);
119 canvas.drawLine(bottomLeftOfGraph, Offset(_kLeftLegendWidth, 0.0), graphBorderLinesPaint);
120
121 final labelTextHeight = ((labelStyle.height ?? 1.0) * (labelStyle.fontSize ?? 14.0));
122 (){
123 // calculate vertical decoration positions
124 final double drawHeight = size.height - _kBottomLegendHeight;
125
126 final leftLabelHeight = labelTextHeight + 4.0; // padding
127 final leftLabelWidth = _kLeftLegendWidth - 6.0 - 2.0;
128 final leftLegendLabelCount = drawHeight / leftLabelHeight;
129
130 // draw vertical decorations
131 for (int i = 0; i < leftLegendLabelCount; i += 2) {
132 final h = (size.height - _kBottomLegendHeight) - i * leftLabelHeight;
133 canvas.drawLine(
134 Offset(_kLeftLegendWidth - 5.0, h),
135 Offset(size.width, h),
136 graphDecoLinesPaint,
137 );
138 final labelY = minY + ((maxY - minY) / leftLegendLabelCount) * i;
139
140 final paragraph = _paragraph(ui.TextAlign.end, labelY.round().toString(), leftLabelWidth);
141 canvas.drawParagraph(paragraph, ui.Offset(2.0, h - (leftLabelHeight / 2)));
142 }
143 }();
144
145 // calculate horizontal decoration positions
146 final double drawWidth = size.width - _kLeftLegendWidth;
147 int bottomLabelCount = 20;
148 Duration? stepDuration;
149 late DateFormat format;
150 while (stepDuration == null && bottomLabelCount > 4) {
151 stepDuration = range.duration ~/ bottomLabelCount;
152 format = switch(stepDuration) {
153 < const Duration(hours: 4) => DateFormat('HH:mm EEE'),
154 < const Duration(days: 1) => DateFormat('EEE'),
155 < const Duration(days: 5) => DateFormat('dd'),
156 < const Duration(days: 30) => DateFormat('MMM, dd'),
157 < const Duration(days: 30*6) => DateFormat('MMM yyyy'),
158 _ => DateFormat('yyyy'),
159 };
160 // Approximate total width needed with duration
161 final paragraph = _paragraph(ui.TextAlign.center, format.format(range.start), drawWidth);
162 final totalWidthOccupiedByLabels = bottomLabelCount * paragraph.minIntrinsicWidth;
163 if (totalWidthOccupiedByLabels > drawWidth) {
164 stepDuration = null;
165 bottomLabelCount--;
166 }
167 }
168
169 // -> Graph to small to draw labels
170 if (stepDuration == null) return;
171
172 // draw horizontal decorations
173 final labelWidth = drawWidth / bottomLabelCount + 8.0;
174 for (int i = 0; i < bottomLabelCount; i += 2) {
175 final x = _kLeftLegendWidth + i * (drawWidth / bottomLabelCount);
176 canvas.drawLine(
177 Offset(x, 0),
178 Offset(x, size.height - _kBottomLegendHeight + 4.0),
179 graphDecoLinesPaint,
180 );
181 final text = format.format(range.start.add(stepDuration * i));
182 final paragraph = _paragraph(ui.TextAlign.center, text, labelWidth);
183 canvas.drawParagraph(paragraph, ui.Offset(
184 x - (labelWidth / 2),
185 size.height - _kBottomLegendHeight + (labelTextHeight / 2),
186 ));
187 }
188 }
189
190 void _paintLine(
191 Canvas canvas,
192 Size size,
193 Iterable<(DateTime, double)> data,
194 Color color,
195 DateTimeRange range,
196 double minY,
197 double maxY,
198 double? warnValue,
199 ) {
200 if (data.length < 2) return;
201
202 Path? path;
203 Path? warnPath = warnValue == null ? null : Path();
204 for (final e in data) {
205 final point = ui.Offset(_transformX(size, e.$1, range), _transformY(size, e.$2, minY, maxY));
206 if (path != null) {
207 path.lineTo(point.dx, point.dy);
208 warnPath?.lineTo(point.dx, point.dy);
209 } else {
210 path = Path();
211 path.moveTo(point.dx, point.dy);
212
213 // This must not cause #461, #482, or #487.
214 if ((warnValue ?? 0) > point.dy) {
215 warnPath?.moveTo(_kLeftLegendWidth, warnValue!);
216 warnPath?.lineTo(point.dx, point.dy);
217 } else {
218 warnPath?.moveTo(point.dx, point.dy);
219 }
220 }
221 }
222
223 if (path == null) return;
224 path = subPath(path, progress);
225
226 if (warnValue != null) {
227 assert(warnPath != null);
228
229 warnPath = subPath(warnPath!, progress);
230 warnPath.relativeLineTo(0, size.height);
231 warnPath.lineTo(_kLeftLegendWidth, size.height);
232
233 final y = _transformY(size, warnValue, minY, maxY);
234
235 final warnRect = Rect.fromLTRB(_kLeftLegendWidth, 0, size.width, y);
236 final clippedPath = Path.combine(
237 PathOperation.intersect,
238 warnPath,
239 Path()..addRect(warnRect),
240 );
241 canvas.drawPath(clippedPath, ui.Paint()
242 ..color = Colors.redAccent
243 ..maskFilter = ui.MaskFilter.blur(ui.BlurStyle.inner, 30.0)
244 ..style = ui.PaintingStyle.fill
245 );
246 }
247
248 final paint = Paint()
249 ..color = color
250 ..strokeWidth = settings.graphLineThickness
251 ..strokeCap = ui.StrokeCap.round
252 ..style = ui.PaintingStyle.stroke
253 ..strokeJoin = ui.StrokeJoin.round;
254 canvas.drawPath(path, paint);
255 }
256
257 // https://www.ncl.ac.uk/webtemplate/ask-assets/external/maths-resources/statistics/regression-and-correlation/simple-linear-regression.html
258 void _paintRegressionLine(Canvas canvas, Size size, List<(DateTime, double)> data, double minY, double maxY) {
259 final List<double> xValues = data.map((e) => e.$1.millisecondsSinceEpoch.toDouble()).toList();
260 final List<double> yValues = data.map((e) => e.$2).toList();
261
262 final double meanX = xValues.sum / data.length;
263 final double meanY = yValues.sum / data.length;
264
265 final slopeTop = data.fold(0.0, (double last, (DateTime, double) e) {
266 final xErr = e.$1.millisecondsSinceEpoch - meanX;
267 final yErr = e.$2 - meanY;
268 return last + xErr * yErr;
269 });
270 final slopeBtm = data.fold(0.0, (double last, (DateTime, double) e) {
271 final xErr = e.$1.millisecondsSinceEpoch - meanX;
272 return last + xErr * xErr;
273 });
274 final slope = slopeTop / slopeBtm;
275 final yIntercept = meanY - slope * meanX;
276
277 // Convert data points to canvas coordinates
278 final minX = xValues.reduce(math.min);
279 final maxX = xValues.reduce(math.max);
280
281 final start = ui.Offset(
282 _kLeftLegendWidth,
283 _transformY(size, slope * minX + yIntercept, minY, maxY),
284 );
285 final end = ui.Offset(
286 size.width,
287 _transformY(size, slope * maxX + yIntercept, minY, maxY),
288 );
289
290 final paint = Paint()
291 ..color = Colors.grey
292 ..strokeWidth = 3.0;
293 canvas.drawLine(start, end, paint);
294 }
295
296 void _paintHorizontalLines(Canvas canvas, Size size, List<HorizontalGraphLine> lines, double minY, double maxY) {
297 for (final line in lines) {
298 final y = _transformY(size, line.height.toDouble(), minY, maxY);
299 final path = Path();
300 double x = _kLeftLegendWidth;
301 bool drawNext = true;
302 while (x < size.width) { // Create dotted line
303 if (drawNext) {
304 final newX = x + 10;
305 path.moveTo(x, y);
306 path.lineTo(newX, y);
307 x = newX;
308 drawNext = false;
309 } else {
310 x += 5;
311 drawNext = true;
312 }
313 }
314 canvas.drawPath(
315 path,
316 ui.Paint()
317 ..style = ui.PaintingStyle.stroke
318 ..strokeWidth = 2
319 ..color = line.color,
320 );
321 }
322 }
323
324 void _buildNeedlePins(Canvas canvas, Size size, List<Note> colors, DateTimeRange range, double minY, double maxY) {
325 for (final color in colors.where((n) => n.color != null)) {
326 final x = _transformX(size, color.time, range);
327 canvas.drawLine(
328 ui.Offset(x, 0),
329 ui.Offset(x, size.height - _kBottomLegendHeight),
330 ui.Paint()
331 ..strokeWidth = settings.needlePinBarWidth
332 ..color = Color(color.color!).withOpacity(0.4),
333 );
334 }
335 }
336
337 void _buildIntakes(Canvas canvas, Size size, List<MedicineIntake> intakes, DateTimeRange range) {
338 for (final iTake in intakes) {
339 final x = _transformX(size, iTake.time, range);
340 final icon = Icons.medication;
341 final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
342 textPainter.text = TextSpan(
343 text: String.fromCharCode(icon.codePoint),
344 style: TextStyle(
345 fontFamily: icon.fontFamily,
346 fontSize: 18.0,
347 backgroundColor: brightness == ui.Brightness.dark ? Colors.black : Colors.white,
348 color: iTake.medicine.color == null ? null : Color(iTake.medicine.color!),
349 ),
350 );
351 textPainter.layout();
352 // Paint centered at the bottom line
353 final y = size.height -_kBottomLegendHeight - (textPainter.height / 2);
354 textPainter.paint(canvas, Offset(x, y));
355 }
356 }
357
358 @override
359 void paint(Canvas canvas, Size size) {
360 assert(records.length >= 2);
361 assert(records.isSorted((a, b) => a.time.compareTo(b.time)));
362
363 final DateTimeRange range = DateTimeRange(
364 start: records.first.time,
365 end: records.last.time, // TODO: fix intake, ... outside range
366 );
367
368 double min = double.infinity;
369 double max = double.negativeInfinity;
370 for (final r in records) {
371 if (r.sys != null && r.sys!.mmHg < min) { min = r.sys!.mmHg.toDouble(); }
372 if (r.dia != null && r.dia!.mmHg < min) { min = r.dia!.mmHg.toDouble(); }
373 if (r.pul != null && r.pul! < min) { min = r.pul!.toDouble(); }
374 if (r.sys != null && r.sys!.mmHg > max) { max = r.sys!.mmHg.toDouble(); }
375 if (r.dia != null && r.dia!.mmHg > max) { max = r.dia!.mmHg.toDouble(); }
376 if (r.pul != null && r.pul! > max) { max = r.pul!.toDouble(); }
377 }
378 for (final l in settings.horizontalGraphLines) {
379 max = math.max(l.height.toDouble(), max);
380 min = math.min(l.height.toDouble(), min);
381 }
382 assert(min != double.infinity);
383 assert(max != double.negativeInfinity);
384
385 _paintDecorations(canvas, size, range, min, max);
386
387 _buildIntakes(canvas, size, intakes, range);
388 _buildNeedlePins(canvas, size, colors, range, min, max);
389
390 _paintLine(canvas, size, records.sysGraph(), settings.sysColor, range, min, max, settings.sysWarn.toDouble());
391 _paintLine(canvas, size, records.diaGraph(), settings.diaColor, range, min, max, settings.diaWarn.toDouble());
392 _paintLine(canvas, size, records.pulGraph(), settings.pulColor, range, min, max, null);
393
394 if (settings.drawRegressionLines) {
395 _paintRegressionLine(canvas, size, records.sysGraph().toList(), min, max);
396 _paintRegressionLine(canvas, size, records.diaGraph().toList(), min, max);
397 }
398
399 _paintHorizontalLines(canvas, size, settings.horizontalGraphLines, min, max);
400 }
401
402 @override
403 bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _ValueGraphPainter
404 || oldDelegate.progress != progress
405 || oldDelegate.brightness != brightness
406 || oldDelegate.settings.sysColor != settings.sysColor
407 || oldDelegate.settings.diaColor != settings.diaColor
408 || oldDelegate.settings.graphLineThickness != settings.graphLineThickness
409 || oldDelegate.settings.pulColor != settings.pulColor
410 || oldDelegate.settings.sysWarn != settings.sysWarn
411 || oldDelegate.settings.diaWarn != settings.diaWarn
412 || oldDelegate.settings.drawRegressionLines != settings.drawRegressionLines
413 || oldDelegate.settings.needlePinBarWidth != settings.needlePinBarWidth
414 || oldDelegate.settings.horizontalGraphLines != settings.horizontalGraphLines
415 || oldDelegate.records != records
416 || oldDelegate.colors != colors
417 || oldDelegate.intakes != intakes;
418
419 /// Transforms an untransformed [y] graph value to correct y-position on a
420 /// canvas of [size].
421 double _transformY(Size size, double y, double minY, double maxY) {
422 final height = size.height - _kBottomLegendHeight;
423 final double factorY = height / (maxY - minY);
424 final yBottom = _kBottomLegendHeight + (y - minY) * factorY;
425 return size.height - yBottom;
426 }
427
428 /// Transforms an untransformed [x] graph position to correct x-position on a
429 /// canvas of [size].
430 double _transformX(Size size, DateTime x, DateTimeRange range) {
431 final width = size.width - _kLeftLegendWidth;
432 final double factorX = width / range.duration.inMilliseconds;
433 final offset = x.millisecondsSinceEpoch - range.start.millisecondsSinceEpoch;
434 return _kLeftLegendWidth + offset * factorX;
435 }
436
437 /// Create and align a paragraph builder
438 ui.Paragraph _paragraph(ui.TextAlign textAlign, String text, double width) {
439 final paragraphBuilder = ui.ParagraphBuilder(
440 labelStyle.getParagraphStyle(textAlign: textAlign),
441 )
442 ..pushStyle(labelStyle.getTextStyle())
443 ..addText(text);
444
445 final paragraph = paragraphBuilder.build();
446 paragraph.layout(ui.ParagraphConstraints(width: width));
447 return paragraph;
448 }
449}
450
451/// Create graph data from a list of blood pressure records.
452extension GraphData on List<BloodPressureRecord> {
453 /// Get the timestamps and mmHg values of all non-null sys values.
454 Iterable<(DateTime, double)> sysGraph() => map((r) => (r.time, r.sys?.mmHg.toDouble()))
455 .whereNot(((DateTime, double?) e) => e.$2 == null)
456 .cast<(DateTime, double)>();
457 /// Get the timestamps and mmHg values of all non-null dia values.
458 Iterable<(DateTime, double)> diaGraph() => map((r) => (r.time, r.dia?.mmHg.toDouble()))
459 .whereNot(((DateTime, double?) e) => e.$2 == null)
460 .cast<(DateTime, double)>();
461 /// Get the timestamps and values as doubles of all non-null pul values.
462 Iterable<(DateTime, double)> pulGraph() => map((r) => (r.time, r.pul?.toDouble()))
463 .whereNot(((DateTime, double?) e) => e.$2 == null)
464 .cast<(DateTime, double)>();
465}