From 36697a660d8fa8e580bb14e69598778ef2ba1fe0 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Fri, 3 May 2024 15:15:24 +0200 Subject: [PATCH] proper nullable "nutritional goals" with inference differentiate between a goal being set (but as 0) vs a goal not being set. fixes various correctness issues --- lib/models/nutrition/nutritional_goals.dart | 142 ++++++++++++++++++ lib/models/nutrition/nutritional_plan.dart | 52 +++---- lib/widgets/nutrition/charts.dart | 77 +++++----- .../nutrition/nutritional_diary_detail.dart | 4 +- .../nutrition/nutritional_plan_detail.dart | 104 +++++++------ .../nutritional_plan_model_test.dart | 3 +- 6 files changed, 264 insertions(+), 118 deletions(-) create mode 100644 lib/models/nutrition/nutritional_goals.dart diff --git a/lib/models/nutrition/nutritional_goals.dart b/lib/models/nutrition/nutritional_goals.dart new file mode 100644 index 00000000..5d7086fe --- /dev/null +++ b/lib/models/nutrition/nutritional_goals.dart @@ -0,0 +1,142 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (C) 2020, 2021 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:wger/helpers/consts.dart'; +import 'package:wger/models/nutrition/nutritional_values.dart'; + +class NutritionalGoals { + double? energy = 0; + double? protein = 0; + double? carbohydrates = 0; + double? carbohydratesSugar = 0; + double? fat = 0; + double? fatSaturated = 0; + double? fibres = 0; + double? sodium = 0; + + NutritionalGoals({ + this.energy, + this.protein, + this.carbohydrates, + this.carbohydratesSugar, + this.fat, + this.fatSaturated, + this.fibres, + this.sodium, + }) { + // infer values where we can + if (energy == null) { + if (protein != null && carbohydrates != null && fat != null) { + energy = + protein! * ENERGY_PROTEIN + carbohydrates! * ENERGY_CARBOHYDRATES + fat! * ENERGY_FAT; + } + return; + } + // TODO: input validation when the user modifies/creates the plan, to assure energy is high enough + if (protein == null && carbohydrates != null && fat != null) { + protein = + (energy! - carbohydrates! * ENERGY_CARBOHYDRATES - fat! * ENERGY_FAT) / ENERGY_PROTEIN; + assert(protein! > 0); + } else if (carbohydrates == null && protein != null && fat != null) { + carbohydrates = + (energy! - protein! * ENERGY_PROTEIN - fat! * ENERGY_FAT) / ENERGY_CARBOHYDRATES; + assert(carbohydrates! > 0); + } else if (fat == null && protein != null && carbohydrates != null) { + fat = (energy! - protein! * ENERGY_PROTEIN - carbohydrates! * ENERGY_CARBOHYDRATES) / + ENERGY_FAT; + assert(fat! > 0); + } + } + + NutritionalGoals operator /(double v) { + return NutritionalGoals( + energy: energy != null ? energy! / v : null, + protein: protein != null ? protein! / v : null, + carbohydrates: carbohydrates != null ? carbohydrates! / v : null, + carbohydratesSugar: carbohydratesSugar != null ? carbohydratesSugar! / v : null, + fat: fat != null ? fat! / v : null, + fatSaturated: fatSaturated != null ? fatSaturated! / v : null, + fibres: fibres != null ? fibres! / v : null, + sodium: sodium != null ? sodium! / v : null, + ); + } + + bool isComplete() { + return energy != null && protein != null && carbohydrates != null && fat != null; + } + + /// Convert goals into values. + /// This turns unset goals into values of 0. + /// Only use this if you know what you're doing, e.g. if isComplete() is true + NutritionalValues toValues() { + return NutritionalValues.values( + energy ?? 0, + protein ?? 0, + carbohydrates ?? 0, + carbohydratesSugar ?? 0, + fat ?? 0, + fatSaturated ?? 0, + fibres ?? 0, + sodium ?? 0, + ); + } + + /// Calculates the percentage each macro nutrient adds to the total energy + NutritionalGoals energyPercentage() { + final goals = NutritionalGoals(); + if (energy == null) { + return goals; + } + assert(energy! > 0); + + if (protein != null) { + goals.protein = (100 * protein! * ENERGY_PROTEIN) / energy!; + } + if (carbohydrates != null) { + goals.carbohydrates = (100 * carbohydrates! * ENERGY_CARBOHYDRATES) / energy!; + } + if (fat != null) { + goals.fat = (100 * fat! * ENERGY_FAT) / energy!; + } + return goals; + } + + double? prop(String name) { + return switch (name) { + 'energy' => energy, + 'protein' => protein, + 'carbohydrates' => carbohydrates, + 'carbohydratesSugar' => carbohydratesSugar, + 'fat' => fat, + 'fatSaturated' => fatSaturated, + 'fibres' => fibres, + 'sodium' => sodium, + _ => 0, + }; + } + + @override + String toString() { + return 'e: $energy, p: $protein, c: $carbohydrates, cS: $carbohydratesSugar, f: $fat, fS: $fatSaturated, fi: $fibres, s: $sodium'; + } + + @override + //ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hash( + energy, protein, carbohydrates, carbohydratesSugar, fat, fatSaturated, fibres, sodium); +} diff --git a/lib/models/nutrition/nutritional_plan.dart b/lib/models/nutrition/nutritional_plan.dart index f03734e2..71f27d1e 100644 --- a/lib/models/nutrition/nutritional_plan.dart +++ b/lib/models/nutrition/nutritional_plan.dart @@ -24,6 +24,7 @@ import 'package:wger/helpers/json.dart'; import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; +import 'package:wger/models/nutrition/nutritional_goals.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; part 'nutritional_plan.g.dart'; @@ -107,19 +108,28 @@ class NutritionalPlan { /// note that (some of) this is already done on the server. It might be better /// to read it from there, but on the other hand we might want to do more locally /// so that a mostly offline mode is possible. - NutritionalValues get plannedNutritionalValues { + NutritionalGoals get nutritionalGoals { // If there are set goals, they take preference over any meals if (hasAnyGoals) { - final out = NutritionalValues(); - - out.energy = goalEnergy != null ? goalEnergy!.toDouble() : 0; - out.fat = goalFat != null ? goalFat!.toDouble() : 0; - out.carbohydrates = goalCarbohydrates != null ? goalCarbohydrates!.toDouble() : 0; - out.protein = goalProtein != null ? goalProtein!.toDouble() : 0; - return out; + return NutritionalGoals( + energy: goalEnergy?.toDouble(), + fat: goalFat?.toDouble(), + protein: goalProtein?.toDouble(), + carbohydrates: goalCarbohydrates?.toDouble(), + ); } - - return meals.fold(NutritionalValues(), (a, b) => a + b.plannedNutritionalValues); + // otherwise, add up all the nutritional values of the meals and use that as goals + final sumValues = meals.fold(NutritionalValues(), (a, b) => a + b.plannedNutritionalValues); + return NutritionalGoals( + energy: sumValues.energy, + fat: sumValues.fat, + protein: sumValues.protein, + carbohydrates: sumValues.carbohydrates, + carbohydratesSugar: sumValues.carbohydratesSugar, + fatSaturated: sumValues.fatSaturated, + fibres: sumValues.fibres, + sodium: sumValues.sodium, + ); } NutritionalValues get loggedNutritionalValuesToday { @@ -138,28 +148,6 @@ class NutritionalPlan { .fold(NutritionalValues(), (a, b) => a + b.nutritionalValues); } - /// Calculates the percentage each macro nutrient adds to the total energy - BaseNutritionalValues energyPercentage(NutritionalValues values) { - return BaseNutritionalValues( - values.protein > 0 ? ((values.protein * ENERGY_PROTEIN * 100) / values.energy) : 0, - values.carbohydrates > 0 - ? ((values.carbohydrates * ENERGY_CARBOHYDRATES * 100) / values.energy) - : 0, - values.fat > 0 ? ((values.fat * ENERGY_FAT * 100) / values.energy) : 0, - ); - } - - /// Calculates the grams per body-kg for each macro nutrient - BaseNutritionalValues gPerBodyKg(num weight, NutritionalValues values) { - assert(weight > 0); - - return BaseNutritionalValues( - values.protein / weight, - values.carbohydrates / weight, - values.fat / weight, - ); - } - Map get logEntriesValues { final out = {}; for (final log in diaryEntries) { diff --git a/lib/widgets/nutrition/charts.dart b/lib/widgets/nutrition/charts.dart index 48a305ef..e3764bf0 100644 --- a/lib/widgets/nutrition/charts.dart +++ b/lib/widgets/nutrition/charts.dart @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import 'dart:math'; + import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -50,7 +52,7 @@ class FlNutritionalPlanGoalWidgetState extends State maxVal) { - maxVal = today.protein / plan.goalProtein!; - } - - if (plan.goalCarbohydrates != null && - today.carbohydrates / plan.goalCarbohydrates! > maxVal) { - maxVal = today.carbohydrates / plan.goalCarbohydrates!; - } - - if (plan.goalFat != null && today.fat / plan.goalFat! > maxVal) { - maxVal = today.fat / plan.goalFat!; - } - - if (plan.goalEnergy != null && today.energy / plan.goalEnergy! > maxVal) { - maxVal = today.energy / plan.goalEnergy!; - } + // if any of the bars goes over 100%, find the one that goes over the most + // that one needs the most horizontal space to show how much it goes over, + // and therefore reduces the width of "100%" the most, and this width we want + // to be consistent for all other bars. + // if none goes over, 100% means fill all available space + final maxVal = [ + 1.0, + if (goals.protein != null && goals.protein! > 0) today.protein / goals.protein!, + if (goals.carbohydrates != null && goals.carbohydrates! > 0) + today.carbohydrates / goals.carbohydrates!, + if (goals.fat != null && goals.fat! > 0) today.fat / goals.fat!, + if (goals.energy != null && goals.energy! > 0) today.energy / goals.energy! + ].reduce(max); final normWidth = constraints.maxWidth / maxVal; + String fmtMacro(String name, double today, double? goal, String unit) { + return '$name: ${today.toStringAsFixed(0)}${goal == null ? '' : ' /${goal.toStringAsFixed(0)}'} $unit'; + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(plan.goalProtein == null - ? '${AppLocalizations.of(context).protein}: ${today.protein.toStringAsFixed(0)} ${AppLocalizations.of(context).g}' - : '${AppLocalizations.of(context).protein}: ${today.protein.toStringAsFixed(0)} / ${plan.goalProtein} ${AppLocalizations.of(context).g}'), + Text(fmtMacro(AppLocalizations.of(context).protein, today.protein, goals.protein, + AppLocalizations.of(context).g)), const SizedBox(height: 2), - _DIYGauge(context, normWidth, plan.goalProtein, today.protein), + _DIYGauge(context, normWidth, goals.protein, today.protein), const SizedBox(height: 8), - Text(plan.goalCarbohydrates == null - ? '${AppLocalizations.of(context).carbohydrates}: ${today.carbohydrates.toStringAsFixed(0)} ${AppLocalizations.of(context).g}' - : '${AppLocalizations.of(context).carbohydrates}: ${today.carbohydrates.toStringAsFixed(0)} / ${plan.goalCarbohydrates} ${AppLocalizations.of(context).g}'), + Text(fmtMacro(AppLocalizations.of(context).carbohydrates, today.carbohydrates, + goals.carbohydrates, AppLocalizations.of(context).g)), const SizedBox(height: 2), - _DIYGauge(context, normWidth, plan.goalCarbohydrates, today.carbohydrates), + _DIYGauge(context, normWidth, goals.carbohydrates, today.carbohydrates), const SizedBox(height: 8), - Text(plan.goalFat == null - ? '${AppLocalizations.of(context).fat}: ${today.fat.toStringAsFixed(0)} ${AppLocalizations.of(context).g}' - : '${AppLocalizations.of(context).fat}: ${today.fat.toStringAsFixed(0)} / ${plan.goalFat} ${AppLocalizations.of(context).g}'), + Text(fmtMacro(AppLocalizations.of(context).fat, today.fat, goals.fat, + AppLocalizations.of(context).g)), const SizedBox(height: 2), - _DIYGauge(context, normWidth, plan.goalFat, today.fat), + _DIYGauge(context, normWidth, goals.fat, today.fat), const SizedBox(height: 8), - Text(plan.goalEnergy == null - ? '${AppLocalizations.of(context).energy}: ${today.energy.toStringAsFixed(0)} ${AppLocalizations.of(context).kcal}' - : '${AppLocalizations.of(context).energy}: ${today.energy.toStringAsFixed(0)} / ${plan.goalEnergy} ${AppLocalizations.of(context).kcal}'), + Text(fmtMacro(AppLocalizations.of(context).energy, today.energy, goals.energy, + AppLocalizations.of(context).kcal)), const SizedBox(height: 2), - _DIYGauge(context, normWidth, plan.goalEnergy, today.energy), + _DIYGauge(context, normWidth, goals.energy, today.energy), ], ); }); @@ -313,7 +308,7 @@ class NutritionalDiaryChartWidgetFlState extends State(context, listen: false).getNewestEntry(); - final valuesGperKg = lastWeightEntry != null - ? _nutritionalPlan.gPerBodyKg(lastWeightEntry.weight, plannedNutritionalValues) - : null; + final nutritionalGoalsGperKg = + lastWeightEntry != null ? nutritionalGoals / lastWeightEntry.weight.toDouble() : null; return SliverList( delegate: SliverChildListDelegate( @@ -82,17 +81,18 @@ class NutritionalPlanDetailWidget extends StatelessWidget { }, ), ), - Container( - padding: const EdgeInsets.all(15), - height: 220, - child: FlNutritionalPlanPieChartWidget(plannedNutritionalValues), // chart - ), + if (nutritionalGoals.isComplete()) + Container( + padding: const EdgeInsets.all(15), + height: 220, + child: FlNutritionalPlanPieChartWidget(nutritionalGoals.toValues()), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: MacronutrientsTable( - plannedNutritionalValues: plannedNutritionalValues, - plannedValuesPercentage: _nutritionalPlan.energyPercentage(plannedNutritionalValues), - plannedValuesGperKg: valuesGperKg, + nutritionalGoals: nutritionalGoals, + plannedValuesPercentage: nutritionalGoals.energyPercentage(), + nutritionalGoalsGperKg: nutritionalGoalsGperKg, ), ), const Padding(padding: EdgeInsets.all(8.0)), @@ -104,7 +104,7 @@ class NutritionalPlanDetailWidget extends StatelessWidget { Container( padding: const EdgeInsets.only(top: 15, left: 15, right: 15), height: 300, - child: NutritionalDiaryChartWidgetFl(nutritionalPlan: _nutritionalPlan), // chart + child: NutritionalDiaryChartWidgetFl(nutritionalPlan: _nutritionalPlan), ), Padding( padding: const EdgeInsets.only(bottom: 40, left: 25, right: 25), @@ -164,15 +164,15 @@ class NutritionalPlanDetailWidget extends StatelessWidget { class MacronutrientsTable extends StatelessWidget { const MacronutrientsTable({ super.key, - required this.plannedNutritionalValues, + required this.nutritionalGoals, required this.plannedValuesPercentage, - required this.plannedValuesGperKg, + required this.nutritionalGoalsGperKg, }); static const double tablePadding = 7; - final NutritionalValues plannedNutritionalValues; - final BaseNutritionalValues plannedValuesPercentage; - final BaseNutritionalValues? plannedValuesGperKg; + final NutritionalGoals nutritionalGoals; + final NutritionalGoals plannedValuesPercentage; + final NutritionalGoals? nutritionalGoalsGperKg; @override Widget build(BuildContext context) { @@ -216,8 +216,9 @@ class MacronutrientsTable extends StatelessWidget { child: Text(AppLocalizations.of(context).energy), ), Text( - plannedNutritionalValues.energy.toStringAsFixed(0) + - AppLocalizations.of(context).kcal, + nutritionalGoals.energy != null + ? nutritionalGoals.energy!.toStringAsFixed(0) + AppLocalizations.of(context).kcal + : '', ), const Text(''), const Text(''), @@ -229,11 +230,15 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding), child: Text(AppLocalizations.of(context).protein), ), - Text(plannedNutritionalValues.protein.toStringAsFixed(0) + - AppLocalizations.of(context).g), - Text(plannedValuesPercentage.protein.toStringAsFixed(1)), - Text( - plannedValuesGperKg != null ? plannedValuesGperKg!.protein.toStringAsFixed(1) : ''), + Text(nutritionalGoals.protein != null + ? nutritionalGoals.protein!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), + Text(plannedValuesPercentage.protein != null + ? plannedValuesPercentage.protein!.toStringAsFixed(1) + : ''), + Text(nutritionalGoalsGperKg != null && nutritionalGoalsGperKg!.protein != null + ? nutritionalGoalsGperKg!.protein!.toStringAsFixed(1) + : ''), ], ), TableRow( @@ -242,11 +247,15 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding), child: Text(AppLocalizations.of(context).carbohydrates), ), - Text(plannedNutritionalValues.carbohydrates.toStringAsFixed(0) + - AppLocalizations.of(context).g), - Text(plannedValuesPercentage.carbohydrates.toStringAsFixed(1)), - Text(plannedValuesGperKg != null - ? plannedValuesGperKg!.carbohydrates.toStringAsFixed(1) + Text(nutritionalGoals.carbohydrates != null + ? nutritionalGoals.carbohydrates!.toStringAsFixed(0) + + AppLocalizations.of(context).g + : ''), + Text(plannedValuesPercentage.carbohydrates != null + ? plannedValuesPercentage.carbohydrates!.toStringAsFixed(1) + : ''), + Text(nutritionalGoalsGperKg != null && nutritionalGoalsGperKg!.carbohydrates != null + ? nutritionalGoalsGperKg!.carbohydrates!.toStringAsFixed(1) : ''), ], ), @@ -256,10 +265,12 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding, horizontal: 12), child: Text(AppLocalizations.of(context).sugars), ), - Text(plannedNutritionalValues.carbohydratesSugar.toStringAsFixed(0) + - AppLocalizations.of(context).g), const Text(''), const Text(''), + Text(nutritionalGoals.carbohydratesSugar != null + ? nutritionalGoals.carbohydratesSugar!.toStringAsFixed(0) + + AppLocalizations.of(context).g + : ''), ], ), TableRow( @@ -268,9 +279,15 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding), child: Text(AppLocalizations.of(context).fat), ), - Text(plannedNutritionalValues.fat.toStringAsFixed(0) + AppLocalizations.of(context).g), - Text(plannedValuesPercentage.fat.toStringAsFixed(1)), - Text(plannedValuesGperKg != null ? plannedValuesGperKg!.fat.toStringAsFixed(1) : ''), + Text(nutritionalGoals.fat != null + ? nutritionalGoals.fat!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), + Text(plannedValuesPercentage.fat != null + ? plannedValuesPercentage.fat!.toStringAsFixed(1) + : ''), + Text(nutritionalGoalsGperKg != null && nutritionalGoalsGperKg!.fat != null + ? nutritionalGoalsGperKg!.fat!.toStringAsFixed(1) + : ''), ], ), TableRow( @@ -279,10 +296,11 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding, horizontal: 12), child: Text(AppLocalizations.of(context).saturatedFat), ), - Text(plannedNutritionalValues.fatSaturated.toStringAsFixed(0) + - AppLocalizations.of(context).g), const Text(''), const Text(''), + Text(nutritionalGoals.fatSaturated != null + ? nutritionalGoals.fatSaturated!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), ], ), TableRow( @@ -291,10 +309,11 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding), child: Text(AppLocalizations.of(context).fibres), ), - Text(plannedNutritionalValues.fibres.toStringAsFixed(0) + - AppLocalizations.of(context).g), const Text(''), const Text(''), + Text(nutritionalGoals.fibres != null + ? nutritionalGoals.fibres!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), ], ), TableRow( @@ -303,10 +322,11 @@ class MacronutrientsTable extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: tablePadding), child: Text(AppLocalizations.of(context).sodium), ), - Text(plannedNutritionalValues.sodium.toStringAsFixed(0) + - AppLocalizations.of(context).g), const Text(''), const Text(''), + Text(nutritionalGoals.sodium != null + ? nutritionalGoals.sodium!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), ], ), ], diff --git a/test/nutrition/nutritional_plan_model_test.dart b/test/nutrition/nutritional_plan_model_test.dart index a832d006..23b09d34 100644 --- a/test/nutrition/nutritional_plan_model_test.dart +++ b/test/nutrition/nutritional_plan_model_test.dart @@ -17,6 +17,7 @@ */ import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/models/nutrition/nutritional_goals.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; @@ -32,7 +33,7 @@ void main() { group('model tests', () { test('Test the nutritionalValues method for nutritional plans', () { final values = NutritionalValues.values(4118.75, 32.75, 347.5, 9.5, 59.0, 37.75, 52.5, 30.5); - expect(plan.plannedNutritionalValues, values); + expect(plan.nutritionalGoals, values); }); test('Test the nutritionalValues method for meals', () {