Skip to content

Commit 161444b

Browse files
committed
weight visualisation improvements
* show weight entries during this nutrition plan and last 30 days * show moving average of last 7 days * show aggregate changes below chart * fix unit displays in a few cases * improv color scheme and other layout tweaks * various small code cleanups
1 parent 96faf89 commit 161444b

File tree

6 files changed

+189
-46
lines changed

6 files changed

+189
-46
lines changed

lib/screens/weight_screen.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ 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';
25+
import 'package:wger/widgets/weight/weight_overview.dart';
2626
import 'package:wger/widgets/weight/forms.dart';
2727

2828
class WeightScreen extends StatelessWidget {
@@ -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: 12 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: [

lib/widgets/measurements/categories_card.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class CategoriesCard extends StatelessWidget {
3232
height: 220,
3333
child: MeasurementChartWidgetFl(
3434
currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
35-
unit: currentCategory.unit,
35+
currentCategory.unit,
3636
),
3737
),
3838
const Divider(),

lib/widgets/measurements/charts.dart

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,34 @@
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+
return Text(
34+
'overall change ${(_last.value - _first.value).toStringAsFixed(1)} $_unit',
35+
);
36+
}
37+
}
38+
39+
String weightUnit(bool isMetric, BuildContext context) {
40+
return isMetric ? AppLocalizations.of(context).kg : AppLocalizations.of(context).lb;
41+
}
42+
2443
class MeasurementChartWidgetFl extends StatefulWidget {
2544
final List<MeasurementChartEntry> _entries;
26-
final String unit;
45+
final List<MeasurementChartEntry>? avgs;
46+
final String _unit;
2747

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

3050
@override
3151
State<MeasurementChartWidgetFl> createState() => _MeasurementChartWidgetFlState();
@@ -37,12 +57,7 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
3757
return AspectRatio(
3858
aspectRatio: 1.70,
3959
child: Padding(
40-
padding: const EdgeInsets.only(
41-
right: 18,
42-
left: 12,
43-
top: 24,
44-
bottom: 12,
45-
),
60+
padding: const EdgeInsets.all(4),
4661
child: LineChart(mainData()),
4762
),
4863
);
@@ -53,8 +68,8 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
5368
touchTooltipData: LineTouchTooltipData(getTooltipItems: (touchedSpots) {
5469
return touchedSpots.map((touchedSpot) {
5570
return LineTooltipItem(
56-
'${touchedSpot.y} kg',
57-
const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
71+
'${touchedSpot.y.toStringAsFixed(1)} ${widget._unit}',
72+
TextStyle(color: touchedSpot.bar.color, fontWeight: FontWeight.bold),
5873
);
5974
}).toList();
6075
}),
@@ -67,13 +82,13 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
6782
gridData: FlGridData(
6883
show: true,
6984
drawVerticalLine: true,
70-
//horizontalInterval: 1,
71-
//verticalInterval: interval,
85+
// horizontalInterval: 1,
86+
// verticalInterval: 1,
7287
getDrawingHorizontalLine: (value) {
73-
return const FlLine(color: Colors.grey, strokeWidth: 1);
88+
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
7489
},
7590
getDrawingVerticalLine: (value) {
76-
return const FlLine(color: Colors.grey, strokeWidth: 1);
91+
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
7792
},
7893
),
7994
titlesData: FlTitlesData(
@@ -94,8 +109,15 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
94109
return const Text('');
95110
}
96111
final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt());
112+
// if we go across years, show years in the ticks. otherwise leave them out
113+
if (DateTime.fromMillisecondsSinceEpoch(meta.min.toInt()).year !=
114+
DateTime.fromMillisecondsSinceEpoch(meta.max.toInt()).year) {
115+
return Text(
116+
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
117+
);
118+
}
97119
return Text(
98-
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
120+
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date),
99121
);
100122
},
101123
interval: widget._entries.isNotEmpty
@@ -111,29 +133,47 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
111133
showTitles: true,
112134
reservedSize: 65,
113135
getTitlesWidget: (value, meta) {
114-
return Text('$value ${widget.unit}');
136+
// Don't show the first and last entries, otherwise they'll overlap with the
137+
// calculated interval
138+
if (value == meta.min || value == meta.max) {
139+
return const Text('');
140+
}
141+
return Text('$value ${widget._unit}');
115142
},
116143
),
117144
),
118145
),
119146
borderData: FlBorderData(
120147
show: true,
121-
border: Border.all(color: const Color(0xff37434d)),
148+
border: Border.all(color: Theme.of(context).colorScheme.primaryContainer),
122149
),
123150
lineBarsData: [
124151
LineChartBarData(
125-
spots: [
126-
...widget._entries.map((e) => FlSpot(
127-
e.date.millisecondsSinceEpoch.toDouble(),
128-
e.value.toDouble(),
129-
)),
130-
],
152+
spots: widget._entries
153+
.map((e) => FlSpot(
154+
e.date.millisecondsSinceEpoch.toDouble(),
155+
e.value.toDouble(),
156+
))
157+
.toList(),
131158
isCurved: false,
132-
color: Theme.of(context).colorScheme.secondary,
133-
barWidth: 2,
159+
color: Theme.of(context).colorScheme.primary,
160+
barWidth: 0,
134161
isStrokeCapRound: true,
135162
dotData: const FlDotData(show: true),
136163
),
164+
if (widget.avgs != null)
165+
LineChartBarData(
166+
spots: widget.avgs!
167+
.map((e) => FlSpot(
168+
e.date.millisecondsSinceEpoch.toDouble(),
169+
e.value.toDouble(),
170+
))
171+
.toList(),
172+
isCurved: false,
173+
color: Theme.of(context).colorScheme.tertiary,
174+
barWidth: 1,
175+
dotData: const FlDotData(show: false),
176+
),
137177
],
138178
);
139179
}
@@ -146,6 +186,34 @@ class MeasurementChartEntry {
146186
MeasurementChartEntry(this.value, this.date);
147187
}
148188

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

lib/widgets/measurements/entries.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class EntriesList extends StatelessWidget {
4040
height: 220,
4141
child: MeasurementChartWidgetFl(
4242
_category.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
43-
unit: _category.unit,
43+
_category.unit,
4444
),
4545
),
4646
Expanded(

lib/widgets/weight/entries_list.dart renamed to lib/widgets/weight/weight_overview.dart

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,97 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
2121
import 'package:intl/intl.dart';
2222
import 'package:provider/provider.dart';
2323
import 'package:wger/providers/body_weight.dart';
24+
import 'package:wger/providers/nutrition.dart';
2425
import 'package:wger/providers/user.dart';
2526
import 'package:wger/screens/form_screen.dart';
2627
import 'package:wger/screens/measurement_categories_screen.dart';
2728
import 'package:wger/widgets/measurements/charts.dart';
2829
import 'package:wger/widgets/weight/forms.dart';
2930

30-
class WeightEntriesList extends StatelessWidget {
31-
const WeightEntriesList();
31+
class WeightOverview extends StatelessWidget {
32+
const WeightOverview();
3233
@override
3334
Widget build(BuildContext context) {
3435
final profile = context.read<UserProvider>().profile;
3536
final weightProvider = Provider.of<BodyWeightProvider>(context, listen: false);
37+
final plan = Provider.of<NutritionPlansProvider>(context, listen: false).currentPlan;
3638

37-
return Column(
38-
children: [
39+
final entriesAll =
40+
weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
41+
final entries7dAvg = moving7dAverage(entriesAll);
42+
43+
List<Widget> getOverviewWidgets(String title, bool isMetric, List<MeasurementChartEntry> raw,
44+
List<MeasurementChartEntry> avg, BuildContext context) {
45+
return [
46+
Text(
47+
title,
48+
textAlign: TextAlign.center,
49+
style: Theme.of(context).textTheme.titleLarge,
50+
),
3951
Container(
4052
padding: const EdgeInsets.all(15),
4153
height: 220,
4254
child: MeasurementChartWidgetFl(
43-
weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(),
44-
unit: profile!.isMetric
45-
? AppLocalizations.of(context).kg
46-
: AppLocalizations.of(context).lb,
55+
raw,
56+
weightUnit(isMetric, context),
57+
avgs: avg,
4758
),
4859
),
60+
MeasurementOverallChangeWidget(
61+
avg.first,
62+
avg.last,
63+
weightUnit(isMetric, context),
64+
),
65+
const SizedBox(height: 8),
66+
];
67+
}
68+
69+
return Column(
70+
children: [
71+
...getOverviewWidgets(
72+
'Weight all-time',
73+
profile!.isMetric,
74+
entriesAll,
75+
entries7dAvg,
76+
context,
77+
),
78+
if (plan != null)
79+
...getOverviewWidgets(
80+
'Weight during nutritional plan ${plan.description}',
81+
profile.isMetric,
82+
entriesAll.where((e) => e.date.isAfter(plan.creationDate)).toList(),
83+
entries7dAvg.where((e) => e.date.isAfter(plan.creationDate)).toList(),
84+
context,
85+
),
86+
// if all time is significantly longer than 30 days (let's say > 75 days)
87+
// and if there is is a plan and it also was > 75 days,
88+
// then let's show a separate chart just focusing on the last 30 days
89+
if (entriesAll.first.date
90+
.isBefore(entriesAll.last.date.subtract(const Duration(days: 75))) &&
91+
(plan == null ||
92+
entriesAll
93+
.firstWhere((e) => e.date.isAfter(plan.creationDate))
94+
.date
95+
.isBefore(entriesAll.last.date.subtract(const Duration(days: 30)))))
96+
...getOverviewWidgets(
97+
'Weight last 30 days',
98+
profile.isMetric,
99+
entriesAll
100+
.where((e) => e.date.isAfter(DateTime.now().subtract(const Duration(days: 30))))
101+
.toList(),
102+
entries7dAvg
103+
.where((e) => e.date.isAfter(DateTime.now().subtract(const Duration(days: 30))))
104+
.toList(),
105+
context,
106+
),
107+
108+
Row(
109+
mainAxisAlignment: MainAxisAlignment.center,
110+
children: [
111+
Indicator(color: Theme.of(context).colorScheme.primary, text: 'raw', isSquare: true),
112+
Indicator(color: Theme.of(context).colorScheme.tertiary, text: 'avg', isSquare: true),
113+
],
114+
),
49115
TextButton(
50116
onPressed: () => Navigator.pushNamed(
51117
context,
@@ -59,7 +125,8 @@ class WeightEntriesList extends StatelessWidget {
59125
],
60126
),
61127
),
62-
Expanded(
128+
SizedBox(
129+
height: 300,
63130
child: RefreshIndicator(
64131
onRefresh: () => weightProvider.fetchAndSetEntries(),
65132
child: ListView.builder(
@@ -69,7 +136,7 @@ class WeightEntriesList extends StatelessWidget {
69136
final currentEntry = weightProvider.items[index];
70137
return Card(
71138
child: ListTile(
72-
title: Text('${currentEntry.weight} kg'),
139+
title: Text('${currentEntry.weight} ${weightUnit(profile.isMetric, context)}'),
73140
subtitle: Text(
74141
DateFormat.yMd(
75142
Localizations.localeOf(context).languageCode,

0 commit comments

Comments
 (0)