Skip to content

Commit dfd18d1

Browse files
authored
Merge pull request #634 from wger-project/chart-weight-since-plan
better weight and measurements visualisation
2 parents beb926d + 349efa6 commit dfd18d1

File tree

10 files changed

+305
-96
lines changed

10 files changed

+305
-96
lines changed

lib/screens/measurement_entries_screen.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,10 @@ class MeasurementEntriesScreen extends StatelessWidget {
133133
);
134134
},
135135
),
136-
body: Consumer<MeasurementProvider>(
137-
builder: (context, provider, child) => EntriesList(category),
136+
body: SingleChildScrollView(
137+
child: Consumer<MeasurementProvider>(
138+
builder: (context, provider, child) => EntriesList(category),
139+
),
138140
),
139141
);
140142
}

lib/screens/weight_screen.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import 'package:provider/provider.dart';
2222
import 'package:wger/providers/body_weight.dart';
2323
import 'package:wger/screens/form_screen.dart';
2424
import 'package:wger/widgets/core/app_bar.dart';
25-
import 'package:wger/widgets/weight/entries_list.dart';
2625
import 'package:wger/widgets/weight/forms.dart';
26+
import 'package:wger/widgets/weight/weight_overview.dart';
2727

2828
class WeightScreen extends StatelessWidget {
2929
const WeightScreen();
@@ -48,8 +48,10 @@ class WeightScreen extends StatelessWidget {
4848
);
4949
},
5050
),
51-
body: Consumer<BodyWeightProvider>(
52-
builder: (context, workoutProvider, child) => const WeightEntriesList(),
51+
body: SingleChildScrollView(
52+
child: Consumer<BodyWeightProvider>(
53+
builder: (context, workoutProvider, child) => const WeightOverview(),
54+
),
5355
),
5456
);
5557
}

lib/widgets/dashboard/widgets.dart

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ class _DashboardWeightWidgetState extends State<DashboardWeightWidget> {
159159
final profile = context.read<UserProvider>().profile;
160160
final weightProvider = context.read<BodyWeightProvider>();
161161

162+
final entriesAll =
163+
weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
164+
final entries7dAvg = moving7dAverage(entriesAll);
165+
162166
return Consumer<BodyWeightProvider>(
163167
builder: (context, workoutProvider, child) => Card(
164168
child: Column(
@@ -182,14 +186,16 @@ class _DashboardWeightWidgetState extends State<DashboardWeightWidget> {
182186
SizedBox(
183187
height: 200,
184188
child: MeasurementChartWidgetFl(
185-
weightProvider.items
186-
.map((e) => MeasurementChartEntry(e.weight, e.date))
187-
.toList(),
188-
unit: profile!.isMetric
189-
? AppLocalizations.of(context).kg
190-
: AppLocalizations.of(context).lb,
189+
entriesAll,
190+
weightUnit(profile!.isMetric, context),
191+
avgs: entries7dAvg,
191192
),
192193
),
194+
MeasurementOverallChangeWidget(
195+
entries7dAvg.first,
196+
entries7dAvg.last,
197+
weightUnit(profile!.isMetric, context),
198+
),
193199
Row(
194200
mainAxisAlignment: MainAxisAlignment.spaceBetween,
195201
children: [
@@ -274,6 +280,9 @@ class _DashboardMeasurementWidgetState extends State<DashboardMeasurementWidget>
274280
FontAwesomeIcons.chartLine,
275281
color: Theme.of(context).textTheme.headlineSmall!.color,
276282
),
283+
// TODO: this icon feels out of place and inconsistent with all
284+
// other dashboard widgets.
285+
// maybe we should just add a "Go to all" at the bottom of the widget
277286
trailing: IconButton(
278287
icon: const Icon(Icons.arrow_forward),
279288
onPressed: () => Navigator.pushNamed(

lib/widgets/measurements/categories_card.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ class CategoriesCard extends StatelessWidget {
1515

1616
@override
1717
Widget build(BuildContext context) {
18+
final entriesAll =
19+
currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList();
20+
final entries7dAvg = moving7dAverage(entriesAll);
21+
1822
return Card(
1923
elevation: elevation,
20-
color: Theme.of(context).colorScheme.onInverseSurface,
2124
child: Column(
2225
children: [
2326
Padding(
@@ -31,10 +34,16 @@ class CategoriesCard extends StatelessWidget {
3134
padding: const EdgeInsets.all(10),
3235
height: 220,
3336
child: MeasurementChartWidgetFl(
34-
currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
35-
unit: currentCategory.unit,
37+
entriesAll,
38+
currentCategory.unit,
39+
avgs: entries7dAvg,
3640
),
3741
),
42+
MeasurementOverallChangeWidget(
43+
entries7dAvg.first,
44+
entries7dAvg.last,
45+
currentCategory.unit,
46+
),
3847
const Divider(),
3948
Row(
4049
mainAxisAlignment: MainAxisAlignment.spaceBetween,

lib/widgets/measurements/charts.dart

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,39 @@
1818

1919
import 'package:fl_chart/fl_chart.dart';
2020
import 'package:flutter/material.dart';
21+
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
2122
import 'package:intl/intl.dart';
2223
import 'package:wger/helpers/charts.dart';
2324

25+
class MeasurementOverallChangeWidget extends StatelessWidget {
26+
final MeasurementChartEntry _first;
27+
final MeasurementChartEntry _last;
28+
final String _unit;
29+
const MeasurementOverallChangeWidget(this._first, this._last, this._unit);
30+
31+
@override
32+
Widget build(BuildContext context) {
33+
final delta = _last.value - _first.value;
34+
final prefix = delta > 0
35+
? '+'
36+
: delta < 0
37+
? '-'
38+
: '';
39+
40+
return Text('overall change $prefix ${delta.abs().toStringAsFixed(1)} $_unit');
41+
}
42+
}
43+
44+
String weightUnit(bool isMetric, BuildContext context) {
45+
return isMetric ? AppLocalizations.of(context).kg : AppLocalizations.of(context).lb;
46+
}
47+
2448
class MeasurementChartWidgetFl extends StatefulWidget {
2549
final List<MeasurementChartEntry> _entries;
26-
final String unit;
50+
final List<MeasurementChartEntry>? avgs;
51+
final String _unit;
2752

28-
const MeasurementChartWidgetFl(this._entries, {this.unit = 'kg'});
53+
const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs});
2954

3055
@override
3156
State<MeasurementChartWidgetFl> createState() => _MeasurementChartWidgetFlState();
@@ -37,12 +62,7 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
3762
return AspectRatio(
3863
aspectRatio: 1.70,
3964
child: Padding(
40-
padding: const EdgeInsets.only(
41-
right: 18,
42-
left: 12,
43-
top: 24,
44-
bottom: 12,
45-
),
65+
padding: const EdgeInsets.all(4),
4666
child: LineChart(mainData()),
4767
),
4868
);
@@ -53,8 +73,8 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
5373
touchTooltipData: LineTouchTooltipData(getTooltipItems: (touchedSpots) {
5474
return touchedSpots.map((touchedSpot) {
5575
return LineTooltipItem(
56-
'${touchedSpot.y} kg',
57-
const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
76+
'${touchedSpot.y.toStringAsFixed(1)} ${widget._unit}',
77+
TextStyle(color: touchedSpot.bar.color, fontWeight: FontWeight.bold),
5878
);
5979
}).toList();
6080
}),
@@ -67,13 +87,13 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
6787
gridData: FlGridData(
6888
show: true,
6989
drawVerticalLine: true,
70-
//horizontalInterval: 1,
71-
//verticalInterval: interval,
90+
// horizontalInterval: 1,
91+
// verticalInterval: 1,
7292
getDrawingHorizontalLine: (value) {
73-
return const FlLine(color: Colors.grey, strokeWidth: 1);
93+
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
7494
},
7595
getDrawingVerticalLine: (value) {
76-
return const FlLine(color: Colors.grey, strokeWidth: 1);
96+
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
7797
},
7898
),
7999
titlesData: FlTitlesData(
@@ -88,14 +108,22 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
88108
sideTitles: SideTitles(
89109
showTitles: true,
90110
getTitlesWidget: (value, meta) {
91-
// Don't show the first and last entries, otherwise they'll overlap with the
92-
// calculated interval
111+
// Don't show the first and last entries, to avoid overlap
112+
// see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate
113+
// this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data
93114
if (value == meta.min || value == meta.max) {
94115
return const Text('');
95116
}
96117
final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt());
118+
// if we go across years, show years in the ticks. otherwise leave them out
119+
if (DateTime.fromMillisecondsSinceEpoch(meta.min.toInt()).year !=
120+
DateTime.fromMillisecondsSinceEpoch(meta.max.toInt()).year) {
121+
return Text(
122+
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
123+
);
124+
}
97125
return Text(
98-
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
126+
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date),
99127
);
100128
},
101129
interval: widget._entries.isNotEmpty
@@ -111,29 +139,49 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
111139
showTitles: true,
112140
reservedSize: 65,
113141
getTitlesWidget: (value, meta) {
114-
return Text('$value ${widget.unit}');
142+
// Don't show the first and last entries, to avoid overlap
143+
// see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate
144+
// this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data
145+
if (value == meta.min || value == meta.max) {
146+
return const Text('');
147+
}
148+
149+
return Text('$value ${widget._unit}');
115150
},
116151
),
117152
),
118153
),
119154
borderData: FlBorderData(
120155
show: true,
121-
border: Border.all(color: const Color(0xff37434d)),
156+
border: Border.all(color: Theme.of(context).colorScheme.primaryContainer),
122157
),
123158
lineBarsData: [
124159
LineChartBarData(
125-
spots: [
126-
...widget._entries.map((e) => FlSpot(
127-
e.date.millisecondsSinceEpoch.toDouble(),
128-
e.value.toDouble(),
129-
)),
130-
],
160+
spots: widget._entries
161+
.map((e) => FlSpot(
162+
e.date.millisecondsSinceEpoch.toDouble(),
163+
e.value.toDouble(),
164+
))
165+
.toList(),
131166
isCurved: false,
132-
color: Theme.of(context).colorScheme.secondary,
133-
barWidth: 2,
167+
color: Theme.of(context).colorScheme.primary,
168+
barWidth: 0,
134169
isStrokeCapRound: true,
135170
dotData: const FlDotData(show: true),
136171
),
172+
if (widget.avgs != null)
173+
LineChartBarData(
174+
spots: widget.avgs!
175+
.map((e) => FlSpot(
176+
e.date.millisecondsSinceEpoch.toDouble(),
177+
e.value.toDouble(),
178+
))
179+
.toList(),
180+
isCurved: false,
181+
color: Theme.of(context).colorScheme.tertiary,
182+
barWidth: 1,
183+
dotData: const FlDotData(show: false),
184+
),
137185
],
138186
);
139187
}
@@ -146,6 +194,34 @@ class MeasurementChartEntry {
146194
MeasurementChartEntry(this.value, this.date);
147195
}
148196

197+
// for each point, return the average of all the points in the 7 days preceeding it
198+
List<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> vals) {
199+
var start = 0;
200+
var end = 0;
201+
final List<MeasurementChartEntry> out = <MeasurementChartEntry>[];
202+
203+
// first make sure our list is in ascending order
204+
vals.sort((a, b) => a.date.compareTo(b.date));
205+
206+
while (end < vals.length) {
207+
// since users can log measurements several days, or minutes apart,
208+
// we can't make assumptions. We have to manually advance 'start'
209+
// such that it is always the first point within our desired range.
210+
// posibly start == end (when there is only one point in the range)
211+
final intervalStart = vals[end].date.subtract(const Duration(days: 7));
212+
while (start < end && vals[start].date.isBefore(intervalStart)) {
213+
start++;
214+
}
215+
216+
final sub = vals.sublist(start, end + 1).map((e) => e.value);
217+
final sum = sub.reduce((val, el) => val + el);
218+
out.add(MeasurementChartEntry(sum / sub.length, vals[end].date));
219+
220+
end++;
221+
}
222+
return out;
223+
}
224+
149225
class Indicator extends StatelessWidget {
150226
const Indicator({
151227
super.key,

lib/widgets/measurements/entries.dart

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import 'package:intl/intl.dart';
2222
import 'package:provider/provider.dart';
2323
import 'package:wger/models/measurements/measurement_category.dart';
2424
import 'package:wger/providers/measurement.dart';
25+
import 'package:wger/providers/nutrition.dart';
2526
import 'package:wger/screens/form_screen.dart';
2627
import 'package:wger/widgets/measurements/charts.dart';
28+
import 'package:wger/widgets/measurements/helpers.dart';
2729

2830
import 'forms.dart';
2931

@@ -34,16 +36,23 @@ class EntriesList extends StatelessWidget {
3436

3537
@override
3638
Widget build(BuildContext context) {
39+
final plan = Provider.of<NutritionPlansProvider>(context, listen: false).currentPlan;
40+
41+
final entriesAll =
42+
_category.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList();
43+
final entries7dAvg = moving7dAverage(entriesAll);
44+
3745
return Column(children: [
38-
Container(
39-
padding: const EdgeInsets.all(10),
40-
height: 220,
41-
child: MeasurementChartWidgetFl(
42-
_category.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
43-
unit: _category.unit,
44-
),
46+
...getOverviewWidgetsSeries(
47+
_category.name,
48+
entriesAll,
49+
entries7dAvg,
50+
plan,
51+
_category.unit,
52+
context,
4553
),
46-
Expanded(
54+
SizedBox(
55+
height: 300,
4756
child: ListView.builder(
4857
padding: const EdgeInsets.all(10.0),
4958
itemCount: _category.entries.length,

0 commit comments

Comments
 (0)