Commit 2b1ac65

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-08-25 09:53:50
Reimplement radar chart (#393)
* implement radar chart Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> * implement ClockBpGraph Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> * remove old graph code Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> * test ClockBpGraph Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> * implement shouldRepaint for _RadarChartPainter Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> --------- Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent 32d7c39
app/lib/features/statistics/clock_bp_graph.dart
@@ -0,0 +1,175 @@
+import 'dart:math' as math;
+import 'dart:ui';
+
+import 'package:blood_pressure_app/model/blood_pressure_analyzer.dart';
+import 'package:blood_pressure_app/model/storage/settings_store.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:health_data_store/health_data_store.dart';
+import 'package:provider/provider.dart';
+
+/// A graph that displays the averages blood pressure values across by time in
+/// the familiar shape of a clock.
+class ClockBpGraph extends StatelessWidget {
+  /// Create a clock shaped graph of average by time.
+  const ClockBpGraph({super.key, required this.measurements});
+
+  /// All measurements used to generate the graph.
+  final List<BloodPressureRecord> measurements;
+
+  @override
+  Widget build(BuildContext context) {
+    final analyzer = BloodPressureAnalyser(measurements);
+    final groups = analyzer.groupAnalysers();
+    return SizedBox.square(
+      dimension: MediaQuery.of(context).size.width,
+      child: Padding(
+        padding: EdgeInsets.all(24.0),
+        child: CustomPaint(
+          painter: _RadarChartPainter(
+            brightness: Theme.of(context).brightness,
+            labels: List.generate(groups.length, (i) => i.toString()),
+            values: [
+              (context.watch<Settings>().sysColor, groups
+                .map((e) => (e.avgSys ?? analyzer.avgSys)?.mmHg ?? 0).toList(growable: false)),
+              (context.watch<Settings>().diaColor, groups
+                .map((e) => (e.avgDia ?? analyzer.avgDia)?.mmHg ?? 0).toList(growable: false)),
+              (context.watch<Settings>().pulColor, groups
+                .map((e) => e.avgPul ?? analyzer.avgPul ?? 0).toList(growable: false)),
+            ]
+          ),
+        ),
+      ),
+  );
+  }
+}
+
+class _RadarChartPainter extends CustomPainter {
+  /// Create a new radar chart painter.
+  ///
+  /// Each value must be as many data points as there are labels.
+  _RadarChartPainter({
+    required this.brightness,
+    required this.labels,
+    required this.values,
+  }) : assert(labels.length >= 3),
+       assert(!values.any((v) => v.$2.length != labels.length)) {
+    _maxValue = values.map((v) =>v.$2).flattened.max;
+  }
+
+  final Brightness brightness;
+
+  final List<String> labels;
+
+  final List<(Color, List<int>)> values;
+
+  /// Highest number in [values].
+  late final int _maxValue;
+
+  static const double _kPadding = 20.0;
+  static const double _kHelperCircleInterval = 60.0;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final decoPaint = Paint()
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 3.0
+      ..color = (brightness == Brightness.dark ? Colors.white : Colors.black).withOpacity(0.3);
+
+    final maxRadius = size.shortestSide / 2;
+
+    // static decorations
+    double circleRadius = maxRadius - _kPadding;
+    while (circleRadius > 10.0) {
+      canvas.drawCircle(size.center(Offset.zero), circleRadius, decoPaint);
+      circleRadius -= _kHelperCircleInterval;
+    }
+
+    // compute directions & add remaining decorations
+    const fullCircleCircumference = 2 * math.pi;
+    final sectionWidthDeg = fullCircleCircumference / labels.length;
+    final List<double> angles = [];
+    for (int i = 0; i < labels.length; i++) {
+      angles.add(i * sectionWidthDeg);
+      canvas.drawLine(
+        size.center(Offset.zero),
+        size.center(_offset(i * sectionWidthDeg, maxRadius)),
+        decoPaint,
+      );
+    }
+
+    // draw content
+    for (final dataRow in values) {
+      Path? path;
+      for (int i = 0; i < labels.length; i++) {
+        final pos = size.center(_offsetFromValue(angles[i], maxRadius, dataRow.$2[i]));
+        if (path == null) {
+          path = Path();
+          path.moveTo(pos.dx, pos.dy);
+        } else {
+          path.lineTo(pos.dx, pos.dy);
+        }
+      }
+      final startPos = size.center(_offsetFromValue(angles[0], maxRadius, dataRow.$2[0]));
+      path!.lineTo(startPos.dx, startPos.dy); // connect to start
+
+      canvas.drawPath(path, Paint() // fill
+        ..color = dataRow.$1.withOpacity(0.4));
+      canvas.drawPath(path, Paint() // stroke around
+        ..color = dataRow.$1
+        ..strokeWidth = 5.0
+        ..strokeJoin = StrokeJoin.round
+        ..style = PaintingStyle.stroke);
+    }
+
+    // draw labels on top of content
+    final textStyle = TextStyle(
+      color: brightness == Brightness.dark ? Colors.white : Colors.black,
+      backgroundColor: (brightness == Brightness.dark ? Colors.black : Colors.white).withOpacity(0.6),
+      fontSize: 24.0
+    );
+    for (int i = 0; i < labels.length; i += 2) {
+      _drawTextInsideBounds(canvas, i * sectionWidthDeg, size, labels[i], textStyle);
+    }
+  }
+
+  /// Draws a given [text] at the end of [angle], but withing [size].
+  void _drawTextInsideBounds(Canvas canvas, double angle, Size size, String text, TextStyle style) {
+    final builder = ParagraphBuilder(style.getParagraphStyle());
+    builder.pushStyle(style.getTextStyle());
+    builder.addText(text);
+    final paragraph = builder.build();
+    paragraph.layout(ParagraphConstraints(width: size.width));
+
+    Offset off = _offset(angle, size.shortestSide / 2);
+    off = size.center(off);
+    // center at pos
+    off = Offset(off.dx - (paragraph.minIntrinsicWidth / 2), off.dy - (paragraph.height / 2));
+    if ((off.dy + paragraph.height) > size.height) { // right overflow
+      off = Offset(off.dx, off.dy - ((off.dy + paragraph.height) - size.height));
+    }
+    if ((off.dx + paragraph.minIntrinsicWidth) > size.width) { // right overflow
+      off = Offset(off.dx - ((off.dx + paragraph.minIntrinsicWidth) - size.width), off.dy);
+    }
+
+    canvas.drawParagraph(paragraph, off);
+  }
+
+  Offset _offsetFromValue(double angle, double fullRadius, int value) {
+    final percent = value / _maxValue;
+    final r = fullRadius * percent;
+    return _offset(angle, r);
+  }
+
+  /// Rotate so up is 0deg and transform to [Offset] from center.
+  Offset _offset(double angle, double radius) => Offset(
+    radius * math.cos(angle - 0.5 * math.pi),
+    radius * math.sin(angle - 0.5 * math.pi),
+  );
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _RadarChartPainter
+    || oldDelegate.brightness != brightness
+    || oldDelegate.labels != labels
+    || oldDelegate.values != values;
+}
app/lib/model/blood_pressure_analyzer.dart
@@ -74,21 +74,6 @@ class BloodPressureAnalyser {
     return c ~/ lastDay.difference(firstDay).inDays;
   }
 
-  /// Relation of average values to the time of the day.
-  ///
-  /// outer list is type (0 -> diastolic, 1 -> systolic, 2 -> pulse)
-  /// inner list index is hour of day ([0] -> 23:30-00:29.59; [1] -> ...)
-  @Deprecated("This api can't express kPa and mmHg preferences and is only a "
-      'thin wrapper around the newer [groupAnalysers].')
-  List<List<int>> get allAvgsRelativeToDaytime {
-    final groupedAnalyzers = groupAnalysers();
-    return [
-      groupedAnalyzers.map((e) => e.avgDia?.mmHg ?? avgDia?.mmHg ?? 0).toList(),
-      groupedAnalyzers.map((e) => e.avgSys?.mmHg ?? avgSys?.mmHg ?? 0).toList(),
-      groupedAnalyzers.map((e) => e.avgPul ?? avgPul ?? 0).toList(),
-    ];
-  }
-
   /// Creates analyzers for each hour of the day (0-23).
   ///
   /// This function groups records by the hour of the day (e.g 23:30-00:29.59)
app/lib/screens/statistics_screen.dart
@@ -1,13 +1,11 @@
 import 'package:blood_pressure_app/data_util/blood_pressure_builder.dart';
 import 'package:blood_pressure_app/data_util/interval_picker.dart';
 import 'package:blood_pressure_app/features/statistics/blood_pressure_distribution.dart';
+import 'package:blood_pressure_app/features/statistics/clock_bp_graph.dart';
 import 'package:blood_pressure_app/model/blood_pressure_analyzer.dart';
 import 'package:blood_pressure_app/model/storage/interval_store.dart';
-import 'package:blood_pressure_app/model/storage/settings_store.dart';
-import 'package:fl_chart/fl_chart.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
-import 'package:provider/provider.dart';
 
 /// A page that shows statistics about stored blood pressure values.
 class StatisticsScreen extends StatefulWidget {
@@ -56,63 +54,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
                 ),
               ),
               _buildSubTitle(localizations.timeResolvedMetrics),
-              () {
-                final data = analyzer.allAvgsRelativeToDaytime;
-                const opacity = 0.5;
-                final helperLinesStyle = BorderSide(
-                  color: Theme.of(context).dividerColor,
-                  width: 2,
-                );
-                final settings = context.watch<Settings>();
-                return Container(
-                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
-                  height: MediaQuery.of(context).size.width,
-                  child: RadarChart(
-                    RadarChartData(
-                      radarShape: RadarShape.circle,
-                      gridBorderData: helperLinesStyle,
-                      tickBorderData: helperLinesStyle,
-                      ticksTextStyle: const TextStyle(
-                        color: Colors.transparent,
-                      ),
-                      tickCount: 5,
-                      titleTextStyle: const TextStyle(fontSize: 25),
-                      getTitle: (pos, value) {
-                        if (pos % 2 == 0) {
-                          return RadarChartTitle(
-                            text: '$pos',
-                            positionPercentageOffset: 0.05,
-                          );
-                        }
-                        return const RadarChartTitle(text: '');
-                      },
-                      dataSets: [
-                        RadarDataSet(
-                          dataEntries: _intListToRadarEntry(data[0]),
-                          borderColor: settings.diaColor,
-                          fillColor: settings.diaColor.withOpacity(opacity),
-                          entryRadius: 0,
-                          borderWidth: settings.graphLineThickness,
-                        ),
-                        RadarDataSet(
-                          dataEntries: _intListToRadarEntry(data[1]),
-                          borderColor: settings.sysColor,
-                          fillColor: settings.sysColor.withOpacity(opacity),
-                          entryRadius: 0,
-                          borderWidth: settings.graphLineThickness,
-                        ),
-                        RadarDataSet(
-                          dataEntries: _intListToRadarEntry(data[2]),
-                          borderColor: settings.pulColor,
-                          fillColor: settings.pulColor.withOpacity(opacity),
-                          entryRadius: 0,
-                          borderWidth: settings.graphLineThickness,
-                        ),
-                      ],
-                    ),
-                  ),
-                );
-              }(),
+              ClockBpGraph(measurements: data),
             ],
           );
         },
@@ -125,14 +67,6 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
     );
   }
 
-  List<RadarEntry> _intListToRadarEntry(List<int> data) {
-    final res = <RadarEntry>[];
-    for (final v in data) {
-      res.add(RadarEntry(value: v.toDouble()));
-    }
-    return res;
-  }
-
   Widget _buildSubTitle(String text) => ListTile(
     contentPadding: EdgeInsets.zero,
     title: Text(
app/test/features/statistics/clock_bp_graph_test.dart
@@ -0,0 +1,69 @@
+import 'dart:math';
+
+import 'package:blood_pressure_app/features/statistics/clock_bp_graph.dart';
+import 'package:blood_pressure_app/model/storage/settings_store.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+import '../../model/analyzer_test.dart';
+
+void main() {
+  testWidgets("doesn't throw when empty" , (tester) async {
+    await tester.pumpWidget(MaterialApp(
+      home: Scaffold(
+        body: ChangeNotifierProvider<Settings>(
+          create: (_) => Settings(),
+          child: ClockBpGraph(measurements: []),
+        ),
+      ),
+    ));
+    expect(tester.takeException(), isNull);
+    expect(find.byType(ClockBpGraph), findsOneWidget);
+  });
+  testWidgets('renders sample data like expected in light mode', (tester) async {
+    final rng = Random(1234);
+    await tester.pumpWidget(MaterialApp(
+      home: Scaffold(
+        body: ChangeNotifierProvider<Settings>(
+          create: (_) => Settings(
+            pulColor: Colors.pink,
+          ),
+          child: ClockBpGraph(measurements: [
+            for (int i = 0; i < 50; i++)
+              mockRecord(
+                time: DateTime.fromMillisecondsSinceEpoch(rng.nextInt(1724578014) * 1000),
+                sys: rng.nextInt(60) + 70,
+                dia: rng.nextInt(60) + 40,
+                pul: rng.nextInt(70) + 40,
+              )
+          ],),
+        ),
+      ),
+    ));
+    await expectLater(find.byType(ClockBpGraph), matchesGoldenFile('ClockBpGraph-light.png'));
+  });
+  testWidgets('renders sample data like expected in dart mode', (tester) async {
+    final rng = Random(1234);
+    await tester.pumpWidget(MaterialApp(
+      theme: ThemeData.dark(useMaterial3: true),
+      home: Scaffold(
+        body: ChangeNotifierProvider<Settings>(
+          create: (_) => Settings(
+            pulColor: Colors.pink,
+          ),
+          child: ClockBpGraph(measurements: [
+            for (int i = 0; i < 50; i++)
+              mockRecord(
+                time: DateTime.fromMillisecondsSinceEpoch(rng.nextInt(1724578014) * 1000),
+                sys: rng.nextInt(60) + 70,
+                dia: rng.nextInt(60) + 40,
+                pul: rng.nextInt(70) + 40,
+              )
+          ],),
+        ),
+      ),
+    ));
+    await expectLater(find.byType(ClockBpGraph), matchesGoldenFile('ClockBpGraph-dark.png'));
+  });
+}
app/test/features/statistics/ClockBpGraph-dark.png
Binary file
app/test/features/statistics/ClockBpGraph-light.png
Binary file
app/pubspec.lock
@@ -194,10 +194,10 @@ packages:
     dependency: "direct main"
     description:
       name: collection
-      sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
+      sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
       url: "https://pub.dev"
     source: hosted
-    version: "1.19.0"
+    version: "1.18.0"
   convert:
     dependency: transitive
     description:
@@ -254,14 +254,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.4.1"
-  equatable:
-    dependency: transitive
-    description:
-      name: equatable
-      sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.0.5"
   fake_async:
     dependency: transitive
     description:
@@ -302,14 +294,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.1.0"
-  fl_chart:
-    dependency: "direct main"
-    description:
-      name: fl_chart
-      sha256: d0f0d49112f2f4b192481c16d05b6418bd7820e021e265a3c22db98acf7ed7fb
-      url: "https://pub.dev"
-    source: hosted
-    version: "0.68.0"
   flutter:
     dependency: "direct main"
     description: flutter
@@ -961,10 +945,10 @@ packages:
     dependency: transitive
     description:
       name: string_scanner
-      sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
+      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
       url: "https://pub.dev"
     source: hosted
-    version: "1.3.0"
+    version: "1.2.0"
   sync_http:
     dependency: transitive
     description:
@@ -993,26 +977,26 @@ packages:
     dependency: transitive
     description:
       name: test
-      sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
+      sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e"
       url: "https://pub.dev"
     source: hosted
-    version: "1.25.8"
+    version: "1.25.7"
   test_api:
     dependency: transitive
     description:
       name: test_api
-      sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
+      sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
       url: "https://pub.dev"
     source: hosted
-    version: "0.7.3"
+    version: "0.7.2"
   test_core:
     dependency: transitive
     description:
       name: test_core
-      sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
+      sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.5"
+    version: "0.6.4"
   timing:
     dependency: transitive
     description:
app/pubspec.yaml
@@ -18,7 +18,6 @@ dependencies:
   flutter_localizations:
     sdk: flutter
   flutter_markdown: ^0.7.3
-  fl_chart: ^0.68.0
   function_tree: ^0.9.1
   provider: ^6.1.2
   path: ^1.9.0