diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6e3e9cb2..46844990 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" diff --git a/lib/screens/log_meal_screen.dart b/lib/screens/log_meal_screen.dart index cfae7e50..48134f62 100644 --- a/lib/screens/log_meal_screen.dart +++ b/lib/screens/log_meal_screen.dart @@ -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 { return Scaffold( appBar: AppBar( - title: Text('Log meal to diary'), + title: Text(AppLocalizations.of(context).logMeal), ), body: Consumer( builder: (context, nutritionProvider, child) => SingleChildScrollView( @@ -64,13 +64,13 @@ class _LogMealScreenState extends State { 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()} %', diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 66eae3ee..9ca29196 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import 'dart:ui'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/core/core.dart b/lib/widgets/core/core.dart index 6430f5b6..cf4ee9ef 100644 --- a/lib/widgets/core/core.dart +++ b/lib/widgets/core/core.dart @@ -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, ); } } diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 4bafbe2d..806c9d4f 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -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 { builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { _mealItem.ingredient = snapshot.data!; - return MealItemTile( + return MealItemValuesTile( ingredient: _mealItem.ingredient, nutritionalValues: _mealItem.nutritionalValues, ); diff --git a/lib/widgets/nutrition/helpers.dart b/lib/widgets/nutrition/helpers.dart index 24ae27ec..39ac478c 100644 --- a/lib/widgets/nutrition/helpers.dart +++ b/lib/widgets/nutrition/helpers.dart @@ -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 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 getNutritionColumnNames(BuildContext context) => [ + AppLocalizations.of(context).energy, + AppLocalizations.of(context).protein, + AppLocalizations.of(context).carbohydrates, + AppLocalizations.of(context).fat, ]; +List 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 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 muted(List 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 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))}'; diff --git a/lib/widgets/nutrition/meal.dart b/lib/widgets/nutrition/meal.dart index 66f104be..98c467b2 100644 --- a/lib/widgets/nutrition/meal.dart +++ b/lib/widgets/nutrition/meal.dart @@ -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 { )), 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 { padding: const EdgeInsets.all(8.0), child: Column( children: [ - NutritionDiaryEntry(diaryEntry: item), + DiaryEntryTile(diaryEntry: item), ], ), )), @@ -194,12 +213,13 @@ class _MealWidgetState extends State { } } -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; diff --git a/lib/widgets/nutrition/nutrition_tile.dart b/lib/widgets/nutrition/nutrition_tile.dart new file mode 100644 index 00000000..4e1cc5b4 --- /dev/null +++ b/lib/widgets/nutrition/nutrition_tile.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nutrition/nutrition_tiles.dart b/lib/widgets/nutrition/nutrition_tiles.dart new file mode 100644 index 00000000..8bd1bcb1 --- /dev/null +++ b/lib/widgets/nutrition/nutrition_tiles.dart @@ -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(context, listen: false) + .deleteLog(diaryEntry.id!, nutritionalPlan!.id!); + }, + icon: const Icon(Icons.delete_outline), + iconSize: ICON_SIZE_SMALL, + ), + ); + } +} diff --git a/lib/widgets/nutrition/nutritional_diary_detail.dart b/lib/widgets/nutrition/nutritional_diary_detail.dart index b1717def..b644fae1 100644 --- a/lib/widgets/nutrition/nutritional_diary_detail.dart +++ b/lib/widgets/nutrition/nutritional_diary_detail.dart @@ -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), + ), ], ); } diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 460c15a7..695d202e 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -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 { 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 { } } -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(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()), - ); - } -} 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 index 537431b6..05e88d1f 100644 Binary files a/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png 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 index 7b709001..75b72dcf 100644 Binary files a/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png and b/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png differ