Commit 49e9e62

derdilla <82763757+NobodyForNothing@users.noreply.github.com>
2024-08-17 18:53:02
implement bp line drawing for custom graph
Signed-off-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com>
1 parent ec38bbd
Changed files (2)
app
lib
features
statistics
test
features
statistics
app/lib/features/statistics/value_graph.dart
@@ -21,7 +21,12 @@ class Tmp extends StatelessWidget {
             height: 250,
             width: 600,
             child: BloodPressureValueGraph(
-              records: [],
+              records: [
+                BloodPressureRecord(time: DateTime(2000), sys: Pressure.kPa(123)),
+                BloodPressureRecord(time: DateTime(2001), sys: Pressure.kPa(140)),
+                BloodPressureRecord(time: DateTime(2002), sys: Pressure.kPa(100)),
+                BloodPressureRecord(time: DateTime(2003), sys: Pressure.kPa(123)),
+              ],
             ),
           ),
         ),
@@ -30,13 +35,17 @@ class Tmp extends StatelessWidget {
   }
 }
 
-
+/// A graph of [BloodPressureRecord] values.
 class BloodPressureValueGraph extends StatelessWidget {
-  const BloodPressureValueGraph({super.key, required this.records});
+  /// Create a new graph of [BloodPressureRecord] values.
+  BloodPressureValueGraph({super.key,
+    required this.records,
+  }): assert(records.length >= 2),
+      assert(records.isSorted((a, b) => a.time.compareTo(b.time)));
 
-  /// Data that make up graph lines.
+  /// Data to draw lines and determine decorations from.
   ///
-  /// Data outside [range] will be ignored.
+  /// Must be more than two and sorted.
   final List<BloodPressureRecord> records;
 
   @override
@@ -65,10 +74,11 @@ class _ValueGraphPainter extends CustomPainter {
   /// Must be at least 2 records long.
   final List<BloodPressureRecord> records;
 
+  static const double _kLeftLegendWidth = 35.0;
+  static const double _kBottomLegendHeight = 50.0;
+
   void _paintDecorations(Canvas canvas, Size size, DateTimeRange range, double minY, double maxY) {
-    const leftLegendWidth = 35.0;
-    const bottomLegendHeight = 50.0;
-    assert(size.width > leftLegendWidth && size.height > bottomLegendHeight);
+    assert(size.width > _kLeftLegendWidth && size.height > _kBottomLegendHeight);
 
     final graphBorderLinesPaint = Paint()
       ..color = brightness == Brightness.dark ? Colors.white : Colors.black
@@ -79,29 +89,29 @@ class _ValueGraphPainter extends CustomPainter {
       ..strokeJoin = ui.StrokeJoin.round;
 
     // draw border
-    final bottomLeftOfGraph = Offset(leftLegendWidth, size.height - bottomLegendHeight);
-    canvas.drawLine(bottomLeftOfGraph, Offset(size.width, size.height - bottomLegendHeight), graphBorderLinesPaint);
-    canvas.drawLine(bottomLeftOfGraph, Offset(leftLegendWidth, 0.0), graphBorderLinesPaint);
+    final bottomLeftOfGraph = Offset(_kLeftLegendWidth, size.height - _kBottomLegendHeight);
+    canvas.drawLine(bottomLeftOfGraph, Offset(size.width, size.height - _kBottomLegendHeight), graphBorderLinesPaint);
+    canvas.drawLine(bottomLeftOfGraph, Offset(_kLeftLegendWidth, 0.0), graphBorderLinesPaint);
 
     final labelTextHeight = ((labelStyle.height ?? 1.0) * (labelStyle.fontSize ?? 14.0));
     (){
     // calculate horizontal decoration positions
-    final double drawHeight = size.height - bottomLegendHeight;
+    final double drawHeight = size.height - _kBottomLegendHeight;
 
     final leftLabelHeight = labelTextHeight + 4.0; // padding
     final leftLegendLabelCount = drawHeight / leftLabelHeight;
 
     // draw horizontal decorations
     for (int i = 0; i < leftLegendLabelCount; i += 2) {
-      final h = (size.height - bottomLegendHeight) - i * leftLabelHeight;
+      final h = (size.height - _kBottomLegendHeight) - i * leftLabelHeight;
       canvas.drawLine(
-        Offset(leftLegendWidth, h),
+        Offset(_kLeftLegendWidth, h),
         Offset(size.width, h),
         graphDecoLinesPaint,
       );
       canvas.drawLine(
-        Offset(leftLegendWidth, h),
-        Offset(leftLegendWidth - 5.0, h),
+        Offset(_kLeftLegendWidth, h),
+        Offset(_kLeftLegendWidth - 5.0, h),
         graphBorderLinesPaint,
       );
       final labelY = minY + ((maxY - minY) / leftLegendLabelCount) * i;
@@ -111,13 +121,13 @@ class _ValueGraphPainter extends CustomPainter {
         ..pushStyle(labelStyle.getTextStyle())
         ..addText(labelY.round().toString());
       final paragraph = paragraphBuilder.build()
-        ..layout(ui.ParagraphConstraints(width: leftLegendWidth - 6.0 - 2.0));
+        ..layout(ui.ParagraphConstraints(width: _kLeftLegendWidth - 6.0 - 2.0));
       canvas.drawParagraph(paragraph, ui.Offset(2.0, h - (leftLabelHeight / 2)));
     }
     }();
 
     // calculate vertical decoration positions
-    final double drawWidth = size.width - leftLegendWidth;
+    final double drawWidth = size.width - _kLeftLegendWidth;
     final bottomLabelHeight = labelTextHeight + 4.0;
     int bottomLabelCount = 20;
     Duration? stepDuration;
@@ -158,10 +168,10 @@ class _ValueGraphPainter extends CustomPainter {
 
     // draw vertical decorations
     for (int i = 0; i < bottomLabelCount; i += 2) {
-      final x = leftLegendWidth + i * (drawWidth / bottomLabelCount);
+      final x = _kLeftLegendWidth + i * (drawWidth / bottomLabelCount);
       canvas.drawLine(
         Offset(x, 0),
-        Offset(x, size.height - bottomLegendHeight + 4.0),
+        Offset(x, size.height - _kBottomLegendHeight + 4.0),
         graphDecoLinesPaint,
       );
       final paragraphBuilder = ui.ParagraphBuilder(labelStyle.getParagraphStyle(
@@ -170,7 +180,46 @@ class _ValueGraphPainter extends CustomPainter {
         ..addText(format.format(range.start.add(stepDuration! * i)));
       final paragraph = paragraphBuilder.build()
         ..layout(ui.ParagraphConstraints(width: drawWidth / bottomLabelCount + 8.0));
-      canvas.drawParagraph(paragraph, ui.Offset(x - ((drawWidth / bottomLabelCount + 8.0) / 2), size.height - bottomLegendHeight + (bottomLabelHeight / 2)));
+      canvas.drawParagraph(paragraph, ui.Offset(x - ((drawWidth / bottomLabelCount + 8.0) / 2), size.height - _kBottomLegendHeight + (bottomLabelHeight / 2)));
+    }
+  }
+
+  void _paintLine(
+    Canvas canvas,
+    Size size,
+    Iterable<(DateTime, double)> data,
+    Color color,
+    DateTimeRange range,
+    double minY,
+    double maxY,
+  ) {
+    final paint = Paint()
+      ..color = color
+      ..strokeWidth = 2.0
+      ..strokeCap = ui.StrokeCap.round
+      ..strokeJoin = ui.StrokeJoin.round;
+
+    ui.Offset transformPoint((DateTime, double) p) {
+      final width = size.width - _kLeftLegendWidth;
+      final double factorX = width / range.duration.inMilliseconds;
+      final x = _kLeftLegendWidth + (p.$1.millisecondsSinceEpoch - range.start.millisecondsSinceEpoch) * factorX;
+      
+      final height = size.height - _kBottomLegendHeight;
+      final double factorY = height / (maxY - minY);
+      final yBottom = _kBottomLegendHeight + (p.$2 - minY) * factorY;
+      final y = size.height - yBottom;
+
+      return ui.Offset(x, y);
+    }
+
+    ui.Offset? lastPoint;
+    for (final e in data) {
+      final point = transformPoint(e);
+      if (lastPoint != null) {
+        canvas.drawLine(lastPoint, point, paint);
+      }
+
+      lastPoint = point;
     }
   }
 
@@ -199,7 +248,9 @@ class _ValueGraphPainter extends CustomPainter {
     // TODO: convert to preferred units
 
     _paintDecorations(canvas, size, range, min, max);
-    // TODO: implement paint
+    _paintLine(canvas, size, records.sysGraph(), Colors.blue, range, min, max);
+    _paintLine(canvas, size, records.diaGraph(), Colors.green, range, min, max);
+    _paintLine(canvas, size, records.pulGraph(), Colors.red, range, min, max);
   }
 
   @override
@@ -209,3 +260,22 @@ class _ValueGraphPainter extends CustomPainter {
   }
 
 }
+
+/// Create graph data from a list of blood pressure records.
+extension GraphData on List<BloodPressureRecord> {
+  /// Get the timestamps and kPa values of all non-null sys values.
+  Iterable<(DateTime, double)> sysGraph() => this
+    .map((r) => (r.time, r.sys?.kPa))
+    .whereNot(((DateTime, double?) e) => e.$2 == null)
+    .cast<(DateTime, double)>();
+  /// Get the timestamps and kPa values of all non-null dia values.
+  Iterable<(DateTime, double)> diaGraph() => this
+      .map((r) => (r.time, r.dia?.kPa))
+      .whereNot(((DateTime, double?) e) => e.$2 == null)
+      .cast<(DateTime, double)>();
+  /// Get the timestamps and values as doubles of all non-null pul values.
+  Iterable<(DateTime, double)> pulGraph() => this
+      .map((r) => (r.time, r.pul?.toDouble()))
+      .whereNot(((DateTime, double?) e) => e.$2 == null)
+      .cast<(DateTime, double)>();
+}
app/test/features/statistics/value_graph.dart
@@ -0,0 +1,88 @@
+import 'package:blood_pressure_app/features/statistics/value_graph.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../../model/analyzer_test.dart';
+
+void main() {
+  test('Creates correct sys series', () {
+    final records = [
+      mockRecord(time: DateTime(2000), sys: 123),
+      mockRecord(time: DateTime(2001), sys: 120),
+      mockRecord(time: DateTime(2002), sys: null),
+      mockRecord(time: DateTime(2003), sys: 123),
+      mockRecord(time: DateTime(2004), sys: 200),
+    ];
+    assert(records.isSorted((a, b) => a.time.compareTo(b.time)));
+
+    final graph = records.sysGraph();
+    expect(graph, hasLength(4));
+    expect(graph.isSorted((a, b) => a.$1.compareTo(b.$1)), isTrue);
+    expect(graph.elementAt(0).$2, 123 * 0.133322);
+    expect(graph.elementAt(1).$2, 120 * 0.133322);
+    expect(graph.elementAt(2).$2, 123 * 0.133322);
+    expect(graph.elementAt(3).$2, 200 * 0.133322);
+  });
+  test('Creates correct dia series', () {
+    final records = [
+      mockRecord(time: DateTime(2000), dia: 123),
+      mockRecord(time: DateTime(2001), dia: 120),
+      mockRecord(time: DateTime(2002), dia: null),
+      mockRecord(time: DateTime(2003), dia: 123),
+      mockRecord(time: DateTime(2004), dia: 200),
+    ];
+    assert(records.isSorted((a, b) => a.time.compareTo(b.time)));
+
+    final graph = records.diaGraph();
+    expect(graph, hasLength(4));
+    expect(graph.isSorted((a, b) => a.$1.compareTo(b.$1)), isTrue);
+    expect(graph.elementAt(0).$2, 123 * 0.133322);
+    expect(graph.elementAt(1).$2, 120 * 0.133322);
+    expect(graph.elementAt(2).$2, 123 * 0.133322);
+    expect(graph.elementAt(3).$2, 200 * 0.133322);
+  });
+  test('Creates correct pul series', () {
+    final records = [
+      mockRecord(time: DateTime(2000), pul: 123),
+      mockRecord(time: DateTime(2001), pul: 120),
+      mockRecord(time: DateTime(2002), pul: null),
+      mockRecord(time: DateTime(2003), pul: 123),
+      mockRecord(time: DateTime(2004), pul: 200),
+    ];
+    assert(records.isSorted((a, b) => a.time.compareTo(b.time)));
+
+    final graph = records.pulGraph();
+    expect(graph, hasLength(4));
+    expect(graph.isSorted((a, b) => a.$1.compareTo(b.$1)), isTrue);
+    expect(graph.elementAt(0).$2, 123.0);
+    expect(graph.elementAt(1).$2, 120.0);
+    expect(graph.elementAt(2).$2, 123.0);
+    expect(graph.elementAt(3).$2, 200.0);
+  });
+  testWidgets('BloodPressureValueGraph throws assertion error without enough values', (tester) async {
+    await expectLater(
+      () => tester.pumpWidget(BloodPressureValueGraph(records: [])),
+      throwsAssertionError,
+    );
+  });
+  testWidgets('BloodPressureValueGraph throws assertion error with unsorted values', (tester) async {
+    await expectLater(
+      () => tester.pumpWidget(BloodPressureValueGraph(records: [
+        mockRecord(time: DateTime(2005), sys: 1),
+        mockRecord(time: DateTime(2003), sys: 1),
+        mockRecord(time: DateTime(2007), sys: 1),
+      ])),
+      throwsAssertionError,
+    );
+  });
+  testWidgets('BloodPressureValueGraph can be instantiated with valid values', (tester) async {
+    await expectLater(
+      () => tester.pumpWidget(BloodPressureValueGraph(records: [
+        mockRecord(time: DateTime(2003), sys: 1),
+        mockRecord(time: DateTime(2005), sys: 1),
+        mockRecord(time: DateTime(2007), sys: 1),
+      ])),
+      returnsNormally,
+    );
+  });
+}