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}