main
  1import 'dart:math';
  2
  3import 'package:collection/collection.dart';
  4import 'package:flutter/foundation.dart';
  5import 'package:flutter/material.dart';
  6import 'package:flutter/rendering.dart';
  7import 'package:blood_pressure_app/l10n/app_localizations.dart';
  8
  9/// A statistic that shows how often values occur in a list of values.
 10///
 11/// Shows in form of a graph that has centered columns of different height and
 12/// labels that indicate min, max and average.
 13///
 14/// The widgets takes a width of at least 180 units and a height of at least 50.
 15///
 16/// First draws the graph lines, then draws the decorations in the color
 17/// [Colors.white70].
 18class ValueDistribution extends StatelessWidget {
 19  /// Create a statistic to show how often values occur.
 20  const ValueDistribution({
 21    super.key,
 22    required this.values,
 23    required this.color,
 24  });
 25
 26  /// Raw list of all values to calculate the distribution from.
 27  ///
 28  /// Sample distribution calculation:
 29  /// ```
 30  /// [1, 9, 9, 9, 3, 4, 4] => {
 31  ///   1: 1,
 32  ///   9: 3,
 33  ///   3: 1,
 34  ///   4: 2
 35  /// }
 36  /// ```
 37  final Iterable<int> values;
 38
 39  /// Color of the data bars on the graph.
 40  final Color color;
 41
 42  @override
 43  Widget build(BuildContext context) {
 44    final localizations = AppLocalizations.of(context)!;
 45    if (values.isEmpty) {
 46      return Center(
 47        child: Text(localizations.errNoData),
 48      );
 49    }
 50
 51    final distribution = <int, int>{};
 52    for (final v in values) {
 53      if(distribution.containsKey(v)) {
 54        distribution[v] = distribution[v]! + 1;
 55      } else {
 56        distribution[v] = 1;
 57      }
 58    }
 59
 60    assert(distribution[distribution.keys.max]! > 0);
 61    assert(distribution[distribution.keys.min]! > 0);
 62    return ConstrainedBox(
 63      constraints: const BoxConstraints(
 64        minWidth: 180,
 65        minHeight: 50,
 66      ),
 67      child: CustomPaint(
 68        painter: _ValueDistributionPainter(
 69          distribution,
 70          localizations,
 71          color,
 72        ),
 73      ),
 74    );
 75  }
 76  
 77}
 78
 79/// Painter of a horizontal array of vertical bars.
 80class _ValueDistributionPainter extends CustomPainter {
 81  /// Create a painter of a horizontal array of vertical bars.
 82  _ValueDistributionPainter(
 83    this.distribution,
 84    this.localizations,
 85    this.barColor,
 86  );
 87
 88  /// Positions and height of bars that make up the distribution.
 89  ///
 90  /// The key is the x order on the bar and it's value indicates it's relative
 91  /// height.
 92  ///
 93  /// The height of the bar is how often it occurs in a list of values.
 94  final Map<int, int> distribution;
 95
 96  /// Text for labels on the graph and for semantics.
 97  final AppLocalizations localizations;
 98
 99  /// Color of the data bars.
100  final Color barColor;
101
102  static const double _kDefaultBarGapWidth = 5.0;
103  static const Color _kDecorationColor = Colors.white70;
104
105  @override
106  void paint(Canvas canvas, Size size) {
107    assert(size.width >= 180, 'Canvas must be at least 180 pixels wide, to avoid overflows.');
108    assert(size.height >= 50, 'Canvas must be at least 50 pixels high, to avoid overflows.');
109    if (kDebugMode) {
110      distribution.keys.every((e) => e >= 0);
111    }
112
113    // Adapt gap width in case of lots of gaps.
114	  final int barNumber = distribution.keys.max - distribution.keys.min + 1;
115    double barGapWidth = _kDefaultBarGapWidth;
116    double barWidth = 0;
117    while(barWidth < barGapWidth && barGapWidth > 1) {
118      barGapWidth -= 1;
119      barWidth = ((size.width + barGapWidth) // fix trailing gap
120          / barNumber) - barGapWidth;
121    }
122
123	assert(barWidth > 0);
124	assert(barGapWidth > 0);
125	assert(barWidth*barNumber + barGapWidth*(barNumber-1) <= size.width);
126
127    // Set height scale so that the largest element takes up the full height.
128    // Ensures that the width of bars bars doesn't draw as overflow
129    final double heightUnit = max(1, size.height - barWidth * 2)
130        / distribution.values.max;
131    assert(heightUnit > 0, '(${size.height} - $barWidth * 2) / '
132        '${distribution.values.max}');
133
134    final barPainter = Paint()
135      ..color = barColor
136      ..style = PaintingStyle.stroke
137      ..strokeWidth = barWidth
138      ..strokeCap = StrokeCap.round
139      ..strokeJoin = StrokeJoin.round;
140
141    double barDrawXOffset = barWidth / 2; // don't clip left side of bar
142    for (int xPos = distribution.keys.min; xPos <= distribution.keys.max; xPos++) {
143      final length = heightUnit * (distribution[xPos] ?? 0);
144      /// Offset from top so that the bar of [length] is centered.
145      final startPos = (size.height - length) / 2;
146      assert(barDrawXOffset >= 0 && barDrawXOffset <= size.width, '0 <= $barDrawXOffset <= ${size.width}');
147      assert(startPos >= 0); assert(startPos <= size.height);
148      assert((startPos + length) >= 0 && (startPos + length) <= size.height);
149      canvas.drawLine(
150        Offset(barDrawXOffset, startPos),
151        Offset(barDrawXOffset, startPos + length),
152        barPainter,
153      );
154
155      barDrawXOffset += barWidth + barGapWidth;
156    }
157
158    // Draw decorations on top:
159    final decorationsPainter = Paint()
160      ..strokeWidth = 2
161      ..style = PaintingStyle.stroke;
162    double centerLineLength = 0.0;
163    while (centerLineLength < size.width) {
164      canvas.drawLine(
165        Offset(centerLineLength, size.height / 2),
166        Offset(min(centerLineLength + 8.0, size.width), size.height / 2),
167        decorationsPainter..color = _kDecorationColor,
168      );
169      centerLineLength += 8.0 + 7.0;
170    }
171
172    /// Draws a decorative label above the center.
173    ///
174    /// [alignment] may only be [Alignment.centerLeft], [Alignment.center] or
175    /// [Alignment.centerRight].
176    void drawLabel(String text, Alignment alignment) {
177      final style = TextStyle(
178        color: _kDecorationColor,
179        backgroundColor: Colors.black.withOpacity(0.5),
180        fontSize: 16,
181      );
182      final textSpan = TextSpan(
183        text: text,
184        style: style,
185      );
186      final textPainter = TextPainter(
187        text: textSpan,
188        textDirection: TextDirection.ltr,
189      );
190      textPainter.layout();
191      final posX = switch(alignment) {
192        Alignment.centerLeft => 0.0,
193        Alignment.center => (size.width / 2)
194            - (textPainter.width / 2).clamp(0, size.width),
195        Alignment.centerRight => (size.width - textPainter.width)
196            .clamp(0, size.width),
197        _ => throw ArgumentError('Unsupported alignment'),
198      };
199      final position = Offset(
200        posX.toDouble(),
201        size.height / 2 - textPainter.height, // above center
202      );
203      assert(posX >= 0);
204      assert((posX + textPainter.width) <= size.width);
205      assert(position.dy >= 0);
206      assert((position.dy + textPainter.height) <= size.height);
207      textPainter.paint(canvas, position);
208    }
209
210    drawLabel(localizations.minOf(_min),
211        Alignment.centerLeft,);
212    drawLabel(localizations.avgOf(_average),
213        Alignment.center,);
214    drawLabel(localizations.maxOf(_max),
215        Alignment.centerRight,);
216  }
217
218  @override
219  bool shouldRepaint(covariant _ValueDistributionPainter oldDelegate) =>
220      distribution == oldDelegate.distribution;
221
222  @override
223  bool shouldRebuildSemantics(covariant _ValueDistributionPainter oldDelegate)
224      => distribution == oldDelegate.distribution;
225
226  @override
227  SemanticsBuilderCallback? get semanticsBuilder => (Size size) {
228    final oneThird = size.width / 3;
229    return [
230      CustomPainterSemantics(
231        rect: Rect.fromLTRB(0, 0, oneThird, size.height),
232        properties: SemanticsProperties(
233          label: localizations.minOf(_min),
234          textDirection: TextDirection.ltr,
235        ),
236      ),
237      CustomPainterSemantics(
238        rect: Rect.fromLTRB(oneThird, 0, 2 * oneThird, size.height),
239        properties: SemanticsProperties(
240          label: localizations.avgOf(_average),
241          textDirection: TextDirection.ltr,
242        ),
243      ),
244      CustomPainterSemantics(
245        rect: Rect.fromLTRB(2 * oneThird, 0, size.width, size.height),
246        properties: SemanticsProperties(
247          label: localizations.maxOf(_max),
248          textDirection: TextDirection.ltr,
249        ),
250      ),
251    ];
252  };
253
254  /// Max (right end) value in distribution.
255  String get _max => distribution.keys.max.toString();
256
257  /// Min (left end) value in distribution.
258  String get _min => distribution.keys.min.toString();
259
260  /// Average value of distribution.
261  String get _average {
262    double sum = 0;
263    int count = 0;
264    for (int key = distribution.keys.min; key <= distribution.keys.max; key++) {
265      sum += key * (distribution[key] ?? 0);
266      count += (distribution[key] ?? 0);
267    }
268    return (sum / count).round().toString();
269  }
270}