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
This commit is contained in:
Dieter Plaetinck
2024-06-10 14:13:02 +03:00
parent d2ac12bee4
commit fb7be3c8a3
9 changed files with 266 additions and 153 deletions

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;
@@ -68,9 +68,9 @@ class _LogMealScreenState extends State<LogMealScreen> {
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

@@ -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,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<int> 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<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,
),
];
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<Widget> 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);

View File

@@ -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<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'))
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<MealWidget> {
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
NutritionDiaryEntry(diaryEntry: item),
DiaryEntryTile(diaryEntry: item),
],
),
)),
@@ -194,12 +210,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 +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}',

View File

@@ -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!),
],
),
),
],
),
);
}
}

View File

@@ -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<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,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),
],
),
)),

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()),
);
}
}