diff --git a/.gitignore b/.gitignore index 728c5893e..0df000b82 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ .pub-cache/ .pub/ /build/ +**/failures/*.png + # Web related lib/generated_plugin_registrant.dart diff --git a/lib/helpers/colors.dart b/lib/helpers/colors.dart index b7fd22885..b454bbaf1 100644 --- a/lib/helpers/colors.dart +++ b/lib/helpers/colors.dart @@ -25,6 +25,8 @@ const LIST_OF_COLORS3 = [ Color(0xFFFFA600), ]; +const COLOR_SURPLUS = Color.fromARGB(255, 231, 71, 71); + Iterable generateChartColors(int nrOfItems) sync* { final List colors; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c23aa6c56..7476eb461 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -352,6 +352,14 @@ "@weekAverage": { "description": "Header for the column of '7 day average' nutritional values, i.e. what was logged last week" }, + "surplus": "surplus", + "@surplus": { + "description": "Caloric surplus (either planned or unplanned)" + }, + "deficit": "deficit", + "@deficit": { + "description": "Caloric deficit (either planned or unplanned)" + }, "difference": "Difference", "@difference": {}, "percentEnergy": "Percent of energy", diff --git a/lib/models/nutrition/meal.dart b/lib/models/nutrition/meal.dart index 058054083..a90b83c06 100644 --- a/lib/models/nutrition/meal.dart +++ b/lib/models/nutrition/meal.dart @@ -64,7 +64,6 @@ class Meal { this.mealItems = mealItems ?? []; this.diaryEntries = diaryEntries ?? []; - time = time ?? TimeOfDay.fromDateTime(DateTime.now()); this.name = name ?? ''; } diff --git a/lib/models/nutrition/nutritional_goals.dart b/lib/models/nutrition/nutritional_goals.dart new file mode 100644 index 000000000..4a03ecbc2 --- /dev/null +++ b/lib/models/nutrition/nutritional_goals.dart @@ -0,0 +1,161 @@ +/* + * 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(); + // when you create a new plan or meal, somehow goals like energy is set to 0 + // whereas strictly speaking it should be null. However, + // we know the intention so treat 0 as null here. + if (energy == null || energy == 0) { + return goals; + } + + 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); + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is NutritionalGoals && + other.energy == energy && + other.protein == protein && + other.carbohydrates == carbohydrates && + other.carbohydratesSugar == carbohydratesSugar && + other.fat == fat && + other.fatSaturated == fatSaturated && + other.fibres == fibres && + other.sodium == sodium; + } +} diff --git a/lib/models/nutrition/nutritional_plan.dart b/lib/models/nutrition/nutritional_plan.dart index f03734e25..afd50d813 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,32 @@ 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); + // if there are no set goals and no defined meals, the goals are still undefined + if (meals.isEmpty) { + return NutritionalGoals(); + } + // 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 +152,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) { @@ -220,7 +212,6 @@ class NutritionalPlan { id: PSEUDO_MEAL_ID, plan: id, name: name, - time: null, diaryEntries: diaryEntries.where((e) => e.mealId == null).toList(), ); } diff --git a/lib/models/nutrition/nutritional_values.dart b/lib/models/nutrition/nutritional_values.dart index cd5dcfbdb..17c232dd6 100644 --- a/lib/models/nutrition/nutritional_values.dart +++ b/lib/models/nutrition/nutritional_values.dart @@ -106,11 +106,3 @@ class NutritionalValues { int get hashCode => Object.hash( energy, protein, carbohydrates, carbohydratesSugar, fat, fatSaturated, fibres, sodium); } - -class BaseNutritionalValues { - double protein = 0; - double carbohydrates = 0; - double fat = 0; - - BaseNutritionalValues(this.protein, this.carbohydrates, this.fat); -} diff --git a/lib/widgets/dashboard/widgets.dart b/lib/widgets/dashboard/widgets.dart index 901c499d1..93297d4af 100644 --- a/lib/widgets/dashboard/widgets.dart +++ b/lib/widgets/dashboard/widgets.dart @@ -45,6 +45,8 @@ import 'package:wger/widgets/nutrition/forms.dart'; import 'package:wger/widgets/weight/forms.dart'; import 'package:wger/widgets/workouts/forms.dart'; +enum viewMode { base, withMeals, withMealsDetails } + class DashboardNutritionWidget extends StatefulWidget { @override _DashboardNutritionWidgetState createState() => _DashboardNutritionWidgetState(); @@ -52,7 +54,7 @@ class DashboardNutritionWidget extends StatefulWidget { class _DashboardNutritionWidgetState extends State { NutritionalPlan? _plan; - var _showDetail = false; + var _viewMode = viewMode.base; bool _hasContent = false; @override @@ -69,79 +71,81 @@ class _DashboardNutritionWidgetState extends State { return out; } - for (final meal in _plan!.meals) { - out.add( - Row( - children: [ - Expanded( - child: Text( - meal.time!.format(context), - style: const TextStyle(fontWeight: FontWeight.bold), - //textAlign: TextAlign.left, + if (_viewMode == viewMode.withMealsDetails || _viewMode == viewMode.withMeals) { + for (final meal in _plan!.meals) { + out.add( + Row( + children: [ + Expanded( + child: Text( + meal.time!.format(context), + style: const TextStyle(fontWeight: FontWeight.bold), + //textAlign: TextAlign.left, + ), ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.min, - children: [ - MutedText( - '${AppLocalizations.of(context).energyShort} ${meal.plannedNutritionalValues.energy.toStringAsFixed(0)}${AppLocalizations.of(context).kcal}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).proteinShort} ${meal.plannedNutritionalValues.protein.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).carbohydratesShort} ${meal.plannedNutritionalValues.carbohydrates.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), - const MutedText(' / '), - MutedText( - '${AppLocalizations.of(context).fatShort} ${meal.plannedNutritionalValues.fat.toStringAsFixed(0)}${AppLocalizations.of(context).g} '), - ], - ), - IconButton( - icon: const Icon(Icons.history_edu), - color: wgerPrimaryButtonColor, - onPressed: () { - Provider.of(context, listen: false).logMealToDiary(meal); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).mealLogged, - textAlign: TextAlign.center, + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.min, + children: [ + MutedText( + '${AppLocalizations.of(context).energyShort} ${meal.plannedNutritionalValues.energy.toStringAsFixed(0)}${AppLocalizations.of(context).kcal}'), + const MutedText(' / '), + MutedText( + '${AppLocalizations.of(context).proteinShort} ${meal.plannedNutritionalValues.protein.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), + const MutedText(' / '), + MutedText( + '${AppLocalizations.of(context).carbohydratesShort} ${meal.plannedNutritionalValues.carbohydrates.toStringAsFixed(0)}${AppLocalizations.of(context).g}'), + const MutedText(' / '), + MutedText( + '${AppLocalizations.of(context).fatShort} ${meal.plannedNutritionalValues.fat.toStringAsFixed(0)}${AppLocalizations.of(context).g} '), + ], + ), + IconButton( + icon: const Icon(Icons.history_edu), + color: wgerPrimaryButtonColor, + onPressed: () { + Provider.of(context, listen: false).logMealToDiary(meal); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).mealLogged, + textAlign: TextAlign.center, + ), ), - ), - ); - }, - ), - ], - ), - ); - out.add(const SizedBox(height: 5)); + ); + }, + ), + ], + ), + ); + out.add(const SizedBox(height: 5)); - if (_showDetail) { - for (final item in meal.mealItems) { - out.add( - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - item.ingredient.name, - overflow: TextOverflow.ellipsis, + if (_viewMode == viewMode.withMealsDetails) { + for (final item in meal.mealItems) { + out.add( + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.ingredient.name, + overflow: TextOverflow.ellipsis, + ), ), - ), - const SizedBox(width: 5), - Text('${item.amount.toStringAsFixed(0)} ${AppLocalizations.of(context).g}'), - ], - ), - ], - ), - ); + const SizedBox(width: 5), + Text('${item.amount.toStringAsFixed(0)} ${AppLocalizations.of(context).g}'), + ], + ), + ], + ), + ); + } + out.add(const SizedBox(height: 10)); } - out.add(const SizedBox(height: 10)); + out.add(const Divider()); } - out.add(const Divider()); } return out; @@ -170,15 +174,19 @@ class _DashboardNutritionWidgetState extends State { trailing: _hasContent ? Tooltip( message: AppLocalizations.of(context).toggleDetails, - child: _showDetail - ? const Icon( - Icons.info, - ) - : const Icon(Icons.info_outline)) + child: switch (_viewMode) { + viewMode.base => const Icon(Icons.info_outline), + viewMode.withMeals => const Icon(Icons.info), + viewMode.withMealsDetails => const Icon(Icons.info), + }) : const SizedBox(), onTap: () { setState(() { - _showDetail = !_showDetail; + _viewMode = switch (_viewMode) { + viewMode.base => viewMode.withMeals, + viewMode.withMeals => viewMode.withMealsDetails, + viewMode.withMealsDetails => viewMode.base, + }; }); }, ), @@ -190,9 +198,9 @@ class _DashboardNutritionWidgetState extends State { ...getContent(), Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 15), - height: 180, - child: FlNutritionalPlanPieChartWidget(_plan!.plannedNutritionalValues), - ) + height: 206, + child: FlNutritionalPlanGoalWidget(nutritionalPlan: _plan!), + ), ], )) else @@ -617,6 +625,7 @@ class NothingFound extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( _titleForm, + hasListView: true, _form, ), ); diff --git a/lib/widgets/nutrition/charts.dart b/lib/widgets/nutrition/charts.dart index 9501cab71..cf7b74c92 100644 --- a/lib/widgets/nutrition/charts.dart +++ b/lib/widgets/nutrition/charts.dart @@ -16,15 +16,126 @@ * 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'; -import 'package:intl/intl.dart'; import 'package:wger/helpers/colors.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; import 'package:wger/widgets/measurements/charts.dart'; +class FlNutritionalPlanGoalWidget extends StatefulWidget { + const FlNutritionalPlanGoalWidget({ + super.key, + required NutritionalPlan nutritionalPlan, + }) : _nutritionalPlan = nutritionalPlan; + + final NutritionalPlan _nutritionalPlan; + + @override + State createState() => FlNutritionalPlanGoalWidgetState(); +} + +// * fl_chart doesn't support horizontal bar charts yet. +// see https://github.com/imaNNeo/fl_chart/issues/113 +// even if it did, i doubt it would let us put text between the gauges/bars +// * LinearProgressIndicator has no way to visualize going beyond 100%, or +// using multiple colors to show multiple components such as surplus, deficit +// * here we draw our own simple gauges that can go beyond 100%, +// and support multiple segments +class FlNutritionalPlanGoalWidgetState extends State { + // normWidth is the width representing 100% completion + // note that if val > plan, we will draw beyond this width + // therefore, caller must set this width to accommodate surpluses. + // why don't we just handle this inside this function? because it might be + // *another* gauge that's in surplus and we want to have consistent widths + // between all gauges + Widget _DIYGauge(BuildContext context, double normWidth, double? plan, double val) { + Container segment(double width, Color color) { + return Container( + height: 16, + width: width, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(15.0)), + ); + } + + // paint a simple bar + if (plan == null || val == plan) { + return segment(normWidth, LIST_OF_COLORS8[0]); + } + + // paint a surplus + if (val > plan) { + return Stack(children: [ + segment(normWidth * val / plan, COLOR_SURPLUS), + segment(normWidth, LIST_OF_COLORS8[0]), + ]); + } + + // paint a deficit + return Stack(children: [ + segment(normWidth, Theme.of(context).colorScheme.surface), + segment(normWidth * val / plan, LIST_OF_COLORS8[0]), + ]); + } + + @override + Widget build(BuildContext context) { + final plan = widget._nutritionalPlan; + final goals = plan.nutritionalGoals; + final today = plan.loggedNutritionalValuesToday; + + return LayoutBuilder(builder: (context, constraints) { + // 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(fmtMacro(AppLocalizations.of(context).protein, today.protein, goals.protein, + AppLocalizations.of(context).g)), + const SizedBox(height: 2), + _DIYGauge(context, normWidth, goals.protein, today.protein), + const SizedBox(height: 8), + Text(fmtMacro(AppLocalizations.of(context).carbohydrates, today.carbohydrates, + goals.carbohydrates, AppLocalizations.of(context).g)), + const SizedBox(height: 2), + _DIYGauge(context, normWidth, goals.carbohydrates, today.carbohydrates), + const SizedBox(height: 8), + Text(fmtMacro(AppLocalizations.of(context).fat, today.fat, goals.fat, + AppLocalizations.of(context).g)), + const SizedBox(height: 2), + _DIYGauge(context, normWidth, goals.fat, today.fat), + const SizedBox(height: 8), + Text(fmtMacro(AppLocalizations.of(context).energy, today.energy, goals.energy, + AppLocalizations.of(context).kcal)), + const SizedBox(height: 2), + _DIYGauge(context, normWidth, goals.energy, today.energy), + ], + ); + }); + } +} + class NutritionData { final String name; final double value; @@ -197,20 +308,18 @@ class NutritionalDiaryChartWidgetFlState extends State value % 10 == 0, - getDrawingHorizontalLine: (value) => const FlLine( - color: Colors.black, - strokeWidth: 1, - ), - drawVerticalLine: false, + return LayoutBuilder( + builder: (context, constraints) { + final barsSpace = 6.0 * constraints.maxWidth / 400; + final barsWidth = 12.0 * constraints.maxWidth / 400; + return BarChart( + BarChartData( + alignment: BarChartAlignment.center, + barTouchData: BarTouchData( + enabled: false, + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 48, + getTitlesWidget: bottomTitles, ), - borderData: FlBorderData( - show: false, + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: leftTitles, ), - groupsSpace: 30, - barGroups: [ - barchartGroup(0, barsSpace, barsWidth, 'protein'), - barchartGroup(1, barsSpace, barsWidth, 'carbohydrates'), - barchartGroup(2, barsSpace, barsWidth, 'carbohydratesSugar'), - barchartGroup(3, barsSpace, barsWidth, 'fat'), - barchartGroup(4, barsSpace, barsWidth, 'fatSaturated'), - ], ), - ); - }, - ), - ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + checkToShowHorizontalLine: (value) => value % 10 == 0, + getDrawingHorizontalLine: (value) => const FlLine( + color: Colors.black, + strokeWidth: 1, + ), + drawVerticalLine: false, + ), + borderData: FlBorderData( + show: false, + ), + groupsSpace: 30, + barGroups: [ + barchartGroup(0, barsSpace, barsWidth, 'protein'), + barchartGroup(1, barsSpace, barsWidth, 'carbohydrates'), + barchartGroup(2, barsSpace, barsWidth, 'carbohydratesSugar'), + barchartGroup(3, barsSpace, barsWidth, 'fat'), + barchartGroup(4, barsSpace, barsWidth, 'fatSaturated'), + ], + ), + ); + }, ); } } @@ -476,192 +579,3 @@ class MealDiaryBarChartWidgetState extends State { ); } } - -class FlNutritionalDiaryChartWidget extends StatefulWidget { - final NutritionalPlan _nutritionalPlan; - - const FlNutritionalDiaryChartWidget({ - super.key, - required NutritionalPlan nutritionalPlan, - }) : _nutritionalPlan = nutritionalPlan; - - final Color barColor = Colors.red; - final Color touchedBarColor = Colors.deepOrange; - - @override - State createState() => FlNutritionalDiaryChartWidgetState(); -} - -class FlNutritionalDiaryChartWidgetState extends State { - final Duration animDuration = const Duration(milliseconds: 250); - - int touchedIndex = -1; - - @override - Widget build(BuildContext context) { - return AspectRatio( - aspectRatio: 1.66, - child: BarChart( - mainBarData(), - swapAnimationDuration: animDuration, - ), - ); - } - - List getDatesBetween(DateTime startDate, DateTime endDate) { - final List dateList = []; - DateTime currentDate = startDate; - - while (currentDate.isBefore(endDate) || currentDate.isAtSameMomentAs(endDate)) { - dateList.add(currentDate); - currentDate = currentDate.add(const Duration(days: 1)); - } - - return dateList; - } - - BarChartGroupData makeGroupData( - int x, - double y, { - bool isTouched = false, - Color? barColor, - double width = 1.5, - List showTooltips = const [], - }) { - barColor ??= widget.barColor; - return BarChartGroupData( - x: x, - barRods: [ - BarChartRodData( - toY: isTouched ? y + 1 : y, - color: isTouched ? widget.touchedBarColor : barColor, - width: width, - borderSide: isTouched - ? const BorderSide(color: Colors.black54) - : const BorderSide(color: Colors.white, width: 0), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: 20, - // color: Colors.black, - ), - ), - ], - showingTooltipIndicators: showTooltips, - ); - } - - List showingGroups() { - final logEntries = widget._nutritionalPlan.logEntriesValues; - final List out = []; - final dateList = getDatesBetween(logEntries.keys.first, logEntries.keys.last); - - for (final date in dateList.reversed) { - out.add( - makeGroupData( - date.millisecondsSinceEpoch, - logEntries.containsKey(date) ? logEntries[date]!.energy : 0, - isTouched: date.millisecondsSinceEpoch == touchedIndex, - ), - ); - } - - return out; - } - - Widget leftTitles(double value, TitleMeta meta) { - if (value == meta.max) { - return Container(); - } - const style = TextStyle( - fontSize: 10, - ); - return SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - AppLocalizations.of(context).kcalValue(meta.formattedValue), - style: style, - ), - ); - } - - BarChartData mainBarData() { - return BarChartData( - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - tooltipBgColor: Colors.blueGrey, - tooltipHorizontalAlignment: FLHorizontalAlignment.right, - tooltipMargin: -10, - getTooltipItem: (group, groupIndex, rod, rodIndex) { - final date = DateTime.fromMillisecondsSinceEpoch(group.x); - - return BarTooltipItem( - '${DateFormat.yMMMd(Localizations.localeOf(context).languageCode).format(date)}\n', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: AppLocalizations.of(context).kcalValue((rod.toY - 1).toStringAsFixed(0)), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ], - ); - }, - ), - touchCallback: (FlTouchEvent event, barTouchResponse) { - setState(() { - if (!event.isInterestedForInteractions || - barTouchResponse == null || - barTouchResponse.spot == null) { - touchedIndex = -1; - return; - } - touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex; - }); - }, - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 60, - getTitlesWidget: leftTitles, - ), - ), - ), - borderData: FlBorderData( - show: false, - ), - gridData: FlGridData( - show: true, - getDrawingHorizontalLine: (value) => const FlLine( - color: Colors.grey, - strokeWidth: 1, - ), - drawVerticalLine: false, - ), - barGroups: showingGroups(), - ); - } - - Future refreshState() async { - setState(() {}); - await Future.delayed( - animDuration + const Duration(milliseconds: 50), - ); - } -} diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index eb68e1b06..c87202ac8 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -39,7 +39,7 @@ class MealForm extends StatelessWidget { final _nameController = TextEditingController(); MealForm(this._planId, [meal]) { - _meal = meal ?? Meal(plan: _planId); + _meal = meal ?? Meal(plan: _planId, time: TimeOfDay.fromDateTime(DateTime.now())); _timeController.text = timeToString(_meal.time)!; _nameController.text = _meal.name; } @@ -65,8 +65,9 @@ class MealForm extends StatelessWidget { context: context, initialTime: _meal.time!, ); - - _timeController.text = timeToString(pickedTime)!; + if (pickedTime != null) { + _timeController.text = timeToString(pickedTime)!; + } }, onSaved: (newValue) { _meal.time = stringToTime(newValue); @@ -234,10 +235,13 @@ class IngredientLogForm extends StatelessWidget { final _ingredientIdController = TextEditingController(); final _amountController = TextEditingController(); final _dateController = TextEditingController(); + final _timeController = TextEditingController(); IngredientLogForm(this._plan) { _mealItem = MealItem.empty(); - _dateController.text = toDate(DateTime.now())!; + final now = DateTime.now(); + _dateController.text = toDate(now)!; + _timeController.text = timeToString(TimeOfDay.fromDateTime(now))!; } @override @@ -272,31 +276,64 @@ class IngredientLogForm extends StatelessWidget { return null; }, ), - TextFormField( - readOnly: true, - // Stop keyboard from appearing - decoration: InputDecoration( - labelText: AppLocalizations.of(context).date, - suffixIcon: const Icon(Icons.calendar_today), - ), - enableInteractiveSelection: false, - controller: _dateController, - onTap: () async { - // Show Date Picker Here - final pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(DateTime.now().year - 10), - lastDate: DateTime.now(), - ); - - if (pickedDate != null) { - _dateController.text = toDate(pickedDate)!; - } - }, - onSaved: (newValue) { - _dateController.text = newValue!; - }, + Row( + children: [ + Expanded( + child: TextFormField( + readOnly: true, + // Stop keyboard from appearing + decoration: InputDecoration( + labelText: AppLocalizations.of(context).date, + // suffixIcon: const Icon(Icons.calendar_today), + ), + enableInteractiveSelection: false, + controller: _dateController, + onTap: () async { + // Show Date Picker Here + final pickedDate = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(DateTime.now().year - 10), + lastDate: DateTime.now(), + ); + + if (pickedDate != null) { + _dateController.text = toDate(pickedDate)!; + } + }, + onSaved: (newValue) { + _dateController.text = newValue!; + }, + ), + ), + Expanded( + child: TextFormField( + key: const Key('field-time'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).time, + //suffixIcon: const Icon(Icons.punch_clock) + ), + controller: _timeController, + onTap: () async { + // Stop keyboard from appearing + FocusScope.of(context).requestFocus(FocusNode()); + + // Open time picker + final pickedTime = await showTimePicker( + context: context, + initialTime: stringToTime(_timeController.text), + ); + if (pickedTime != null) { + _timeController.text = timeToString(pickedTime)!; + } + }, + onSaved: (newValue) { + _timeController.text = newValue!; + }, + onFieldSubmitted: (_) {}, + ), + ), + ], ), ElevatedButton( child: Text(AppLocalizations.of(context).save), @@ -308,8 +345,11 @@ class IngredientLogForm extends StatelessWidget { _mealItem.ingredientId = int.parse(_ingredientIdController.text); try { - Provider.of(context, listen: false).logIngredientToDiary( - _mealItem, _plan.id!, DateTime.parse(_dateController.text)); + var date = DateTime.parse(_dateController.text); + final tod = stringToTime(_timeController.text); + date = DateTime(date.year, date.month, date.day, tod.hour, tod.minute); + Provider.of(context, listen: false) + .logIngredientToDiary(_mealItem, _plan.id!, date); } on WgerHttpException catch (error) { showHttpExceptionErrorDialog(error, context); } catch (error) { @@ -427,7 +467,6 @@ class _PlanFormState extends State { Column( children: [ GoalMacros( - widget: widget, val: widget._plan.goalEnergy?.toString(), label: AppLocalizations.of(context).goalEnergy, suffix: AppLocalizations.of(context).kcal, @@ -435,7 +474,6 @@ class _PlanFormState extends State { key: const Key('field-goal-energy'), ), GoalMacros( - widget: widget, val: widget._plan.goalProtein?.toString(), label: AppLocalizations.of(context).goalProtein, suffix: AppLocalizations.of(context).g, @@ -443,7 +481,6 @@ class _PlanFormState extends State { key: const Key('field-goal-protein'), ), GoalMacros( - widget: widget, val: widget._plan.goalCarbohydrates?.toString(), label: AppLocalizations.of(context).goalCarbohydrates, suffix: AppLocalizations.of(context).g, @@ -451,7 +488,6 @@ class _PlanFormState extends State { key: const Key('field-goal-carbohydrates'), ), GoalMacros( - widget: widget, val: widget._plan.goalFat?.toString(), label: AppLocalizations.of(context).goalFat, suffix: AppLocalizations.of(context).g, @@ -513,18 +549,16 @@ class _PlanFormState extends State { class GoalMacros extends StatelessWidget { const GoalMacros({ super.key, - required this.widget, required this.label, required this.suffix, - this.val, required this.onSave, + this.val, }); - final PlanForm widget; final String label; final String suffix; - final String? val; final Function onSave; + final String? val; @override Widget build(BuildContext context) { diff --git a/lib/widgets/nutrition/macro_nutrients_table.dart b/lib/widgets/nutrition/macro_nutrients_table.dart new file mode 100644 index 000000000..13b5b6d2f --- /dev/null +++ b/lib/widgets/nutrition/macro_nutrients_table.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:wger/models/nutrition/nutritional_goals.dart'; + +class MacronutrientsTable extends StatelessWidget { + const MacronutrientsTable({ + super.key, + required this.nutritionalGoals, + required this.plannedValuesPercentage, + required this.nutritionalGoalsGperKg, + }); + + static const double tablePadding = 7; + final NutritionalGoals nutritionalGoals; + final NutritionalGoals plannedValuesPercentage; + final NutritionalGoals? nutritionalGoalsGperKg; + + @override + Widget build(BuildContext context) { + return Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + border: TableBorder( + horizontalInside: BorderSide( + width: 1, + color: Theme.of(context).colorScheme.outline, + ), + ), + columnWidths: const {0: FractionColumnWidth(0.4)}, + children: [ + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text( + AppLocalizations.of(context).macronutrients, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Text( + AppLocalizations.of(context).total, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + AppLocalizations.of(context).percentEnergy, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text( + AppLocalizations.of(context).gPerBodyKg, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).energy), + ), + Text( + nutritionalGoals.energy != null + ? nutritionalGoals.energy!.toStringAsFixed(0) + AppLocalizations.of(context).kcal + : '', + ), + const Text(''), + const Text(''), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).protein), + ), + 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( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).carbohydrates), + ), + 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) + : ''), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding, horizontal: 12), + child: Text(AppLocalizations.of(context).sugars), + ), + const Text(''), + const Text(''), + Text(nutritionalGoals.carbohydratesSugar != null + ? nutritionalGoals.carbohydratesSugar!.toStringAsFixed(0) + + AppLocalizations.of(context).g + : ''), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).fat), + ), + 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( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding, horizontal: 12), + child: Text(AppLocalizations.of(context).saturatedFat), + ), + const Text(''), + const Text(''), + Text(nutritionalGoals.fatSaturated != null + ? nutritionalGoals.fatSaturated!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).fibres), + ), + const Text(''), + const Text(''), + Text(nutritionalGoals.fibres != null + ? nutritionalGoals.fibres!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), + ], + ), + TableRow( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: tablePadding), + child: Text(AppLocalizations.of(context).sodium), + ), + const Text(''), + const Text(''), + Text(nutritionalGoals.sodium != null + ? nutritionalGoals.sodium!.toStringAsFixed(0) + AppLocalizations.of(context).g + : ''), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/nutrition/meal.dart b/lib/widgets/nutrition/meal.dart index 4b3c992fe..0efa2f7b5 100644 --- a/lib/widgets/nutrition/meal.dart +++ b/lib/widgets/nutrition/meal.dart @@ -32,6 +32,12 @@ import 'package:wger/widgets/nutrition/forms.dart'; import 'package:wger/widgets/nutrition/helpers.dart'; import 'package:wger/widgets/nutrition/widgets.dart'; +enum viewMode { + base, // just highlevel meal info (name, time) + withIngredients, // + ingredients + withAllDetails // + nutritional breakdown of ingredients, + logged today +} + class MealWidget extends StatefulWidget { final Meal _meal; final List _listMealItems; @@ -46,7 +52,7 @@ class MealWidget extends StatefulWidget { } class _MealWidgetState extends State { - bool _showDetails = false; + var _viewMode = viewMode.base; bool _editing = false; void _toggleEditing() { @@ -57,7 +63,19 @@ class _MealWidgetState extends State { void _toggleDetails() { setState(() { - _showDetails = !_showDetails; + if (widget._meal.isRealMeal) { + _viewMode = switch (_viewMode) { + viewMode.base => viewMode.withIngredients, + viewMode.withIngredients => viewMode.withAllDetails, + viewMode.withAllDetails => viewMode.base, + }; + } else { + // the "other logs" fake meal doesn't have ingredients to show + _viewMode = switch (_viewMode) { + viewMode.base => viewMode.withAllDetails, + _ => viewMode.base, + }; + } }); } @@ -72,8 +90,8 @@ class _MealWidgetState extends State { MealHeader( editing: _editing, toggleEditing: _toggleEditing, - showingDetails: _showDetails, - toggleDetails: _toggleDetails, + viewMode: _viewMode, + toggleViewMode: _toggleDetails, meal: widget._meal, ), if (_editing) @@ -131,16 +149,21 @@ class _MealWidgetState extends State { icon: const Icon(Icons.delete)), ], )), - const Divider(), - if (_showDetails && widget._meal.isRealMeal) + if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) + const Divider(), + if (_viewMode == viewMode.withAllDetails && widget._meal.isRealMeal) MealItemExpandedRow( AppLocalizations.of(context).energy, AppLocalizations.of(context).protein, AppLocalizations.of(context).carbohydrates, AppLocalizations.of(context).fat, ), - ...widget._meal.mealItems.map((item) => MealItemWidget(item, _showDetails, _editing)), - if (_showDetails) + if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) + if (widget._meal.mealItems.isEmpty && widget._meal.isRealMeal) + const ListTile(title: Text('No ingredients defined yet')) + else + ...widget._meal.mealItems.map((item) => MealItemWidget(item, _viewMode, _editing)), + if (_viewMode == viewMode.withAllDetails) Column( children: [ // if (widget._meal.isRealMeal) @@ -188,10 +211,10 @@ class _MealWidgetState extends State { class MealItemWidget extends StatelessWidget { final bool _editing; - final bool _showingDetails; + final viewMode _viewMode; final MealItem _item; - const MealItemWidget(this._item, this._showingDetails, this._editing); + const MealItemWidget(this._item, this._viewMode, this._editing); @override Widget build(BuildContext context) { @@ -225,7 +248,9 @@ class MealItemWidget extends StatelessWidget { subtitle: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.max, - children: [if (_showingDetails) ...getMutedNutritionalValues(values, context)], + children: [ + if (_viewMode == viewMode.withAllDetails) ...getMutedNutritionalValues(values, context) + ], ), trailing: _editing ? IconButton( @@ -339,24 +364,23 @@ class MealItemExpandedRow extends StatelessWidget { } class MealHeader extends StatelessWidget { + final Meal _meal; final bool _editing; - final bool _showingDetails; + final viewMode _viewMode; final Function _toggleEditing; - final Function _toggleDetails; + final Function _toggleViewMode; const MealHeader({ required Meal meal, required bool editing, + required viewMode viewMode, required Function toggleEditing, - required bool showingDetails, - required Function toggleDetails, - }) : _toggleDetails = toggleDetails, - _toggleEditing = toggleEditing, - _showingDetails = showingDetails, + required Function toggleViewMode, + }) : _meal = meal, _editing = editing, - _meal = meal; - - final Meal _meal; + _viewMode = viewMode, + _toggleViewMode = toggleViewMode, + _toggleEditing = toggleEditing; @override Widget build(BuildContext context) { @@ -383,7 +407,7 @@ class MealHeader extends StatelessWidget { ], ) : Text( - _meal.time!.format(context), + _meal.time != null ? _meal.time!.format(context) : '', style: Theme.of(context).textTheme.headlineSmall, ), ), @@ -402,9 +426,13 @@ class MealHeader extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: _showingDetails ? const Icon(Icons.info) : const Icon(Icons.info_outline), + icon: switch (_viewMode) { + viewMode.base => const Icon(Icons.info_outline), + viewMode.withIngredients => const Icon(Icons.info), + viewMode.withAllDetails => const Icon(Icons.info), + }, onPressed: () { - _toggleDetails(); + _toggleViewMode(); }, tooltip: AppLocalizations.of(context).toggleDetails, ), diff --git a/lib/widgets/nutrition/nutritional_diary_detail.dart b/lib/widgets/nutrition/nutritional_diary_detail.dart index 538829231..f8fdddcfd 100644 --- a/lib/widgets/nutrition/nutritional_diary_detail.dart +++ b/lib/widgets/nutrition/nutritional_diary_detail.dart @@ -31,7 +31,7 @@ class NutritionalDiaryDetailWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final valuesPlanned = _nutritionalPlan.plannedNutritionalValues; + final nutritionalGoals = _nutritionalPlan.nutritionalGoals; final valuesLogged = _nutritionalPlan.getValuesForDate(_date); final logs = _nutritionalPlan.getLogsForDate(_date); @@ -52,7 +52,7 @@ class NutritionalDiaryDetailWidget extends StatelessWidget { Padding( padding: const EdgeInsets.all(8.0), child: NutritionDiaryTable( - planned: valuesPlanned, + planned: nutritionalGoals.toValues(), logged: valuesLogged, ), ), diff --git a/lib/widgets/nutrition/nutritional_diary_table.dart b/lib/widgets/nutrition/nutritional_diary_table.dart new file mode 100644 index 000000000..19f709c0f --- /dev/null +++ b/lib/widgets/nutrition/nutritional_diary_table.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:wger/helpers/colors.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'; +import 'package:wger/screens/nutritional_diary_screen.dart'; + +class NutritionalDiaryTable extends StatelessWidget { + const NutritionalDiaryTable({ + super.key, + required NutritionalPlan nutritionalPlan, + }) : plan = nutritionalPlan; + + final NutritionalPlan plan; + + @override + Widget build(BuildContext context) { + final goals = plan.nutritionalGoals; + + return Table( + columnWidths: const { + 0: FractionColumnWidth(0.14), // Date + 1: FractionColumnWidth(0.19), // E (kcal) + 2: FractionColumnWidth(0.25), // Difference + 3: FractionColumnWidth(0.14), // P (g) + 4: FractionColumnWidth(0.14), // C (g) + 5: FractionColumnWidth(0.14), // F (g) + }, + children: [ + nutrionalDiaryHeader(context, goals), + ...plan.logEntriesValues.entries + .map((entry) => nutritionDiaryEntry(context, goals, entry.key, entry.value)) + .toList() + .reversed, + ], + ); + } + + TableRow nutrionalDiaryHeader(BuildContext context, NutritionalGoals goals) { + return TableRow( + children: [ + Text(style: Theme.of(context).textTheme.titleMedium, 'Date'), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + '${AppLocalizations.of(context).energyShort} (${AppLocalizations.of(context).kcal})'), + if (goals.energy != null) + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + AppLocalizations.of(context).difference), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + '${AppLocalizations.of(context).proteinShort} (${AppLocalizations.of(context).g})'), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + '${AppLocalizations.of(context).carbohydratesShort} (${AppLocalizations.of(context).g})'), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + '${AppLocalizations.of(context).fatShort} (${AppLocalizations.of(context).g})'), + ], + ); + } + + TableRow nutritionDiaryEntry(final BuildContext context, NutritionalGoals goals, DateTime date, + final NutritionalValues values) { + return TableRow( + decoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey[300]!))), + children: [ + Text( + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: LIST_OF_COLORS3.first), + DateFormat.Md(Localizations.localeOf(context).languageCode).format(date), + ), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + values.energy.toStringAsFixed(0)), + if (goals.energy != null) + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + (values.energy - goals.energy!).toStringAsFixed(0)), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + values.protein.toStringAsFixed(0)), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + values.carbohydrates.toStringAsFixed(0)), + Text( + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.end, + values.fat.toStringAsFixed(0)), + ].map((element) { + return GestureDetector( + onTap: () => Navigator.of(context).pushNamed( + NutritionalDiaryScreen.routeName, + arguments: NutritionalDiaryArguments(plan, date), + ), + child: element); + }).toList()); + } +} diff --git a/lib/widgets/nutrition/nutritional_plan_detail.dart b/lib/widgets/nutrition/nutritional_plan_detail.dart index a17500d41..87f7cf138 100644 --- a/lib/widgets/nutrition/nutritional_plan_detail.dart +++ b/lib/widgets/nutrition/nutritional_plan_detail.dart @@ -18,18 +18,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:wger/helpers/colors.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; -import 'package:wger/models/nutrition/nutritional_values.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/screens/form_screen.dart'; -import 'package:wger/screens/nutritional_diary_screen.dart'; import 'package:wger/widgets/measurements/charts.dart'; import 'package:wger/widgets/nutrition/charts.dart'; import 'package:wger/widgets/nutrition/forms.dart'; +import 'package:wger/widgets/nutrition/macro_nutrients_table.dart'; import 'package:wger/widgets/nutrition/meal.dart'; +import 'package:wger/widgets/nutrition/nutritional_diary_table.dart'; class NutritionalPlanDetailWidget extends StatelessWidget { final NutritionalPlan _nutritionalPlan; @@ -38,334 +37,126 @@ class NutritionalPlanDetailWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final plannedNutritionalValues = _nutritionalPlan.plannedNutritionalValues; + final nutritionalGoals = _nutritionalPlan.nutritionalGoals; final lastWeightEntry = Provider.of(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( - [ - const SizedBox(height: 10), - ..._nutritionalPlan.meals.map((meal) => MealWidget( - meal, - _nutritionalPlan.allMealItems, - )), - MealWidget( - _nutritionalPlan.pseudoMealOthers('Other logs'), - _nutritionalPlan.allMealItems, + delegate: SliverChildListDelegate( + [ + SizedBox( + width: 300, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FlNutritionalPlanGoalWidget( + nutritionalPlan: _nutritionalPlan, + ), ), - if (!_nutritionalPlan.onlyLogging) - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - child: Text(AppLocalizations.of(context).addMeal), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).addMeal, - MealForm(_nutritionalPlan.id!), - ), - ); - }, - ), + ), + const SizedBox(height: 10), + ..._nutritionalPlan.meals.map((meal) => MealWidget( + meal, + _nutritionalPlan.allMealItems, + )), + MealWidget( + _nutritionalPlan.pseudoMealOthers('Other logs'), + _nutritionalPlan.allMealItems, + ), + if (!_nutritionalPlan.onlyLogging) + Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + child: Text(AppLocalizations.of(context).addMeal), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).addMeal, + MealForm(_nutritionalPlan.id!), + ), + ); + }, ), + ), + if (nutritionalGoals.isComplete()) Container( padding: const EdgeInsets.all(15), height: 220, - child: FlNutritionalPlanPieChartWidget(plannedNutritionalValues), // chart - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: MacronutrientsTable( - plannedNutritionalValues: plannedNutritionalValues, - plannedValuesPercentage: _nutritionalPlan.energyPercentage(plannedNutritionalValues), - plannedValuesGperKg: valuesGperKg, - ), + child: FlNutritionalPlanPieChartWidget(nutritionalGoals.toValues()), ), - const Padding(padding: EdgeInsets.all(8.0)), - Text( - AppLocalizations.of(context).logged, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: MacronutrientsTable( + nutritionalGoals: nutritionalGoals, + plannedValuesPercentage: nutritionalGoals.energyPercentage(), + nutritionalGoalsGperKg: nutritionalGoalsGperKg, ), - Container( - padding: const EdgeInsets.only(top: 15, left: 15, right: 15), - height: 300, - child: NutritionalDiaryChartWidgetFl(nutritionalPlan: _nutritionalPlan), // chart + ), + const Padding(padding: EdgeInsets.all(8.0)), + Text( + AppLocalizations.of(context).logged, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + Container( + padding: const EdgeInsets.only(top: 15, left: 15, right: 15), + height: 300, + child: NutritionalDiaryChartWidgetFl(nutritionalPlan: _nutritionalPlan), + ), + Padding( + padding: const EdgeInsets.only(bottom: 40, left: 25, right: 25), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Indicator( + color: LIST_OF_COLORS3[0], + text: AppLocalizations.of(context).deficit, + isSquare: true, + marginRight: 0, + ), + Indicator( + color: COLOR_SURPLUS, + text: AppLocalizations.of(context).surplus, + isSquare: true, + marginRight: 0, + ), + Indicator( + color: LIST_OF_COLORS3[1], + text: AppLocalizations.of(context).today, + isSquare: true, + marginRight: 0, + ), + Indicator( + color: LIST_OF_COLORS3[2], + text: AppLocalizations.of(context).weekAverage, + isSquare: true, + marginRight: 0, + ), + ], ), + ), + if (_nutritionalPlan.logEntriesValues.isNotEmpty) Padding( - padding: const EdgeInsets.only(bottom: 40, left: 25, right: 25), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Indicator( - color: LIST_OF_COLORS3[0], - text: 'deficit', - isSquare: true, - marginRight: 0, - ), - const Indicator( - color: Colors.red, - text: 'surplus', - isSquare: true, - marginRight: 0, - ), - Indicator( - color: LIST_OF_COLORS3[1], - text: AppLocalizations.of(context).today, - isSquare: true, - marginRight: 0, - ), - Indicator( - color: LIST_OF_COLORS3[2], - text: AppLocalizations.of(context).weekAverage, - isSquare: true, - marginRight: 0, - ), - ], - ), - ), - if (_nutritionalPlan.logEntriesValues.isNotEmpty) - Column( + padding: const EdgeInsets.only(bottom: 15, left: 15, right: 15), + child: Column( children: [ Text( AppLocalizations.of(context).nutritionalDiary, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, - ), - Container( - padding: const EdgeInsets.all(15), - height: 220, - child: FlNutritionalDiaryChartWidget(nutritionalPlan: _nutritionalPlan), // chart + style: Theme.of(context).textTheme.titleLarge, ), SizedBox( - height: 200, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextButton(onPressed: () {}, child: const Text('')), - Text( - '${AppLocalizations.of(context).energyShort} (${AppLocalizations.of(context).kcal})'), - Text( - '${AppLocalizations.of(context).proteinShort} (${AppLocalizations.of(context).g})'), - Text( - '${AppLocalizations.of(context).carbohydratesShort} (${AppLocalizations.of(context).g})'), - Text( - '${AppLocalizations.of(context).fatShort} (${AppLocalizations.of(context).g})'), - ], - ), - ), - ..._nutritionalPlan.logEntriesValues.entries - .map((entry) => - NutritionDiaryEntry(entry.key, entry.value, _nutritionalPlan)) - .toList() - .reversed, - ], - ), - ) + height: 200, + child: SingleChildScrollView( + child: NutritionalDiaryTable(nutritionalPlan: _nutritionalPlan), + )), ], ), - ], - ), - ); - } -} - -class MacronutrientsTable extends StatelessWidget { - const MacronutrientsTable({ - super.key, - required this.plannedNutritionalValues, - required this.plannedValuesPercentage, - required this.plannedValuesGperKg, - }); - - static const double tablePadding = 7; - final NutritionalValues plannedNutritionalValues; - final BaseNutritionalValues plannedValuesPercentage; - final BaseNutritionalValues? plannedValuesGperKg; - - @override - Widget build(BuildContext context) { - return Table( - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - border: TableBorder( - horizontalInside: BorderSide( - width: 1, - color: Theme.of(context).colorScheme.outline, - ), - ), - columnWidths: const {0: FractionColumnWidth(0.4)}, - children: [ - TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: tablePadding), - child: Text( - AppLocalizations.of(context).macronutrients, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Text( - AppLocalizations.of(context).total, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - AppLocalizations.of(context).percentEnergy, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - AppLocalizations.of(context).gPerBodyKg, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: tablePadding), - child: Text(AppLocalizations.of(context).energy), - ), - Text( - plannedNutritionalValues.energy.toStringAsFixed(0) + - AppLocalizations.of(context).kcal, - ), - const Text(''), - const Text(''), - ], - ), - TableRow( - children: [ - Padding( - 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) : ''), - ], - ), - TableRow( - children: [ - Padding( - 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) - : ''), - ], - ), - TableRow( - children: [ - Padding( - 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(''), - ], - ), - TableRow( - children: [ - Padding( - 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) : ''), - ], - ), - TableRow( - children: [ - Padding( - 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(''), - ], - ), - TableRow( - children: [ - Padding( - 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(''), - ], - ), - TableRow( - children: [ - Padding( - 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(''), - ], - ), + ), ], - ); - } -} - -class NutritionDiaryEntry extends StatelessWidget { - final DateTime date; - final NutritionalValues values; - final NutritionalPlan plan; - - const NutritionDiaryEntry( - this.date, - this.values, - this.plan, - ); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pushNamed( - NutritionalDiaryScreen.routeName, - arguments: NutritionalDiaryArguments(plan, date), - ), - child: Text( - DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date), - )), - Text(values.energy.toStringAsFixed(0)), - Text(values.protein.toStringAsFixed(0)), - Text(values.carbohydrates.toStringAsFixed(0)), - Text(values.fat.toStringAsFixed(0)), - ], - ), - ); + )); } } diff --git a/pubspec.lock b/pubspec.lock index 4ca9db0dd..c0fa4cd11 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -607,6 +607,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + golden_toolkit: + dependency: "direct dev" + description: + name: golden_toolkit + sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0" + url: "https://pub.dev" + source: hosted + version: "0.15.0" graphs: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2eee6f912..21a707d8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dev_dependencies: cider: ^0.2.7 drift_dev: ^2.17.0 freezed: ^2.5.2 + golden_toolkit: ^0.15.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/nutrition/goldens/nutritional_plan_1_default_view.png b/test/nutrition/goldens/nutritional_plan_1_default_view.png new file mode 100644 index 000000000..4f9eaee0e Binary files /dev/null and b/test/nutrition/goldens/nutritional_plan_1_default_view.png differ diff --git a/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png b/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png new file mode 100644 index 000000000..347196661 Binary files /dev/null and b/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png differ diff --git a/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png b/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png new file mode 100644 index 000000000..f6f2cffa0 Binary files /dev/null and b/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png differ diff --git a/test/nutrition/nutritional_plan_model_test.dart b/test/nutrition/nutritional_plan_model_test.dart index a832d006b..096e837e4 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'; @@ -30,9 +31,67 @@ 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); + test('Test NutritionalPlan.nutritionalGoals based on meals', () { + expect( + plan.nutritionalGoals, + NutritionalGoals( + energy: 4118.75, + protein: 32.75, + carbohydrates: 347.5, + carbohydratesSugar: 9.5, + fat: 59.0, + fatSaturated: 37.75, + fibres: 52.5, + sodium: 30.5)); + }); + test('Test NutritionalPlan.nutritionalValues based on 3 macros and energy', () { + expect( + NutritionalPlan( + description: '3 macros and energy defined', + creationDate: DateTime(2024, 5, 4), + goalProtein: 150, + goalCarbohydrates: 100, + goalFat: 100, + goalEnergy: 1500, + ).nutritionalGoals, + NutritionalGoals( + energy: 1500, + protein: 150, + carbohydrates: 100, + fat: 100, + )); + }); + test('Test NutritionalPlan.nutritionalValues based on 2 macros and energy', () { + expect( + NutritionalPlan( + description: '2 macros and energy defined', + creationDate: DateTime(2024, 5, 4), + goalProtein: 100, + goalCarbohydrates: 100, + goalEnergy: 1700, + ).nutritionalGoals, + NutritionalGoals( + energy: 1700, + protein: 100, + carbohydrates: 100, + fat: 100, // inferred + )); + }); + test('Test NutritionalPlan.nutritionalValues based on 3 macros only', () { + expect( + NutritionalPlan( + description: '3 macros defined', + creationDate: DateTime(2024, 5, 4), + goalProtein: 100, + goalCarbohydrates: 100, + goalFat: 10, + ).nutritionalGoals, + NutritionalGoals( + energy: 890, // inferred + protein: 100, + carbohydrates: 100, + fat: 10, + )); }); test('Test the nutritionalValues method for meals', () { diff --git a/test/nutrition/nutritional_plan_screen_test.dart b/test/nutrition/nutritional_plan_screen_test.dart index cea4afb8c..26ab314e6 100644 --- a/test/nutrition/nutritional_plan_screen_test.dart +++ b/test/nutrition/nutritional_plan_screen_test.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:http/http.dart' as http; import 'package:mockito/annotations.dart'; import 'package:provider/provider.dart'; @@ -27,8 +28,6 @@ import 'package:wger/providers/base_provider.dart'; import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/nutrition.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; -import 'package:wger/widgets/nutrition/charts.dart'; - import '../../test_data/nutritional_plans.dart'; import 'nutritional_plan_screen_test.mocks.dart'; @@ -67,22 +66,68 @@ void main() { ); } - testWidgets('Test the widgets on the nutritional plan screen', (WidgetTester tester) async { - await tester.pumpWidget(createNutritionalPlan()); - await tester.tap(find.byType(TextButton)); - await tester.pumpAndSettle(); + testGoldens( + 'Test the widgets on the nutritional plan screen', + (tester) async { + await loadAppFonts(); + final globalKey = GlobalKey(); + await tester.pumpWidgetBuilder( + Material( + key: globalKey, + ), + wrapper: materialAppWrapper( + localizations: [ + AppLocalizations.delegate, + ], + ), + surfaceSize: const Size(500, 1000), + ); + await tester.pumpWidget(createNutritionalPlan()); + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); - // PLan description - expect(find.text('Less fat, more protein'), findsOneWidget); + await screenMatchesGolden(tester, 'nutritional_plan_1_default_view'); - // Ingredients - expect(find.text('100g Water'), findsOneWidget); - expect(find.text('75g Burger soup'), findsOneWidget); - expect(find.text('300g Broccoli cake'), findsOneWidget); + // Default view shows plan description, info button, and no ingredients + expect(find.text('Less fat, more protein'), findsOneWidget); + expect(find.byIcon(Icons.info_outline), findsNWidgets(3)); // 2 meals, 1 "other logs" + expect(find.byIcon(Icons.info), findsNothing); + expect(find.text('100g Water'), findsNothing); + expect(find.text('75g Burger soup'), findsNothing); - expect(find.byType(Card), findsNWidgets(2)); - expect(find.byType(FlNutritionalDiaryChartWidget), findsNothing); - }); + // tap the first info button changes it and reveals ingredients for the first meal + var infoOutlineButtons = find.byIcon(Icons.info_outline); + await tester.tap(infoOutlineButtons.first); // 2nd button shows up also, but is off-screen + await tester.pumpAndSettle(); + await screenMatchesGolden(tester, 'nutritional_plan_2_one_meal_with_ingredients'); + + // Ingredients show up now + expect(find.text('100g Water'), findsOneWidget); + expect(find.text('75g Burger soup'), findsOneWidget); + + // .. and the button icon has changed + expect(find.byIcon(Icons.info_outline), findsNWidgets(2)); + expect(find.byIcon(Icons.info), findsOneWidget); + + // the goals widget pushes this content down a bit. + // let's first find our icon (note: the previous icon no longer matches) + infoOutlineButtons = find.byIcon(Icons.info_outline); + + await tester.scrollUntilVisible(infoOutlineButtons.first, 30); + expect(find.text('300g Broccoli cake'), findsNothing); + + await tester.tap(infoOutlineButtons.first); + await tester.pumpAndSettle(); + await screenMatchesGolden(tester, 'nutritional_plan_3_both_meals_with_ingredients'); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + expect(find.byIcon(Icons.info), findsNWidgets(2)); + + await tester.scrollUntilVisible(find.text('300g Broccoli cake'), 30); + expect(find.text('300g Broccoli cake'), findsOneWidget); + + expect(find.byType(Card), findsNWidgets(3)); + }, + ); testWidgets('Tests the localization of times - EN', (WidgetTester tester) async { await tester.pumpWidget(createNutritionalPlan()); diff --git a/test_data/dart_test.yaml b/test_data/dart_test.yaml new file mode 100644 index 000000000..62bd27462 --- /dev/null +++ b/test_data/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + golden: \ No newline at end of file