From b21c19ae6579491a274a761a4e876c161efcdc74 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Sun, 26 May 2024 21:05:10 +0200 Subject: [PATCH 1/5] various ingredient form UX issues * upon scan completion, give more useful preview of the found ingredient * use ID field to track whether the form is "loaded" with a valid ingredient or not * don't trigger search if an ingredient is loaded, unless user changes the pattern * always show meal item preview, whether loaded from recent items, or from scan result --- lib/widgets/nutrition/forms.dart | 19 ++++++-------- lib/widgets/nutrition/widgets.dart | 41 ++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index f5b2e7c1..7ba3443e 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -169,7 +169,6 @@ class IngredientFormState extends State { final _timeController = TextEditingController(); // optional final _mealItem = MealItem.empty(); - bool validIngredientId = false; @override void initState() { super.initState(); @@ -287,7 +286,7 @@ class IngredientFormState extends State { ), ], ), - if (validIngredientId) + if (ingredientIdController.text.isNotEmpty) Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -302,10 +301,9 @@ class IngredientFormState extends State { builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { _mealItem.ingredient = snapshot.data!; - return ListTile( - leading: IngredientAvatar(ingredient: _mealItem.ingredient), - title: - Text(getShortNutritionValues(_mealItem.nutritionalValues, context)), + return MealItemTile( + ingredient: _mealItem.ingredient, + nutritionalValues: _mealItem.nutritionalValues, ); } else if (snapshot.hasError) { return Padding( @@ -365,14 +363,13 @@ class IngredientFormState extends State { return Card( child: ListTile( onTap: () { - _ingredientController.text = widget.recent[index].ingredient.name; - _ingredientIdController.text = - widget.recent[index].ingredient.id.toString(); - _amountController.text = widget.recent[index].amount.toStringAsFixed(0); setState(() { + _ingredientController.text = widget.recent[index].ingredient.name; + _ingredientIdController.text = + widget.recent[index].ingredient.id.toString(); + _amountController.text = widget.recent[index].amount.toStringAsFixed(0); _mealItem.ingredientId = widget.recent[index].ingredientId; _mealItem.amount = widget.recent[index].amount; - validIngredientId = true; }); }, title: Text( diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 0cc2d24c..074d5713 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -32,6 +32,7 @@ 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'; @@ -118,6 +119,11 @@ class _IngredientTypeaheadState extends State { } return null; }, + onChanged: (value) { + // if user changes the pattern, it means they want to drop the + // currently loaded ingredient (if any) and start a new search + widget._ingredientIdController.text = ''; + }, decoration: InputDecoration( prefixIcon: const Icon(Icons.search), labelText: AppLocalizations.of(context).searchIngredient, @@ -126,7 +132,8 @@ class _IngredientTypeaheadState extends State { ); }, suggestionsCallback: (pattern) { - if (pattern == '') { + // don't do search if user has already loaded a specific item + if (pattern == '' || widget._ingredientIdController.text.isNotEmpty) { return null; } @@ -188,6 +195,7 @@ class _IngredientTypeaheadState extends State { } final result = await Provider.of(context, listen: false) .searchIngredientWithCode(barcode); + // TODO: show spinner... if (!mounted) { return; } @@ -198,7 +206,16 @@ class _IngredientTypeaheadState extends State { builder: (ctx) => AlertDialog( key: const Key('found-dialog'), title: Text(AppLocalizations.of(context).productFound), - content: Text(AppLocalizations.of(context).productFoundDescription(result.name)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppLocalizations.of(context).productFoundDescription(result.name)), + MealItemTile( + ingredient: result, + nutritionalValues: result.nutritionalValues, + ), + ], + ), actions: [ TextButton( key: const Key('found-dialog-confirm-button'), @@ -357,3 +374,23 @@ 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()), + ); + } +} From 864c6fc82b0fdb243054fcc09e2d765b1e1d5916 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Sun, 26 May 2024 21:40:41 +0200 Subject: [PATCH 2/5] properly set/unset value via helper functions. fix #591 --- lib/widgets/nutrition/forms.dart | 36 ++++++++++++++++++++++-------- lib/widgets/nutrition/widgets.dart | 18 +++++++-------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 7ba3443e..247d8abc 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -181,6 +181,27 @@ class IngredientFormState extends State { MealItem get mealItem => _mealItem; +// note: make sure to set _mealItem.ingredient (before/after calling this) + void selectIngredient(int id, String name, num? amount) { + setState(() { + _mealItem.ingredientId = id; + _ingredientController.text = name; + _ingredientIdController.text = id.toString(); + if (amount != null) { + _amountController.text = amount.toStringAsFixed(0); + _mealItem.amount = amount; + } + }); + } + +// note: does not reset text search and amount inputs + void unSelectIngredient() { + setState(() { + _mealItem.ingredientId = 0; + _ingredientIdController.text = ''; + }); + } + @override Widget build(BuildContext context) { final String unit = AppLocalizations.of(context).g; @@ -196,6 +217,8 @@ class IngredientFormState extends State { _ingredientController, barcode: widget.barcode, test: widget.test, + selectIngredient: selectIngredient, + unSelectIngredient: unSelectIngredient, ), Row( children: [ @@ -286,7 +309,7 @@ class IngredientFormState extends State { ), ], ), - if (ingredientIdController.text.isNotEmpty) + if (ingredientIdController.text.isNotEmpty && _amountController.text.isNotEmpty) Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -363,14 +386,9 @@ class IngredientFormState extends State { return Card( child: ListTile( onTap: () { - setState(() { - _ingredientController.text = widget.recent[index].ingredient.name; - _ingredientIdController.text = - widget.recent[index].ingredient.id.toString(); - _amountController.text = widget.recent[index].amount.toStringAsFixed(0); - _mealItem.ingredientId = widget.recent[index].ingredientId; - _mealItem.amount = widget.recent[index].amount; - }); + final ingredient = widget.recent[index].ingredient; + selectIngredient( + ingredient.id, ingredient.name, widget.recent[index].amount); }, title: Text( '${widget.recent[index].ingredient.name} (${widget.recent[index].amount.toStringAsFixed(0)}$unit)'), diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 074d5713..4b2f9d12 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -63,12 +63,17 @@ class IngredientTypeahead extends StatefulWidget { final bool? test; final bool showScanner; + final Function(int id, String name, num? amount) selectIngredient; + final Function() unSelectIngredient; + const IngredientTypeahead( this._ingredientIdController, this._ingredientController, { this.showScanner = true, this.test = false, this.barcode = '', + required this.selectIngredient, + required this.unSelectIngredient, }); @override @@ -120,9 +125,8 @@ class _IngredientTypeaheadState extends State { return null; }, onChanged: (value) { - // if user changes the pattern, it means they want to drop the - // currently loaded ingredient (if any) and start a new search - widget._ingredientIdController.text = ''; + // unselect to start a new search + widget.unSelectIngredient(); }, decoration: InputDecoration( prefixIcon: const Icon(Icons.search), @@ -158,10 +162,7 @@ class _IngredientTypeaheadState extends State { child: child, ), onSelected: (suggestion) { - //SuggestionsController.of(context).; - - widget._ingredientIdController.text = suggestion.data.id.toString(); - widget._ingredientController.text = suggestion.value; + widget.selectIngredient(suggestion.data.id, suggestion.value, null); }, ), if (Localizations.localeOf(context).languageCode != LANGUAGE_SHORT_ENGLISH) @@ -221,8 +222,7 @@ class _IngredientTypeaheadState extends State { key: const Key('found-dialog-confirm-button'), child: Text(MaterialLocalizations.of(context).continueButtonLabel), onPressed: () { - widget._ingredientController.text = result.name; - widget._ingredientIdController.text = result.id.toString(); + widget.selectIngredient(result.id, result.name, null); Navigator.of(ctx).pop(); }, ), From ad64eb2c14d2ff4c5ca69ed15cb06d86724d8201 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 27 May 2024 09:37:04 +0200 Subject: [PATCH 3/5] cleanup --- lib/widgets/nutrition/forms.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 247d8abc..c9d319e7 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -181,7 +181,6 @@ class IngredientFormState extends State { MealItem get mealItem => _mealItem; -// note: make sure to set _mealItem.ingredient (before/after calling this) void selectIngredient(int id, String name, num? amount) { setState(() { _mealItem.ingredientId = id; @@ -349,9 +348,7 @@ class IngredientFormState extends State { ), ), ElevatedButton( - key: const Key( - SUBMIT_BUTTON_KEY_NAME), // needed? mealItemForm had it, but not ingredientlogform - + key: const Key(SUBMIT_BUTTON_KEY_NAME), child: Text(AppLocalizations.of(context).save), onPressed: () async { if (!_form.currentState!.validate()) { From 4fcacbcdcecb2d4d57f11ad789754bb2d3cf830a Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 27 May 2024 10:05:13 +0200 Subject: [PATCH 4/5] fix test --- test/nutrition/nutritional_meal_item_form_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/nutrition/nutritional_meal_item_form_test.dart b/test/nutrition/nutritional_meal_item_form_test.dart index aa739ba1..3ee6a3eb 100644 --- a/test/nutrition/nutritional_meal_item_form_test.dart +++ b/test/nutrition/nutritional_meal_item_form_test.dart @@ -305,6 +305,11 @@ void main() { expect(formState.ingredientIdController.text, '1'); + // once ID and weight are set, it'll fetchIngredient and show macros preview + when(mockNutrition.fetchIngredient(1)).thenAnswer((_) => Future.value( + Ingredient.fromJson(jsonDecode(fixture('nutrition/ingredient_59887_response.json'))), + )); + await tester.enterText(find.byKey(const Key('field-weight')), '2'); await tester.pumpAndSettle(); From 436ee2a723280709c8f6465fc1d5f04c4ed7adb2 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 27 May 2024 10:09:20 +0200 Subject: [PATCH 5/5] hide id --- lib/widgets/nutrition/widgets.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 4b2f9d12..35d335c5 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -390,7 +390,7 @@ class MealItemTile extends StatelessWidget { return ListTile( leading: IngredientAvatar(ingredient: ingredient), title: Text(getShortNutritionValues(nutritionalValues, context)), - subtitle: Text(ingredient.id.toString()), + // subtitle: Text(ingredient.id.toString()), ); } }