Skip to content

Commit d4a272c

Browse files
committed
use interpolation for higher accuracy
used on: * overall delta's in the NP list * the NP and last30day charts (and their overall delta's) We display them on the respective charts to make it more consistent, but in this case, we add a small tooltip indicator to avoid user confusion
1 parent 29f6c87 commit d4a272c

File tree

4 files changed

+124
-27
lines changed

4 files changed

+124
-27
lines changed

lib/helpers/measurements.dart

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* This file is part of wger Workout Manager <https://github.com/wger-project>.
3+
* Copyright (C) 2020, 2021 wger Team
4+
*
5+
* wger Workout Manager is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
19+
import 'package:wger/helpers/misc.dart';
20+
import 'package:wger/widgets/measurements/charts.dart';
21+
22+
extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
23+
List<MeasurementChartEntry> whereDate(DateTime start, DateTime? end) {
24+
return where((e) => e.date.isAfter(start) && (end == null || e.date.isBefore(end))).toList();
25+
}
26+
27+
// assures values on the start (and optionally end) dates exist, by interpolating if needed
28+
// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan)
29+
// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer
30+
// explicit interpolation for the start and end dates (if needed)
31+
// this also helps with computing delta's across the entire window
32+
List<MeasurementChartEntry> whereDateWithInterpolation(DateTime start, DateTime? end) {
33+
// Make sure our list is sorted by date
34+
sort((a, b) => a.date.compareTo(b.date));
35+
36+
// Initialize result list
37+
final List<MeasurementChartEntry> result = [];
38+
39+
// Check if we have any entries on the same day as start/end
40+
bool hasEntryOnStartDay = false;
41+
bool hasEntryOnEndDay = false;
42+
43+
// Track entries for potential interpolation
44+
MeasurementChartEntry? lastBeforeStart;
45+
MeasurementChartEntry? lastBeforeEnd;
46+
47+
// Single pass through the data
48+
for (final entry in this) {
49+
if (entry.date.isSameDayAs(start)) {
50+
hasEntryOnStartDay = true;
51+
}
52+
if (end != null && entry.date.isSameDayAs(end)) {
53+
hasEntryOnEndDay = true;
54+
}
55+
56+
if (end != null && entry.date.isBefore(end)) {
57+
lastBeforeEnd = entry;
58+
}
59+
60+
if (entry.date.isBefore(start)) {
61+
lastBeforeStart = entry;
62+
} else {
63+
// insert interpolated start value if needed
64+
if (!hasEntryOnStartDay && lastBeforeStart != null) {
65+
result.insert(0, interpolateBetween(lastBeforeStart, entry, start));
66+
hasEntryOnStartDay = true;
67+
}
68+
69+
if (end == null || entry.date.isBefore(end)) {
70+
result.add(entry);
71+
}
72+
if (end != null && entry.date.isAfter(end)) {
73+
// insert interpolated end value if needed
74+
// note: we only interpolate end if we have data going beyond end
75+
// if let's say your plan ends in a week from now, we wouldn't want to fake data until next week.
76+
if (!hasEntryOnEndDay && lastBeforeEnd != null) {
77+
result.add(interpolateBetween(lastBeforeEnd, entry, end));
78+
hasEntryOnEndDay = true;
79+
}
80+
// we added all our values and did all interpolations
81+
// surely all input values from here on are irrelevant.
82+
return result;
83+
}
84+
}
85+
}
86+
return result;
87+
}
88+
}
89+
90+
// caller needs to make sure that before.date < date < after.date
91+
MeasurementChartEntry interpolateBetween(
92+
MeasurementChartEntry before, MeasurementChartEntry after, DateTime date) {
93+
final totalDuration = after.date.difference(before.date).inMilliseconds;
94+
final startDuration = date.difference(before.date).inMilliseconds;
95+
96+
// Create a special DateTime with milliseconds ending in 123 to mark it as interpolated
97+
// which we leverage in the UI
98+
final markedDate =
99+
DateTime(date.year, date.month, date.day, date.hour, date.minute, date.second, 123);
100+
101+
return MeasurementChartEntry(
102+
before.value + (after.value - before.value) * (startDuration / totalDuration),
103+
markedDate,
104+
);
105+
}

lib/widgets/measurements/charts.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,17 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
7979
getTooltipColor: (touchedSpot) => Theme.of(context).colorScheme.primaryContainer,
8080
getTooltipItems: (touchedSpots) {
8181
return touchedSpots.map((touchedSpot) {
82-
final DateTime date = DateTime.fromMillisecondsSinceEpoch(touchedSpot.x.toInt());
82+
final msSinceEpoch = touchedSpot.x.toInt();
83+
final DateTime date = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch);
8384
final dateStr =
8485
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date);
8586

87+
// Check if this is an interpolated point (milliseconds ending with 123)
88+
final bool isInterpolated = msSinceEpoch % 1000 == 123;
89+
final String interpolatedMarker = isInterpolated ? ' (interpolated)' : '';
90+
8691
return LineTooltipItem(
87-
'$dateStr: ${touchedSpot.y.toStringAsFixed(1)} ${widget._unit}',
92+
'$dateStr: ${touchedSpot.y.toStringAsFixed(1)} ${widget._unit}$interpolatedMarker',
8893
TextStyle(color: touchedSpot.bar.color),
8994
);
9095
}).toList();

lib/widgets/measurements/helpers.dart

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,6 @@ List<Widget> getOverviewWidgets(
3636
];
3737
}
3838

39-
// TODO(dieter): i'm not sure if this handles well the case where weights were not logged consistently
40-
// e.g. if the plan runs for a month, but the first point is after 3 weeks.
41-
// and the last (non-included) point was *right* before the startDate.
42-
// wouldn't it be better to interpolate the missing points?
4339
List<Widget> getOverviewWidgetsSeries(
4440
String name,
4541
List<MeasurementChartEntry> entriesAll,
@@ -61,8 +57,8 @@ List<Widget> getOverviewWidgetsSeries(
6157
for (final plan in plans)
6258
...getOverviewWidgets(
6359
AppLocalizations.of(context).chartDuringPlanTitle(name, plan.description),
64-
entriesAll.whereDate(plan.startDate, plan.endDate),
65-
entries7dAvg.whereDate(plan.startDate, plan.endDate),
60+
entriesAll.whereDateWithInterpolation(plan.startDate, plan.endDate),
61+
entries7dAvg.whereDateWithInterpolation(plan.startDate, plan.endDate),
6662
unit,
6763
context,
6864
),
@@ -74,8 +70,8 @@ List<Widget> getOverviewWidgetsSeries(
7470
entriesAll.any((e) => e.date.isAfter(monthAgo)))
7571
...getOverviewWidgets(
7672
AppLocalizations.of(context).chart30DaysTitle(name),
77-
entriesAll.whereDate(monthAgo, null),
78-
entries7dAvg.whereDate(monthAgo, null),
73+
entriesAll.whereDateWithInterpolation(monthAgo, null),
74+
entries7dAvg.whereDateWithInterpolation(monthAgo, null),
7975
unit,
8076
context,
8177
),

lib/widgets/nutrition/nutritional_plans_list.dart

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import 'package:flutter/material.dart';
2020
import 'package:intl/intl.dart';
2121
import 'package:provider/provider.dart';
22+
import 'package:wger/helpers/measurements.dart';
2223
import 'package:wger/l10n/generated/app_localizations.dart';
2324
import 'package:wger/providers/body_weight.dart';
2425
import 'package:wger/providers/nutrition.dart';
@@ -34,25 +35,17 @@ class NutritionalPlansList extends StatelessWidget {
3435

3536
/// Builds the weight change information for a nutritional plan period
3637
Widget _buildWeightChangeInfo(BuildContext context, DateTime startDate, DateTime? endDate) {
37-
final _provider = Provider.of<BodyWeightProvider>(context, listen: false);
38+
final provider = Provider.of<BodyWeightProvider>(context, listen: false);
3839

39-
final entriesAll = _provider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
40-
final entries7dAvg = moving7dAverage(entriesAll);
41-
// Filter weight entries within the plan period
42-
final DateTime planEndDate = endDate ?? DateTime.now();
43-
final List<MeasurementChartEntry> entriesInPeriod = entries7dAvg
44-
.where((entry) => entry.date.isAfter(startDate) && entry.date.isBefore(planEndDate))
45-
.toList();
46-
if (entriesInPeriod.length < 2) {
40+
final entriesAll = provider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
41+
final entries7dAvg = moving7dAverage(entriesAll).whereDateWithInterpolation(startDate, endDate);
42+
if (entries7dAvg.length < 2) {
4743
return const SizedBox.shrink();
4844
}
4945

50-
// Sort entries by date
51-
entriesInPeriod.sort((a, b) => a.date.compareTo(b.date));
52-
5346
// Calculate weight change
54-
final firstWeight = entriesInPeriod.first;
55-
final lastWeight = entriesInPeriod.last;
47+
final firstWeight = entries7dAvg.first;
48+
final lastWeight = entries7dAvg.last;
5649
final weightDifference = lastWeight.value - firstWeight.value;
5750

5851
// Format the weight change text and determine color
@@ -62,8 +55,6 @@ class NutritionalPlansList extends StatelessWidget {
6255

6356
final unit = weightUnit(profile!.isMetric, context);
6457

65-
// TODO: only proceed if it's "representative" (if we covered the plan timespan well enough), or actually,
66-
// we could also interpolate the missing values
6758
if (weightDifference > 0) {
6859
weightChangeText = '+${weightDifference.toStringAsFixed(1)} $unit';
6960
weightChangeColor = Colors.red;
@@ -80,7 +71,7 @@ class NutritionalPlansList extends StatelessWidget {
8071
child: Row(
8172
children: [
8273
Text(
83-
AppLocalizations.of(context).weight + ' change: ',
74+
'${AppLocalizations.of(context).weight} change: ',
8475
style: Theme.of(context).textTheme.bodySmall,
8576
),
8677
Text(

0 commit comments

Comments
 (0)