Skip to content

Commit a7ee4ff

Browse files
authored
Merge pull request #855 from wger-project/nutrition-plan-stats
Nutrition plan stats
2 parents f7ce7e5 + dbd3fa9 commit a7ee4ff

28 files changed

+671
-93
lines changed

lib/helpers/consts.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,7 @@ enum WeightUnitEnum { kg, lb }
137137
const textInputTypeDecimal = TextInputType.numberWithOptions(decimal: true);
138138

139139
const String API_MAX_PAGE_SIZE = '999';
140+
141+
/// Marker used for identifying interpolated values in a list, e.g. for measurements
142+
/// the milliseconds in the entry date are set to this value
143+
const INTERPOLATION_MARKER = 123;

lib/helpers/measurements.dart

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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/consts.dart';
20+
import 'package:wger/helpers/date.dart';
21+
import 'package:wger/widgets/measurements/charts.dart';
22+
23+
extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
24+
List<MeasurementChartEntry> whereDate(DateTime start, DateTime? end) {
25+
return where((e) => e.date.isAfter(start) && (end == null || e.date.isBefore(end))).toList();
26+
}
27+
28+
// assures values on the start (and optionally end) dates exist, by interpolating if needed
29+
// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan)
30+
// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer
31+
// explicit interpolation for the start and end dates (if needed)
32+
// this also helps with computing delta's across the entire window
33+
List<MeasurementChartEntry> whereDateWithInterpolation(DateTime start, DateTime? end) {
34+
// Make sure our list is sorted by date
35+
sort((a, b) => a.date.compareTo(b.date));
36+
37+
// Initialize result list
38+
final List<MeasurementChartEntry> result = [];
39+
40+
// Check if we have any entries on the same day as start/end
41+
bool hasEntryOnStartDay = false;
42+
bool hasEntryOnEndDay = false;
43+
44+
// Track entries for potential interpolation
45+
MeasurementChartEntry? lastBeforeStart;
46+
MeasurementChartEntry? lastBeforeEnd;
47+
48+
// Single pass through the data
49+
for (final entry in this) {
50+
if (entry.date.isSameDayAs(start)) {
51+
hasEntryOnStartDay = true;
52+
}
53+
if (end != null && entry.date.isSameDayAs(end)) {
54+
hasEntryOnEndDay = true;
55+
}
56+
57+
if (end != null && entry.date.isBefore(end)) {
58+
lastBeforeEnd = entry;
59+
}
60+
61+
if (entry.date.isBefore(start)) {
62+
lastBeforeStart = entry;
63+
} else {
64+
// insert interpolated start value if needed
65+
if (!hasEntryOnStartDay && lastBeforeStart != null) {
66+
result.insert(0, interpolateBetween(lastBeforeStart, entry, start));
67+
hasEntryOnStartDay = true;
68+
}
69+
70+
if (end == null || entry.date.isBefore(end)) {
71+
result.add(entry);
72+
}
73+
if (end != null && entry.date.isAfter(end)) {
74+
// insert interpolated end value if needed
75+
// note: we only interpolate end if we have data going beyond end
76+
// if let's say your plan ends in a week from now, we wouldn't want to fake data until next week.
77+
if (!hasEntryOnEndDay && lastBeforeEnd != null) {
78+
result.add(interpolateBetween(lastBeforeEnd, entry, end));
79+
hasEntryOnEndDay = true;
80+
}
81+
// we added all our values and did all interpolations
82+
// surely all input values from here on are irrelevant.
83+
return result;
84+
}
85+
}
86+
}
87+
return result;
88+
}
89+
}
90+
91+
// caller needs to make sure that before.date < date < after.date
92+
MeasurementChartEntry interpolateBetween(
93+
MeasurementChartEntry before, MeasurementChartEntry after, DateTime date) {
94+
final totalDuration = after.date.difference(before.date).inMilliseconds;
95+
final startDuration = date.difference(before.date).inMilliseconds;
96+
97+
// Create a special DateTime with milliseconds ending in 123 to mark it as interpolated
98+
// which we leverage in the UI
99+
final markedDate = DateTime(
100+
date.year,
101+
date.month,
102+
date.day,
103+
date.hour,
104+
date.minute,
105+
date.second,
106+
INTERPOLATION_MARKER,
107+
);
108+
109+
return MeasurementChartEntry(
110+
before.value + (after.value - before.value) * (startDuration / totalDuration),
111+
markedDate,
112+
);
113+
}

lib/l10n/app_en.arb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,18 @@
387387
"@date": {
388388
"description": "The date of a workout log or body weight entry"
389389
},
390+
"creationDate": "Start date",
391+
"@creationDate": {
392+
"description": "The Start date of a nutritional plan"
393+
},
394+
"endDate": "End date",
395+
"@endDate": {
396+
"description": "The End date of a nutritional plan"
397+
},
398+
"openEnded": "Open ended",
399+
"@openEnded": {
400+
"description": "When a nutrition plan has no pre-defined end date"
401+
},
390402
"value": "Value",
391403
"@value": {
392404
"description": "The value of a measurement entry"

lib/models/nutrition/nutritional_plan.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import 'package:collection/collection.dart';
2020
import 'package:flutter/widgets.dart';
2121
import 'package:json_annotation/json_annotation.dart';
22+
import 'package:logging/logging.dart';
2223
import 'package:wger/helpers/consts.dart';
2324
import 'package:wger/helpers/json.dart';
2425
import 'package:wger/l10n/generated/app_localizations.dart';
@@ -32,6 +33,8 @@ part 'nutritional_plan.g.dart';
3233

3334
@JsonSerializable(explicitToJson: true)
3435
class NutritionalPlan {
36+
final _logger = Logger('NutritionalPlan Model');
37+
3538
@JsonKey(required: true)
3639
int? id;
3740

@@ -41,6 +44,12 @@ class NutritionalPlan {
4144
@JsonKey(required: true, name: 'creation_date', toJson: dateToUtcIso8601)
4245
late DateTime creationDate;
4346

47+
@JsonKey(required: true, name: 'start', toJson: dateToYYYYMMDD)
48+
late DateTime startDate;
49+
50+
@JsonKey(required: true, name: 'end', toJson: dateToYYYYMMDD)
51+
late DateTime? endDate;
52+
4453
@JsonKey(required: true, name: 'only_logging')
4554
late bool onlyLogging;
4655

@@ -68,7 +77,9 @@ class NutritionalPlan {
6877
NutritionalPlan({
6978
this.id,
7079
required this.description,
71-
required this.creationDate,
80+
DateTime? creationDate,
81+
required this.startDate,
82+
this.endDate,
7283
this.onlyLogging = false,
7384
this.goalEnergy,
7485
this.goalProtein,
@@ -77,13 +88,23 @@ class NutritionalPlan {
7788
this.goalFiber,
7889
List<Meal>? meals,
7990
List<Log>? diaryEntries,
80-
}) {
91+
}) : creationDate = creationDate ?? DateTime.now() {
8192
this.meals = meals ?? [];
8293
this.diaryEntries = diaryEntries ?? [];
94+
95+
if (endDate != null && endDate!.isBefore(startDate)) {
96+
_logger.warning(
97+
'The end date of a nutritional plan is before the start. Setting to null! '
98+
'PlanId: $id, startDate: $startDate, endDate: $endDate',
99+
);
100+
endDate = null;
101+
}
83102
}
84103

85104
NutritionalPlan.empty() {
86105
creationDate = DateTime.now();
106+
startDate = DateTime.now();
107+
endDate = null;
87108
description = '';
88109
onlyLogging = false;
89110
goalEnergy = null;

lib/models/nutrition/nutritional_plan.g.dart

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/providers/nutrition.dart

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import 'dart:convert';
2020

21+
import 'package:collection/collection.dart';
2122
import 'package:flutter/material.dart';
2223
import 'package:logging/logging.dart';
2324
import 'package:wger/core/locator.dart';
@@ -66,13 +67,19 @@ class NutritionPlansProvider with ChangeNotifier {
6667
ingredients = [];
6768
}
6869

69-
/// Returns the current active nutritional plan. At the moment this is just
70-
/// the latest, but this might change in the future.
70+
/// Returns the current active nutritional plan.
71+
/// A plan is considered active if:
72+
/// - Its start date is before now
73+
/// - Its end date is after now or not set
74+
/// If multiple plans match these criteria, the one with the most recent creation date is returned.
7175
NutritionalPlan? get currentPlan {
72-
if (_plans.isNotEmpty) {
73-
return _plans.first;
74-
}
75-
return null;
76+
final now = DateTime.now();
77+
return _plans
78+
.where((plan) =>
79+
plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now)))
80+
.toList()
81+
.sorted((a, b) => b.creationDate.compareTo(a.creationDate))
82+
.firstOrNull;
7683
}
7784

7885
NutritionalPlan findById(int id) {

lib/widgets/measurements/charts.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,17 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
8282
NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
8383

8484
return touchedSpots.map((touchedSpot) {
85+
final msSinceEpoch = touchedSpot.x.toInt();
8586
final DateTime date = DateTime.fromMillisecondsSinceEpoch(touchedSpot.x.toInt());
8687
final dateStr =
8788
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date);
8889

90+
// Check if this is an interpolated point (milliseconds ending with 123)
91+
final bool isInterpolated = msSinceEpoch % 1000 == INTERPOLATION_MARKER;
92+
final String interpolatedMarker = isInterpolated ? ' (interpolated)' : '';
93+
8994
return LineTooltipItem(
90-
'$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}',
95+
'$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}$interpolatedMarker',
9196
TextStyle(color: touchedSpot.bar.color),
9297
);
9398
}).toList();

lib/widgets/measurements/entries.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class EntriesList extends StatelessWidget {
3636

3737
@override
3838
Widget build(BuildContext context) {
39-
final plan = Provider.of<NutritionPlansProvider>(context, listen: false).currentPlan;
39+
final plans = Provider.of<NutritionPlansProvider>(context, listen: false).items;
4040
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
4141
final provider = Provider.of<MeasurementProvider>(context, listen: false);
4242

@@ -49,7 +49,7 @@ class EntriesList extends StatelessWidget {
4949
_category.name,
5050
entriesAll,
5151
entries7dAvg,
52-
plan,
52+
plans,
5353
_category.unit,
5454
context,
5555
),

0 commit comments

Comments
 (0)