Commit 2b1ac65
Changed files (8)
app
lib
features
statistics
screens
test
features
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