diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index 8e4e6d76..b518a8e3 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -430,16 +430,18 @@ class IngredientFormState extends State { itemCount: suggestions.length, shrinkWrap: true, itemBuilder: (context, index) { + void select() { + final ingredient = suggestions[index].ingredient; + selectIngredient( + ingredient.id, + ingredient.name, + suggestions[index].amount, + ); + } + return Card( child: ListTile( - onTap: () { - final ingredient = suggestions[index].ingredient; - selectIngredient( - ingredient.id, - ingredient.name, - suggestions[index].amount, - ); - }, + onTap: select, title: Text( '${suggestions[index].ingredient.name} (${suggestions[index].amount.toStringAsFixed(0)}$unit)', ), @@ -456,7 +458,7 @@ class IngredientFormState extends State { showIngredientDetails( context, suggestions[index].ingredient.id, - image: suggestions[index].ingredient.image?.image, + select: select, ); }, ), diff --git a/lib/widgets/nutrition/helpers.dart b/lib/widgets/nutrition/helpers.dart index 302d3df3..f4f6d3f7 100644 --- a/lib/widgets/nutrition/helpers.dart +++ b/lib/widgets/nutrition/helpers.dart @@ -19,14 +19,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; -import 'package:wger/helpers/misc.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/meal.dart'; -import 'package:wger/models/nutrition/nutritional_goals.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/macro_nutrients_table.dart'; +import 'package:wger/widgets/nutrition/ingredient_dialogs.dart'; List getNutritionColumnNames(BuildContext context) => [ AppLocalizations.of(context).energy, @@ -101,69 +99,13 @@ String getKcalConsumedVsPlanned(Meal meal, BuildContext context) { return '${consumed.toStringAsFixed(0)} / ${planned.toStringAsFixed(0)} ${loc.kcal}'; } -void showIngredientDetails(BuildContext context, int id, {String? image}) { +void showIngredientDetails(BuildContext context, int id, {void Function()? select}) { showDialog( context: context, builder: (context) => FutureBuilder( future: Provider.of(context, listen: false).fetchIngredient(id), builder: (BuildContext context, AsyncSnapshot snapshot) { - Ingredient? ingredient; - NutritionalGoals? goals; - String? source; - - if (snapshot.hasData) { - ingredient = snapshot.data; - goals = ingredient!.nutritionalValues.toGoals(); - source = ingredient.sourceName ?? 'unknown'; - } - var radius = 100.0; - final height = MediaQuery.sizeOf(context).height; - final width = MediaQuery.sizeOf(context).width; - final smallest = height < width ? height : width; - if (smallest < 400) { - radius = smallest / 4; - } - return AlertDialog( - title: (snapshot.hasData) ? Text(ingredient!.name) : null, - content: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (image != null) - CircleAvatar(backgroundImage: NetworkImage(image), radius: radius), - if (image != null) const SizedBox(height: 12), - if (snapshot.hasError) - Text( - 'Ingredient lookup error: ${snapshot.error ?? 'unknown error'}', - style: const TextStyle(color: Colors.red), - ), - if (!snapshot.hasData && !snapshot.hasError) const CircularProgressIndicator(), - if (snapshot.hasData) - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 400), - child: MacronutrientsTable( - nutritionalGoals: goals!, - plannedValuesPercentage: goals.energyPercentage(), - showGperKg: false, - ), - ), - if (snapshot.hasData && ingredient!.licenseObjectURl == null) - Text('Source: ${source!}'), - if (snapshot.hasData && ingredient!.licenseObjectURl != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: InkWell( - child: Text('Source: ${source!}'), - onTap: () => launchURL(ingredient!.licenseObjectURl!, context), - ), - ), - ], - ), - ), - ), - ); + return IngredientDetails(snapshot, select: select); }, ), ); diff --git a/lib/widgets/nutrition/ingredient_dialogs.dart b/lib/widgets/nutrition/ingredient_dialogs.dart new file mode 100644 index 00000000..e636e22c --- /dev/null +++ b/lib/widgets/nutrition/ingredient_dialogs.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:wger/helpers/misc.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/models/nutrition/nutritional_goals.dart'; +import 'package:wger/widgets/nutrition/macro_nutrients_table.dart'; + +Widget ingredientImage(String url, BuildContext context) { + var radius = 100.0; + final height = MediaQuery.sizeOf(context).height; + final width = MediaQuery.sizeOf(context).width; + final smallest = height < width ? height : width; + if (smallest < 400) { + radius = smallest / 4; + } + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: CircleAvatar(backgroundImage: NetworkImage(url), radius: radius), + ); +} + +class IngredientDetails extends StatelessWidget { + final AsyncSnapshot snapshot; + final void Function()? select; + const IngredientDetails(this.snapshot, {super.key, this.select}); + + @override + Widget build(BuildContext context) { + Ingredient? ingredient; + NutritionalGoals? goals; + String? source; + + if (snapshot.hasData) { + ingredient = snapshot.data; + goals = ingredient!.nutritionalValues.toGoals(); + source = ingredient.sourceName ?? 'unknown'; + } + + return AlertDialog( + title: (snapshot.hasData) ? Text(ingredient!.name) : null, + content: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (snapshot.hasError) + Text( + 'Ingredient lookup error: ${snapshot.error ?? 'unknown error'}', + style: const TextStyle(color: Colors.red), + ), + if (ingredient?.image?.image != null) + ingredientImage(ingredient!.image!.image, context), + if (!snapshot.hasData && !snapshot.hasError) const CircularProgressIndicator(), + if (snapshot.hasData) + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 400), + child: MacronutrientsTable( + nutritionalGoals: goals!, + plannedValuesPercentage: goals.energyPercentage(), + showGperKg: false, + ), + ), + if (snapshot.hasData && ingredient!.licenseObjectURl == null) + Text('Source: ${source!}'), + if (snapshot.hasData && ingredient!.licenseObjectURl != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: InkWell( + child: Text('Source: ${source!}'), + onTap: () => launchURL(ingredient!.licenseObjectURl!, context), + ), + ), + ], + ), + ), + ), + actions: [ + if (snapshot.hasData && select != null) + TextButton( + key: const Key('ingredient-details-continue-button'), + child: Text(MaterialLocalizations.of(context).continueButtonLabel), + onPressed: () { + select!(); + Navigator.of(context).pop(); + }, + ), + TextButton( + key: const Key('ingredient-details-close-button'), + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} + +class IngredientScanResultDialog extends StatelessWidget { + final AsyncSnapshot snapshot; + final String barcode; + final Function(int id, String name, num? amount) selectIngredient; + + const IngredientScanResultDialog(this.snapshot, this.barcode, this.selectIngredient, {super.key}); + + @override + Widget build(BuildContext context) { + Ingredient? ingredient; + NutritionalGoals? goals; + String? title; + String? source; + + if (snapshot.connectionState == ConnectionState.done) { + ingredient = snapshot.data; + title = ingredient != null + ? AppLocalizations.of(context).productFound + : AppLocalizations.of(context).productNotFound; + if (ingredient != null) { + goals = ingredient.nutritionalValues.toGoals(); + source = ingredient.sourceName ?? 'unknown'; + } + } + return AlertDialog( + key: const Key('ingredient-scan-result-dialog'), + title: title != null ? Text(title) : null, + content: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (snapshot.hasError) + Text( + 'Ingredient lookup error: ${snapshot.error ?? 'unknown error'}', + style: const TextStyle(color: Colors.red), + ), + if (snapshot.connectionState == ConnectionState.done && ingredient == null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + AppLocalizations.of(context).productNotFoundDescription(barcode), + ), + ), + if (ingredient != null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: + Text(AppLocalizations.of(context).productFoundDescription(ingredient.name)), + ), + if (ingredient?.image?.image != null) + ingredientImage(ingredient!.image!.image, context), + if (snapshot.connectionState != ConnectionState.done && !snapshot.hasError) + const CircularProgressIndicator(), + if (goals != null) + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 400), + child: MacronutrientsTable( + nutritionalGoals: goals, + plannedValuesPercentage: goals.energyPercentage(), + showGperKg: false, + ), + ), + if (ingredient != null && ingredient.licenseObjectURl == null) + Text('Source: ${source!}'), + if (ingredient?.licenseObjectURl != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: InkWell( + child: Text('Source: ${source!}'), + onTap: () => launchURL(ingredient!.licenseObjectURl!, context), + ), + ), + ], + ), + ), + ), + actions: [ + if (ingredient != null) // if barcode matched + TextButton( + key: const Key('ingredient-scan-result-dialog-confirm-button'), + child: Text(MaterialLocalizations.of(context).continueButtonLabel), + onPressed: () { + selectIngredient(ingredient!.id, ingredient.name, null); + Navigator.of(context).pop(); + }, + ), + // if didn't match, or we're still waiting + TextButton( + key: const Key('ingredient-scan-result-dialog-close-button'), + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/nutrition/nutrition_tiles.dart b/lib/widgets/nutrition/nutrition_tiles.dart index ecb10aae..dc23fbff 100644 --- a/lib/widgets/nutrition/nutrition_tiles.dart +++ b/lib/widgets/nutrition/nutrition_tiles.dart @@ -35,7 +35,6 @@ class MealItemValuesTile extends StatelessWidget { showIngredientDetails( context, ingredient.id, - image: ingredient.image?.image, ); }, ), diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index ae3f57d6..00afff5b 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -32,7 +32,7 @@ import 'package:wger/models/nutrition/ingredient.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'; +import 'package:wger/widgets/nutrition/ingredient_dialogs.dart'; class ScanReader extends StatelessWidget { const ScanReader(); @@ -166,7 +166,9 @@ class _IngredientTypeaheadState extends State { showIngredientDetails( context, suggestion.data.id, - image: suggestion.data.image != null ? url! + suggestion.data.image! : null, + select: () { + widget.selectIngredient(suggestion.data.id, suggestion.value, null); + }, ); }, ), @@ -204,88 +206,24 @@ class _IngredientTypeaheadState extends State { if (!widget.test!) { barcode = await readerscan(context); } - - if (barcode.isNotEmpty) { - if (!mounted) { - return; - } - final result = await Provider.of( - context, - listen: false, - ).searchIngredientWithCode(barcode); - // TODO: show spinner... - if (!mounted) { - return; - } - - if (result != null) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - key: const Key('found-dialog'), - title: Text(AppLocalizations.of(context).productFound), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(AppLocalizations.of(context).productFoundDescription(result.name)), - // TODO replace with full view instead of small popup - MealItemValuesTile( - ingredient: result, - nutritionalValues: result.nutritionalValues, - ), - ], - ), - actions: [ - TextButton( - key: const Key('found-dialog-confirm-button'), - child: Text(MaterialLocalizations.of(context).continueButtonLabel), - onPressed: () { - widget.selectIngredient(result.id, result.name, null); - Navigator.of(ctx).pop(); - }, - ), - TextButton( - key: const Key('found-dialog-close-button'), - child: Text( - MaterialLocalizations.of(context).closeButtonLabel, - ), - onPressed: () { - Navigator.of(ctx).pop(); - }, - ), - ], - ), - ); - } else { - //nothing is matching barcode - showDialog( - context: context, - builder: (ctx) => AlertDialog( - key: const Key('notFound-dialog'), - title: Text(AppLocalizations.of(context).productNotFound), - content: Text( - AppLocalizations.of(context).productNotFoundDescription(barcode), - ), - actions: [ - TextButton( - key: const Key('notFound-dialog-close-button'), - child: Text( - MaterialLocalizations.of(context).closeButtonLabel, - ), - onPressed: () { - Navigator.of(ctx).pop(); - }, - ), - ], - ), - ); - } - } } catch (e) { if (mounted) { showErrorDialog(e, context); } } + if (!mounted) { + return; + } + showDialog( + context: context, + builder: (context) => FutureBuilder( + future: Provider.of(context, listen: false) + .searchIngredientWithCode(barcode), + builder: (BuildContext context, AsyncSnapshot snapshot) { + return IngredientScanResultDialog(snapshot, barcode, widget.selectIngredient); + }, + ), + ); }, ); } diff --git a/test/nutrition/nutritional_meal_item_form_test.dart b/test/nutrition/nutritional_meal_item_form_test.dart index 9ea69f77..ba5de3f0 100644 --- a/test/nutrition/nutritional_meal_item_form_test.dart +++ b/test/nutrition/nutritional_meal_item_form_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:http/http.dart' as http; import 'package:mockito/mockito.dart'; +import 'package:network_image_mock/network_image_mock.dart'; import 'package:provider/provider.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/models/exercises/ingredient_api.dart'; @@ -127,13 +128,15 @@ void main() { }); group('Test the AlertDialogs for scanning result', () { + // TODO: why do we need to support empty barcodes? testWidgets('with empty code', (WidgetTester tester) async { await tester.pumpWidget(createMealItemFormScreen(meal1, '', true)); await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsNothing); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsNothing); }); testWidgets('with correct code', (WidgetTester tester) async { @@ -142,7 +145,8 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsOneWidget); }); testWidgets('with incorrect code', (WidgetTester tester) async { @@ -151,7 +155,8 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('notFound-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsNothing); }); }); @@ -221,9 +226,9 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); - await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); + await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button'))); await tester.pumpAndSettle(); expect(formState.ingredientIdController.text, '1'); @@ -235,12 +240,12 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); - await tester.tap(find.byKey(const Key('found-dialog-close-button'))); + await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-close-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsNothing); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsNothing); }); }); @@ -264,9 +269,9 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); - await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); + await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); @@ -275,15 +280,16 @@ void main() { expect(find.text('Please enter a valid number'), findsOneWidget); }); +//TODO: isn't this test just a duplicate of the above one? can be removed? testWidgets('save ingredient with incorrect weight input type', (WidgetTester tester) async { await tester.pumpWidget(createMealItemFormScreen(meal1, '123', true)); await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); - await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); + await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); @@ -301,22 +307,22 @@ void main() { await tester.tap(find.byKey(const Key('scan-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('found-dialog')), findsOneWidget); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget); - await tester.tap(find.byKey(const Key('found-dialog-confirm-button'))); + await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button'))); await tester.pumpAndSettle(); expect(formState.ingredientIdController.text, '1'); - // once ID and weight are set, it'll fetchIngredient and show macros preview + await tester.enterText(find.byKey(const Key('field-weight')), '2'); + + // once ID and weight are set, it'll fetchIngredient and show macros preview and ingredient image when(mockNutrition.fetchIngredient(1)).thenAnswer((_) => Future.value( Ingredient.fromJson(jsonDecode(fixture('nutrition/ingredientinfo_59887.json'))), )); + await mockNetworkImagesFor(() => tester.pumpAndSettle()); - await tester.enterText(find.byKey(const Key('field-weight')), '2'); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('found-dialog')), findsNothing); + expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsNothing); await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); await tester.pumpAndSettle();