From fb7be3c8a3d29cade284296f8873525dac57d4fe Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 10 Jun 2024 14:13:02 +0300 Subject: [PATCH] overhaul macros viewing * fix macros alignments across different UI elements, by relying on a new standardized NutritionTile * honor true needed widths based on string lengths of the column names * show total macros for meal --- lib/screens/log_meal_screen.dart | 8 +- lib/widgets/core/core.dart | 3 + lib/widgets/nutrition/forms.dart | 3 +- lib/widgets/nutrition/helpers.dart | 70 ++++++++--- lib/widgets/nutrition/meal.dart | 45 ++++--- lib/widgets/nutrition/nutrition_tile.dart | 51 ++++++++ lib/widgets/nutrition/nutrition_tiles.dart | 119 ++++++++++++++++++ .../nutrition/nutritional_diary_detail.dart | 6 +- lib/widgets/nutrition/widgets.dart | 114 +---------------- 9 files changed, 266 insertions(+), 153 deletions(-) create mode 100644 lib/widgets/nutrition/nutrition_tile.dart create mode 100644 lib/widgets/nutrition/nutrition_tiles.dart diff --git a/lib/screens/log_meal_screen.dart b/lib/screens/log_meal_screen.dart index cfae7e50..198e5698 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; @@ -68,9 +68,9 @@ class _LogMealScreenState extends State { 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/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..7047904c 100644 --- a/lib/widgets/nutrition/helpers.dart +++ b/lib/widgets/nutrition/helpers.dart @@ -23,24 +23,60 @@ import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/nutritional_values.dart'; import 'package:wger/widgets/core/core.dart'; +/* +// flex factors, based on number of characters in English +// other languages are usually similar, though for fat, often +// use a bit more characters +// energy: 6 +// protein: 7 +// carbohydrates: 13 +// fat: 6 +const nutritionColumnRatios = [6, 7, 13, 6]; +*/ + +List nutritionColumnFlexes(BuildContext context) { + return [ + AppLocalizations.of(context).energy.characters.length, + AppLocalizations.of(context).protein.characters.length, + AppLocalizations.of(context).carbohydrates.characters.length, + AppLocalizations.of(context).fat.characters.length, + ].map((e) { + // 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 (e <= 3) ? 4 : e; + }).toList(); +} + 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, - ), - ]; + 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)), + ] + .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: nutritionColumnFlexes(context)[e.$1], + child: e.$2, + ), + ) + .toList(), + ); +} String getShortNutritionValues(NutritionalValues values, BuildContext context) { final loc = AppLocalizations.of(context); diff --git a/lib/widgets/nutrition/meal.dart b/lib/widgets/nutrition/meal.dart index 66f104be..e8f715ec 100644 --- a/lib/widgets/nutrition/meal.dart +++ b/lib/widgets/nutrition/meal.dart @@ -30,6 +30,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 +159,29 @@ 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')) + const NutritionTile(title: Center(child: Text('No ingredients defined yet'))) 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) + Divider(), + if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) + NutritionTile( + vPadding: 0, + leading: const Text('total'), + title: getNutritionRow( + context, + getMutedNutritionalValues(widget._meal.plannedNutritionalValues, context), + ), + ), if (_viewMode == viewMode.withAllDetails) Column( children: [ + Divider(), Center( child: Text( AppLocalizations.of(context).loggedToday, @@ -181,7 +197,7 @@ class _MealWidgetState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - NutritionDiaryEntry(diaryEntry: item), + DiaryEntryTile(diaryEntry: item), ], ), )), @@ -194,12 +210,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 +230,18 @@ 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, - mainAxisSize: MainAxisSize.max, - children: [ - if (_viewMode == viewMode.withAllDetails) ...getMutedNutritionalValues(values, context) - ], - ), + subtitle: (_viewMode != viewMode.withAllDetails && !_editing) + ? null + : getNutritionRow(context, getMutedNutritionalValues(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: () { @@ -267,7 +280,7 @@ class LogDiaryItemWidget 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}', diff --git a/lib/widgets/nutrition/nutrition_tile.dart b/lib/widgets/nutrition/nutrition_tile.dart new file mode 100644 index 00000000..f7134f62 --- /dev/null +++ b/lib/widgets/nutrition/nutrition_tile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +/// NutritionTile is similar to a non-interactive ListTile, +/// but uses a fixed, easy to understand layout. +/// any trailing value is overlayed over the title & subtitle, on the right +/// as to not disturb the overall layout +class NutritionTile extends StatelessWidget { + final Widget? leading; // will always be constrained to 40px wide + final double vPadding; + final Widget? title; + final Widget? subtitle; + final Widget? trailing; + + 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: Stack( + children: [ + Column( + children: [ + if (title != null) title!, + if (subtitle != null) subtitle!, + ], + ), + if (trailing != null) Align(alignment: Alignment.centerRight, child: trailing!), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nutrition/nutrition_tiles.dart b/lib/widgets/nutrition/nutrition_tiles.dart new file mode 100644 index 00000000..aeeffc82 --- /dev/null +++ b/lib/widgets/nutrition/nutrition_tiles.dart @@ -0,0 +1,119 @@ +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/core/core.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)), // TODO align + ); + } +} + +/// 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, + [ + AppLocalizations.of(context).energy, + AppLocalizations.of(context).protein, + AppLocalizations.of(context).carbohydrates, + AppLocalizations.of(context).fat, + ] + .map((e) => MutedText( + e, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + )) + .toList()), + ); + } +} + +/// 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 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), + iconSize: ICON_SIZE_SMALL, + ), + ], + ); + } +} diff --git a/lib/widgets/nutrition/nutritional_diary_detail.dart b/lib/widgets/nutrition/nutritional_diary_detail.dart index b1717def..b28643ba 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,12 +60,12 @@ class NutritionalDiaryDetailWidget extends StatelessWidget { ), ), const SizedBox(height: 15), - const NutritionDiaryheader(), + const DiaryheaderTile(), ...logs.map((e) => Padding( padding: const EdgeInsets.all(8.0), child: Column( children: [ - NutritionDiaryEntry(diaryEntry: e, nutritionalPlan: _nutritionalPlan), + 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()), - ); - } -}