Merge pull request #604 from wger-project/show-kcal-macros-total-for-meal

kcal/macros total for meals + fix alignments
This commit is contained in:
Dieter Plaetinck
2024-06-16 12:24:36 +03:00
committed by GitHub
13 changed files with 238 additions and 195 deletions

View File

@@ -122,6 +122,7 @@
"@searchExercise": {
"description": "Label on set form. Selected exercises are added to the set"
},
"noIngredientsDefined": "No ingredients defined yet",
"noMatchingExerciseFound": "No matching exercises found",
"@noMatchingExerciseFound": {
"description": "Message returned if no exercises match the searched string"

View File

@@ -22,7 +22,7 @@ import 'package:provider/provider.dart';
import 'package:wger/models/nutrition/meal.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/nutrition/meal.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
class LogMealArguments {
final Meal meal;
@@ -51,7 +51,7 @@ class _LogMealScreenState extends State<LogMealScreen> {
return Scaffold(
appBar: AppBar(
title: Text('Log meal to diary'),
title: Text(AppLocalizations.of(context).logMeal),
),
body: Consumer<NutritionPlansProvider>(
builder: (context, nutritionProvider, child) => SingleChildScrollView(
@@ -64,13 +64,13 @@ class _LogMealScreenState extends State<LogMealScreen> {
children: [
Text(meal.name, style: Theme.of(context).textTheme.headlineSmall),
if (meal.mealItems.isEmpty)
const ListTile(title: Text('No ingredients defined yet'))
ListTile(title: Text(AppLocalizations.of(context).noIngredientsDefined))
else
Column(
children: [
const NutritionDiaryheader(),
...meal.mealItems
.map((item) => MealItemWidget(item, viewMode.withAllDetails, false)),
const DiaryheaderTile(),
...meal.mealItems.map(
(item) => MealItemEditableFullTile(item, viewMode.withAllDetails, false)),
const SizedBox(height: 32),
Text(
'Portion: ${portionPct.round()} %',

View File

@@ -16,7 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:ui';
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';

View File

@@ -21,10 +21,12 @@ import 'package:flutter/material.dart';
class MutedText extends StatelessWidget {
final String _text;
final TextAlign textAlign;
final TextOverflow? overflow;
const MutedText(
this._text, {
this.textAlign = TextAlign.left,
this.overflow,
});
@override
@@ -33,6 +35,7 @@ class MutedText extends StatelessWidget {
_text,
style: TextStyle(color: Theme.of(context).colorScheme.outline),
textAlign: textAlign,
overflow: overflow,
);
}
}

View File

@@ -31,6 +31,7 @@ import 'package:wger/models/nutrition/nutritional_plan.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/screens/nutritional_plan_screen.dart';
import 'package:wger/widgets/nutrition/helpers.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
class MealForm extends StatelessWidget {
@@ -341,7 +342,7 @@ class IngredientFormState extends State<IngredientForm> {
builder: (BuildContext context, AsyncSnapshot<Ingredient> snapshot) {
if (snapshot.hasData) {
_mealItem.ingredient = snapshot.data!;
return MealItemTile(
return MealItemValuesTile(
ingredient: _mealItem.ingredient,
nutritionalValues: _mealItem.nutritionalValues,
);

View File

@@ -23,25 +23,55 @@ import 'package:wger/models/nutrition/meal.dart';
import 'package:wger/models/nutrition/nutritional_values.dart';
import 'package:wger/widgets/core/core.dart';
List<Widget> getMutedNutritionalValues(NutritionalValues values, BuildContext context) => [
MutedText(
AppLocalizations.of(context).kcalValue(values.energy.toStringAsFixed(0)),
textAlign: TextAlign.right,
),
MutedText(
AppLocalizations.of(context).gValue(values.protein.toStringAsFixed(0)),
textAlign: TextAlign.right,
),
MutedText(
AppLocalizations.of(context).gValue(values.carbohydrates.toStringAsFixed(0)),
textAlign: TextAlign.right,
),
MutedText(
AppLocalizations.of(context).gValue(values.fat.toStringAsFixed(0)),
textAlign: TextAlign.right,
),
List<String> getNutritionColumnNames(BuildContext context) => [
AppLocalizations.of(context).energy,
AppLocalizations.of(context).protein,
AppLocalizations.of(context).carbohydrates,
AppLocalizations.of(context).fat,
];
List<String> getNutritionalValues(NutritionalValues values, BuildContext context) => [
AppLocalizations.of(context).kcalValue(values.energy.toStringAsFixed(0)),
AppLocalizations.of(context).gValue(values.protein.toStringAsFixed(0)),
AppLocalizations.of(context).gValue(values.carbohydrates.toStringAsFixed(0)),
AppLocalizations.of(context).gValue(values.fat.toStringAsFixed(0)),
];
List<int> getNutritionColumnFlexes(BuildContext context) {
return getNutritionColumnNames(context).map((e) {
final l = e.characters.length;
// if the word is really small (e.g. "fat"),
// we still want to have a minimum value to keep some spacing,
// especially because column values might become like "123 g"
return (l <= 3) ? 4 : l;
}).toList();
}
List<Widget> muted(List<String> children) => children
.map((e) => MutedText(
e,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
))
.toList();
// return a row of elements in the standard macros spacing
Row getNutritionRow(BuildContext context, List<Widget> children) {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: children.indexed
.map(
(e) => Flexible(
fit: FlexFit.tight,
flex: getNutritionColumnFlexes(context)[e.$1],
child: e.$2,
),
)
.toList(),
);
}
String getShortNutritionValues(NutritionalValues values, BuildContext context) {
final loc = AppLocalizations.of(context);
final e = '${loc.energyShort} ${loc.kcalValue(values.energy.toStringAsFixed(0))}';

View File

@@ -21,7 +21,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_svg_icons/flutter_svg_icons.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.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/providers/nutrition.dart';
@@ -30,6 +29,8 @@ import 'package:wger/screens/log_meal_screen.dart';
import 'package:wger/widgets/nutrition/charts.dart';
import 'package:wger/widgets/nutrition/forms.dart';
import 'package:wger/widgets/nutrition/helpers.dart';
import 'package:wger/widgets/nutrition/nutrition_tile.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
enum viewMode {
@@ -157,15 +158,33 @@ class _MealWidgetState extends State<MealWidget> {
)),
if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails)
const Divider(),
if (_viewMode == viewMode.withAllDetails) const NutritionDiaryheader(),
if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails)
const DiaryheaderTile(),
if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails)
if (widget._meal.mealItems.isEmpty && widget._meal.isRealMeal)
const ListTile(title: Text('No ingredients defined yet'))
NutritionTile(
title: Text(
AppLocalizations.of(context).noIngredientsDefined,
textAlign: TextAlign.left,
))
else
...widget._meal.mealItems.map((item) => MealItemWidget(item, _viewMode, _editing)),
...widget._meal.mealItems
.map((item) => MealItemEditableFullTile(item, _viewMode, _editing)),
if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails)
const Divider(),
if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails)
NutritionTile(
vPadding: 0,
leading: const Text('total'),
title: getNutritionRow(
context,
muted(getNutritionalValues(widget._meal.plannedNutritionalValues, context)),
),
),
if (_viewMode == viewMode.withAllDetails)
Column(
children: [
const Divider(),
Center(
child: Text(
AppLocalizations.of(context).loggedToday,
@@ -181,7 +200,7 @@ class _MealWidgetState extends State<MealWidget> {
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
NutritionDiaryEntry(diaryEntry: item),
DiaryEntryTile(diaryEntry: item),
],
),
)),
@@ -194,12 +213,13 @@ class _MealWidgetState extends State<MealWidget> {
}
}
class MealItemWidget extends StatelessWidget {
/// An editable NutritionTile showing the avatar, name, nutritional values
class MealItemEditableFullTile extends StatelessWidget {
final bool _editing;
final viewMode _viewMode;
final MealItem _item;
const MealItemWidget(this._item, this._viewMode, this._editing);
const MealItemEditableFullTile(this._item, this._viewMode, this._editing);
@override
Widget build(BuildContext context) {
@@ -213,22 +233,24 @@ class MealItemWidget extends StatelessWidget {
final String unit = AppLocalizations.of(context).g;
final values = _item.nutritionalValues;
return ListTile(
return NutritionTile(
leading: IngredientAvatar(ingredient: _item.ingredient),
title: Text(
'${_item.amount.toStringAsFixed(0)}$unit ${_item.ingredient.name}',
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
title: Row(
mainAxisSize: MainAxisSize.max,
children: [
if (_viewMode == viewMode.withAllDetails) ...getMutedNutritionalValues(values, context)
Text(
'${_item.amount.toStringAsFixed(0)}$unit ${_item.ingredient.name}',
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
),
],
),
subtitle: (_viewMode != viewMode.withAllDetails && !_editing)
? null
: getNutritionRow(context, muted(getNutritionalValues(values, context))),
trailing: _editing
? IconButton(
icon: const Icon(Icons.delete),
icon: const Icon(Icons.delete, size: ICON_SIZE_SMALL),
tooltip: AppLocalizations.of(context).delete,
iconSize: ICON_SIZE_SMALL,
onPressed: () {
@@ -250,38 +272,6 @@ class MealItemWidget extends StatelessWidget {
}
}
class LogDiaryItemWidget extends StatelessWidget {
final Log _item;
const LogDiaryItemWidget(this._item);
@override
Widget build(BuildContext context) {
// TODO(x): add real support for weight units
/*
String unit = _item.weightUnitId == null
? AppLocalizations.of(context).g
: _item.weightUnitObj!.weightUnit.name;
*/
final String unit = AppLocalizations.of(context).g;
final values = _item.nutritionalValues;
return ListTile(
leading: IngredientAvatar(ingredient: _item.ingredient),
title: Text(
'${_item.amount.toStringAsFixed(0)}$unit ${_item.ingredient.name}',
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [...getMutedNutritionalValues(values, context)],
),
);
}
}
class MealHeader extends StatelessWidget {
final Meal _meal;
final bool _editing;

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
/// NutritionTile is similar to a non-interactive ListTile,
/// but uses a fixed, easy to understand layout.
class NutritionTile extends StatelessWidget {
final Widget? leading; // always constrained to 40px wide
final double vPadding;
final Widget? title;
final Widget? subtitle;
final Widget? trailing; // always constrained to 20px wide
const NutritionTile({
this.leading,
this.title,
this.subtitle,
this.trailing,
this.vPadding = 8,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: vPadding),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 40, maxWidth: 40),
child: leading ?? const SizedBox(width: 40),
),
const SizedBox(width: 8),
Expanded(
child: Column(
children: [
if (title != null) title!,
if (subtitle != null) subtitle!,
],
),
),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 20, maxWidth: 20),
child: trailing ?? const SizedBox(width: 20),
),
],
),
);
}
}

View File

@@ -0,0 +1,85 @@
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/consts.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/models/nutrition/log.dart';
import 'package:wger/models/nutrition/nutritional_plan.dart';
import 'package:wger/models/nutrition/nutritional_values.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/nutrition/helpers.dart';
import 'package:wger/widgets/nutrition/nutrition_tile.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
/// a NutritionTitle showing an ingredient, with its
/// avatar and nutritional values
class MealItemValuesTile extends StatelessWidget {
final Ingredient ingredient;
final NutritionalValues nutritionalValues;
const MealItemValuesTile({
super.key,
required this.ingredient,
required this.nutritionalValues,
});
@override
Widget build(BuildContext context) {
return NutritionTile(
leading: IngredientAvatar(ingredient: ingredient),
title: Text(getShortNutritionValues(nutritionalValues, context)),
);
}
}
/// a NutritionTitle showing the header for the diary
class DiaryheaderTile extends StatelessWidget {
final Widget? leading;
const DiaryheaderTile({this.leading});
@override
Widget build(BuildContext context) {
return NutritionTile(title: getNutritionRow(context, muted(getNutritionColumnNames(context))));
}
}
/// a NutritionTitle showing diary entries
class DiaryEntryTile extends StatelessWidget {
const DiaryEntryTile({
super.key,
required this.diaryEntry,
this.nutritionalPlan,
});
final Log diaryEntry;
final NutritionalPlan? nutritionalPlan;
@override
Widget build(BuildContext context) {
return NutritionTile(
leading: Text(
DateFormat.Hm(Localizations.localeOf(context).languageCode).format(diaryEntry.datetime),
style: const TextStyle(fontWeight: FontWeight.bold),
),
title: Text(
'${AppLocalizations.of(context).gValue(diaryEntry.amount.toStringAsFixed(0))} ${diaryEntry.ingredient.name}',
overflow: TextOverflow.ellipsis,
),
subtitle: getNutritionRow(
context, muted(getNutritionalValues(diaryEntry.nutritionalValues, context))),
trailing: (nutritionalPlan == null)
? null
: IconButton(
tooltip: AppLocalizations.of(context).delete,
onPressed: () {
Provider.of<NutritionPlansProvider>(context, listen: false)
.deleteLog(diaryEntry.id!, nutritionalPlan!.id!);
},
icon: const Icon(Icons.delete_outline),
iconSize: ICON_SIZE_SMALL,
),
);
}
}

View File

@@ -21,7 +21,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:wger/models/nutrition/nutritional_plan.dart';
import 'package:wger/models/nutrition/nutritional_values.dart';
import 'package:wger/widgets/nutrition/charts.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
class NutritionalDiaryDetailWidget extends StatelessWidget {
final NutritionalPlan _nutritionalPlan;
@@ -60,15 +60,10 @@ class NutritionalDiaryDetailWidget extends StatelessWidget {
),
),
const SizedBox(height: 15),
const NutritionDiaryheader(),
...logs.map((e) => Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
NutritionDiaryEntry(diaryEntry: e, nutritionalPlan: _nutritionalPlan),
],
),
)),
const DiaryheaderTile(),
...logs.map(
(e) => DiaryEntryTile(diaryEntry: e, nutritionalPlan: _nutritionalPlan),
),
],
);
}

View File

@@ -22,7 +22,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:flutter_zxing/flutter_zxing.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/misc.dart';
@@ -30,12 +29,9 @@ import 'package:wger/helpers/platform.dart';
import 'package:wger/helpers/ui.dart';
import 'package:wger/models/exercises/ingredient_api.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/models/nutrition/log.dart';
import 'package:wger/models/nutrition/nutritional_plan.dart';
import 'package:wger/models/nutrition/nutritional_values.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/core/core.dart';
import 'package:wger/widgets/nutrition/helpers.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
class ScanReader extends StatelessWidget {
@override
@@ -214,7 +210,7 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context).productFoundDescription(result.name)),
MealItemTile(
MealItemValuesTile(
ingredient: result,
nutritionalValues: result.nutritionalValues,
),
@@ -272,92 +268,6 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
}
}
class NutritionDiaryheader extends StatelessWidget {
final Widget? leading;
const NutritionDiaryheader({this.leading});
@override
Widget build(BuildContext context) {
return ListTile(
leading: leading ??
const CircleIconAvatar(
Icon(Icons.image, color: Colors.transparent),
color: Colors.transparent,
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
AppLocalizations.of(context).energy,
AppLocalizations.of(context).protein,
AppLocalizations.of(context).carbohydrates,
AppLocalizations.of(context).fat
]
.map((e) => MutedText(
e,
textAlign: TextAlign.right,
))
.toList(),
),
);
}
}
class NutritionDiaryEntry extends StatelessWidget {
const NutritionDiaryEntry({
super.key,
required this.diaryEntry,
this.nutritionalPlan,
});
final Log diaryEntry;
final NutritionalPlan? nutritionalPlan;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
DateFormat.Hm(Localizations.localeOf(context).languageCode).format(diaryEntry.datetime),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${AppLocalizations.of(context).gValue(diaryEntry.amount.toStringAsFixed(0))} ${diaryEntry.ingredient.name}',
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
...getMutedNutritionalValues(diaryEntry.nutritionalValues, context),
],
),
const SizedBox(height: 12),
],
),
),
if (nutritionalPlan != null)
IconButton(
tooltip: AppLocalizations.of(context).delete,
onPressed: () {
Provider.of<NutritionPlansProvider>(context, listen: false)
.deleteLog(diaryEntry.id!, nutritionalPlan!.id!);
},
icon: const Icon(Icons.delete_outline)),
],
);
}
}
class IngredientAvatar extends StatelessWidget {
final Ingredient ingredient;
@@ -377,23 +287,3 @@ class IngredientAvatar extends StatelessWidget {
: const CircleIconAvatar(Icon(Icons.image, color: Colors.grey));
}
}
class MealItemTile extends StatelessWidget {
final Ingredient ingredient;
final NutritionalValues nutritionalValues;
const MealItemTile({
super.key,
required this.ingredient,
required this.nutritionalValues,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: IngredientAvatar(ingredient: ingredient),
title: Text(getShortNutritionValues(nutritionalValues, context)),
// subtitle: Text(ingredient.id.toString()),
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 69 KiB