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}