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}