diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3af17cece..b185a7107 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -460,6 +460,25 @@ "@toAddMealsToThePlanGoToNutritionalPlanDetails": { "description": "Message shown to guide users to the nutritional plan details page to add meals" }, + "planDateRange": "from {startDate} to {endDate}", + "@planDateRange": { + "description": "Label for the start and end date range of a plan", + "placeholders": { + "startDate": { "type": "String" }, + "endDate": { "type": "String" } + } + }, + "planStartDate": "from {startDate}", + "@planStartDate": { + "description": "Label for the start date of a plan", + "placeholders": { + "startDate": { "type": "String" } + } + }, + "otherLogs": "Other logs", + "@otherLogs": { + "description": "Other unassigned logs. Label for the pseudo-meal section that groups all nutritional diary entries not assigned to a specific meal" + }, "goalEnergy": "Energy goal", "goalProtein": "Protein goal", "goalCarbohydrates": "Carbohydrates goal", @@ -675,6 +694,10 @@ "fiber": "Fibers", "sodium": "Sodium", "@sodium": {}, + "carbohydratesSugar": "Sugar (Carbohydrates)", + "@carbohydratesSugar": { + "description": "Label for the carbohydrates sugar" + }, "amount": "Amount", "@amount": { "description": "The amount (e.g. in grams) of an ingredient in a meal" diff --git a/lib/providers/nutrition_notifier.dart b/lib/providers/nutrition_notifier.dart index e31bdbaf2..6b2b37841 100644 --- a/lib/providers/nutrition_notifier.dart +++ b/lib/providers/nutrition_notifier.dart @@ -59,6 +59,11 @@ class NutritionState { /// Returns the plan with the given [id], or `null` if it isn't (yet) in the /// streamed snapshot NutritionalPlan? findByIdOrNull(String? id) => plans.firstWhereOrNull((plan) => plan.id == id); + + List? recentMealItemsInPlan(String? planId) { + final nutriPlan = plans.firstWhereOrNull((plan) => plan.id == planId); + return nutriPlan?.dedupMealItems; + } } @Riverpod(keepAlive: true) diff --git a/lib/screens/log_meal_screen.dart b/lib/screens/log_meal_screen.dart index cf47069ff..85b4863e0 100644 --- a/lib/screens/log_meal_screen.dart +++ b/lib/screens/log_meal_screen.dart @@ -78,7 +78,7 @@ class _LogMealScreenState extends ConsumerState { children: [ const DiaryheaderTile(), ...meal.mealItems.map( - (item) => MealItemEditableFullTile(item, viewMode.withAllDetails, false), + (item) => MealItemEditableFullTile(item, ViewMode.withAllDetails, false), ), const SizedBox(height: 32), Text( diff --git a/lib/screens/log_meals_screen.dart b/lib/screens/log_meals_screen.dart index 847195b53..0250da77a 100644 --- a/lib/screens/log_meals_screen.dart +++ b/lib/screens/log_meals_screen.dart @@ -61,7 +61,6 @@ class _LogMealsScreenState extends State { itemCount: nutritionalPlan.meals.length, itemBuilder: (context, index) => MealWidget( nutritionalPlan.meals[index], - nutritionalPlan.dedupMealItems, true, true, ), diff --git a/lib/widgets/nutrition/meal.dart b/lib/widgets/nutrition/meal.dart index 06d5cc670..a24b209d9 100644 --- a/lib/widgets/nutrition/meal.dart +++ b/lib/widgets/nutrition/meal.dart @@ -27,6 +27,7 @@ import 'package:wger/providers/nutrition_notifier.dart'; import 'package:wger/screens/form_screen.dart'; import 'package:wger/screens/log_meal_screen.dart'; import 'package:wger/widgets/core/core.dart'; +import 'package:wger/widgets/core/progress_indicator.dart'; import 'package:wger/widgets/nutrition/charts.dart'; import 'package:wger/widgets/nutrition/forms.dart'; import 'package:wger/widgets/nutrition/helpers.dart'; @@ -34,21 +35,20 @@ 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 { +enum ViewMode { base, // just highlevel meal info (name, time) withIngredients, // + ingredients withAllDetails, // + nutritional breakdown of ingredients, + logged today } +/// Card widget for a single [Meal] on the NutritionalPlanDetailWidget (NutritionalPlanScreen). class MealWidget extends ConsumerStatefulWidget { final Meal _meal; - final List _recentMealItems; final bool popTwice; final bool readOnly; const MealWidget( this._meal, - this._recentMealItems, this.popTwice, this.readOnly, ); @@ -58,7 +58,7 @@ class MealWidget extends ConsumerStatefulWidget { } class _MealWidgetState extends ConsumerState { - var _viewMode = viewMode.base; + var _viewMode = ViewMode.base; bool _editing = false; void _toggleEditing() { @@ -71,15 +71,15 @@ class _MealWidgetState extends ConsumerState { setState(() { if (widget._meal.isRealMeal) { _viewMode = switch (_viewMode) { - viewMode.base => viewMode.withIngredients, - viewMode.withIngredients => viewMode.withAllDetails, - viewMode.withAllDetails => viewMode.base, + 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, + ViewMode.base => ViewMode.withAllDetails, + _ => ViewMode.base, }; } }); @@ -102,90 +102,12 @@ class _MealWidgetState extends ConsumerState { toggleViewMode: _toggleDetails, meal: widget._meal, ), - if (_editing) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Wrap( - spacing: 8, - children: [ - TextButton.icon( - icon: const Icon(Icons.add), - label: Text(AppLocalizations.of(context).addIngredient), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).addIngredient, - getMealItemForm(widget._meal, widget._recentMealItems), - hasListView: true, - ), - ); - }, - ), - TextButton.icon( - label: Text(AppLocalizations.of(context).edit), - onPressed: () { - Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - MealForm(widget._meal.planId, widget._meal), - ), - ); - }, - icon: const Icon(Icons.timer), - ), - TextButton.icon( - onPressed: () { - // Delete the meal - ref.read(nutritionProvider.notifier).deleteMeal(widget._meal); - - // and inform the user - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, - ), - ), - ); - }, - label: Text(AppLocalizations.of(context).delete), - icon: const Icon(Icons.delete), - ), - ], - ), - ), - if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) - const Divider(), - if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) - const DiaryheaderTile(), - if (_viewMode == viewMode.withIngredients || _viewMode == viewMode.withAllDetails) - if (widget._meal.mealItems.isEmpty && widget._meal.isRealMeal) - NutritionTile( - title: Text( - AppLocalizations.of(context).noIngredientsDefined, - textAlign: TextAlign.left, - ), - ) - else - ...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) + MealIngredientsSection( + meal: widget._meal, + editing: _editing, + viewMode: _viewMode, + ), + if (_viewMode == ViewMode.withAllDetails) Column( children: [ const Divider(), @@ -220,7 +142,7 @@ class _MealWidgetState extends ConsumerState { /// An editable NutritionTile showing the avatar, name, nutritional values class MealItemEditableFullTile extends ConsumerWidget { final bool _editing; - final viewMode _viewMode; + final ViewMode _viewMode; final MealItem _item; const MealItemEditableFullTile(this._item, this._viewMode, this._editing); @@ -246,7 +168,7 @@ class MealItemEditableFullTile extends ConsumerWidget { overflow: TextOverflow.ellipsis, textAlign: TextAlign.left, ), - subtitle: (_viewMode != viewMode.withAllDetails && !_editing) + subtitle: (_viewMode != ViewMode.withAllDetails && !_editing) ? null : getNutritionRow(context, muted(getNutritionalValues(values, context))), trailing: _editing @@ -279,7 +201,7 @@ class MealHeader extends StatelessWidget { final bool _editing; final bool popTwice; final bool readOnly; - final viewMode _viewMode; + final ViewMode _viewMode; final Function() _toggleEditing; final Function() _toggleViewMode; @@ -288,7 +210,7 @@ class MealHeader extends StatelessWidget { required bool editing, this.popTwice = false, this.readOnly = false, - required viewMode viewMode, + required ViewMode viewMode, required Function() toggleEditing, required Function() toggleViewMode, }) : _meal = meal, @@ -325,9 +247,9 @@ class MealHeader extends StatelessWidget { children: [ IconButton( icon: switch (_viewMode) { - viewMode.base => const Icon(Icons.info_outline), - viewMode.withIngredients => const Icon(Icons.info), - viewMode.withAllDetails => const Icon(Icons.info), + ViewMode.base => const Icon(Icons.info_outline), + ViewMode.withIngredients => const Icon(Icons.info), + ViewMode.withAllDetails => const Icon(Icons.info), }, onPressed: () { _toggleViewMode(); @@ -362,3 +284,140 @@ class MealHeader extends StatelessWidget { ); } } + +class MealIngredientsSection extends ConsumerWidget { + const MealIngredientsSection({ + super.key, + required this.meal, + required this.editing, + required this.viewMode, + }); + final Meal meal; + final bool editing; + final ViewMode viewMode; + + bool showIngredientsDetails(ViewMode viewMode) { + return viewMode == ViewMode.withIngredients || viewMode == ViewMode.withAllDetails; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + if (editing) + MealEditingToolbar( + meal: meal, + ), + if (showIngredientsDetails(viewMode)) ...[ + const Divider(), + const DiaryheaderTile(), + _buildIngredientList(context), + const Divider(), + _buildTotalRow(context), + ], + ], + ); + } + + Widget _buildIngredientList(BuildContext context) { + if (meal.mealItems.isEmpty && meal.isRealMeal) { + return NutritionTile( + title: Text( + AppLocalizations.of(context).noIngredientsDefined, + textAlign: TextAlign.left, + ), + ); + } + + return Column( + children: [ + ...meal.mealItems.map( + (item) => MealItemEditableFullTile(item, viewMode, editing), + ), + ], + ); + } + + Widget _buildTotalRow(BuildContext context) { + final i18n = AppLocalizations.of(context); + + return NutritionTile( + vPadding: 0, + leading: Text(i18n.total), + title: getNutritionRow( + context, + muted(getNutritionalValues(meal.plannedNutritionalValues, context)), + ), + ); + } +} + +class MealEditingToolbar extends ConsumerWidget { + final Meal meal; + const MealEditingToolbar({super.key, required this.meal}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(nutritionProvider).value; + if (state == null) { + return const Center(child: BoxedProgressIndicator()); + } + final recentMealItems = state.recentMealItemsInPlan(meal.planId) ?? const []; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Wrap( + spacing: 8, + children: [ + TextButton.icon( + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context).addIngredient), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).addIngredient, + getMealItemForm(meal, recentMealItems), + hasListView: true, + ), + ); + }, + ), + TextButton.icon( + label: Text(AppLocalizations.of(context).edit), + onPressed: () { + Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).edit, + MealForm(meal.planId, meal), + ), + ); + }, + icon: const Icon(Icons.timer), + ), + TextButton.icon( + onPressed: () { + // Delete the meal + ref.read(nutritionProvider.notifier).deleteMeal(meal); + + // and inform the user + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + ), + ); + }, + label: Text(AppLocalizations.of(context).delete), + icon: const Icon(Icons.delete), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/nutrition/nutritional_plan_detail.dart b/lib/widgets/nutrition/nutritional_plan_detail.dart index 789476bbe..56c4dca3b 100644 --- a/lib/widgets/nutrition/nutritional_plan_detail.dart +++ b/lib/widgets/nutrition/nutritional_plan_detail.dart @@ -42,6 +42,18 @@ class NutritionalPlanDetailWidget extends riverpod.ConsumerWidget { ? nutritionalGoals / lastWeightEntry.weight.toDouble() : null; + final i18n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).languageCode; + final startDateFormatted = DateFormat.yMd(locale).format(_nutritionalPlan.startDate); + + String dateDisplay; + if (_nutritionalPlan.endDate != null) { + final endDateFormatted = DateFormat.yMd(locale).format(_nutritionalPlan.endDate!); + dateDisplay = i18n.planDateRange(startDateFormatted, endDateFormatted); + } else { + dateDisplay = '${i18n.planStartDate(startDateFormatted)} (${i18n.openEnded})'; + } + return SliverList( delegate: SliverChildListDelegate( [ @@ -49,15 +61,7 @@ class NutritionalPlanDetailWidget extends riverpod.ConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( - _nutritionalPlan.endDate != null - ? 'from ${DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(_nutritionalPlan.startDate)} to ${DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(_nutritionalPlan.endDate!)}' - : 'from ${DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(_nutritionalPlan.startDate)} (${AppLocalizations.of(context).openEnded})', + dateDisplay, style: Theme.of(context).textTheme.titleSmall?.copyWith( fontStyle: FontStyle.italic, ), @@ -78,14 +82,12 @@ class NutritionalPlanDetailWidget extends riverpod.ConsumerWidget { ..._nutritionalPlan.meals.map( (meal) => MealWidget( meal, - _nutritionalPlan.dedupMealItems, false, false, ), ), MealWidget( - _nutritionalPlan.pseudoMealOthers('Other logs'), - _nutritionalPlan.dedupMealItems, + _nutritionalPlan.pseudoMealOthers(i18n.otherLogs), false, true, ), diff --git a/test/nutrition/meal_test.dart b/test/nutrition/meal_test.dart index 5ef44b464..fa5a64752 100644 --- a/test/nutrition/meal_test.dart +++ b/test/nutrition/meal_test.dart @@ -22,8 +22,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/providers/network_provider.dart'; +import 'package:wger/providers/nutrition_notifier.dart'; import 'package:wger/widgets/nutrition/meal.dart'; +class _StubNutritionNotifier extends NutritionNotifier { + @override + Stream build() async* { + yield const NutritionState(); + } +} + void main() { Meal buildMeal() => Meal( id: 'aa000000-0000-4000-8000-000000000001', @@ -37,13 +45,16 @@ void main() { // The meal widget does not gate its actions on connectivity. Pinning the // network status to offline makes a re-introduced connectivity gate fail // this test. - overrides: [networkStatusProvider.overrideWithValue(isOnline)], + overrides: [ + networkStatusProvider.overrideWithValue(isOnline), + nutritionProvider.overrideWith(() => _StubNutritionNotifier()), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: SingleChildScrollView( - child: MealWidget(buildMeal(), const [], false, false), + child: MealWidget(buildMeal(), false, false), ), ), ), @@ -66,6 +77,7 @@ void main() { matching: find.byType(TextButton), ), ); + expect(addButton.onPressed, isNotNull); }); }