diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panels_builder.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panels_builder.dart index 09ddf8fa4a2..ab96ebe516a 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panels_builder.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panels_builder.dart @@ -4,10 +4,14 @@ import 'package:openfoodfacts/model/KnowledgePanel.dart'; import 'package:openfoodfacts/model/KnowledgePanelElement.dart'; import 'package:openfoodfacts/model/KnowledgePanels.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/knowledge_panels/knowledge_panel_element_card.dart'; +import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/product/edit_ingredients_page.dart'; import 'package:smooth_app/pages/product/nutrition_page_loaded.dart'; import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; +import 'package:smooth_app/pages/user_preferences_dev_mode.dart'; /// Builds "knowledge panels" panels. /// @@ -128,12 +132,23 @@ class KnowledgePanelsBuilder { }, ), ); - if (product.statesTags?.contains('en:ingredients-to-be-completed') ?? + if (context.read().getFlag( + UserPreferencesDevMode.userPreferencesFlagEditIngredients) ?? false) { + // When the flag is removed, this should be the following: + // if (product.statesTags?.contains('en:ingredients-to-be-completed') ?? false) { knowledgePanelElementWidgets.add( addPanelButton( appLocalizations.score_add_missing_ingredients, - onPressed: () {}, + onPressed: () async => Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => EditIngredientsPage( + product: product, + imageIngredientsUrl: product.imageIngredientsUrl, + ), + ), + ), ), ); } diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 9132006570e..1985f0d2b78 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -336,6 +336,10 @@ }, "ingredients": "Ingredients", "@ingredients": {}, + "ingredients_editing_instructions": "Keep the original order. Indicate the percentage when specified. Separate with a comma or hyphen, use parentheses for ingredients of an ingredient, and indicate allergens between underscores.", + "ingredients_editing_error": "Failed to save the ingredients.", + "ingredients_editing_image_error": "Failed to get a new ingredients image.", + "ingredients_editing_title": "Edit Ingredients", "ingredients_photo": "Ingredients photo", "@ingredients_photo": { "description": "Button label: For adding a picture of the Ingredients of a product" @@ -586,4 +590,4 @@ "@main_app_color": { "description": "Heading for the section to pick the main app color" } -} \ No newline at end of file +} diff --git a/packages/smooth_app/lib/l10n/app_fr.arb b/packages/smooth_app/lib/l10n/app_fr.arb index be45cfb7501..c1efc47de39 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -336,6 +336,7 @@ }, "ingredients": "Ingrédients", "@ingredients": {}, + "ingredients_editing_instructions": "Conserver l'ordre, indiquer le % lorsqu'il est précisé, séparer par une virgule ou -, utiliser les () pour les ingrédients d’un ingrédient, indiquer les allergènes entre _.", "ingredients_photo": "Photo des ingrédients", "@ingredients_photo": { "description": "Button label: For adding a picture of the Ingredients of a product" @@ -582,4 +583,4 @@ "@main_app_color": { "description": "Heading for the section to pick the main app color" } -} \ No newline at end of file +} diff --git a/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart new file mode 100644 index 00000000000..8655ae4477f --- /dev/null +++ b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart @@ -0,0 +1,370 @@ +import 'dart:io'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/model/OcrIngredientsResult.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/helpers/picture_capture_helper.dart'; +import 'package:smooth_app/pages/image_crop_page.dart'; +import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/themes/smooth_theme.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; + +/// Page for editing the ingredients of a product and the image of the +/// ingredients. +class EditIngredientsPage extends StatefulWidget { + const EditIngredientsPage({ + Key? key, + this.imageIngredientsUrl, + required this.product, + }) : super(key: key); + + final Product product; + final String? imageIngredientsUrl; + + @override + State createState() => _EditIngredientsPageState(); +} + +class _EditIngredientsPageState extends State { + final TextEditingController _controller = TextEditingController(); + ImageProvider? _imageProvider; + bool _updatingImage = false; + bool _updatingIngredients = false; + + static String _getIngredientsString(List? ingredients) { + return ingredients == null ? '' : ingredients.join(', '); + } + + @override + void initState() { + super.initState(); + _controller.text = _getIngredientsString(widget.product.ingredients); + } + + @override + void didUpdateWidget(EditIngredientsPage oldWidget) { + super.didUpdateWidget(oldWidget); + final String productIngredients = + _getIngredientsString(widget.product.ingredients); + if (productIngredients != _controller.text) { + _controller.text = productIngredients; + } + } + + Future _onSubmitField(String string) async { + final User user = ProductQuery.getUser(); + + setState(() { + _updatingIngredients = true; + }); + + try { + await _updateIngredientsText(string, user); + } catch (error) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + _showError(appLocalizations.ingredients_editing_error); + } + + setState(() { + _updatingIngredients = false; + }); + } + + Future _onTapGetImage() async { + setState(() { + _updatingImage = true; + }); + + try { + await _getImage(); + } catch (error) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + _showError(appLocalizations.ingredients_editing_image_error); + } + + setState(() { + _updatingImage = false; + }); + } + + // Show the given error message to the user in a SnackBar. + void _showError(String error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + duration: const Duration(seconds: 3), + ), + ); + } + + // Get an image from the camera, run OCR on it, and update the product's + // ingredients. + // + // Returns a Future that resolves successfully only if everything succeeds, + // otherwise it will resolve with the relevant error. + Future _getImage() async { + final File? croppedImageFile = await startImageCropping(context); + + // If the user cancels. + if (croppedImageFile == null) { + return; + } + + // Update the image to load the new image file. + setState(() { + _imageProvider = FileImage(croppedImageFile); + }); + + final bool isUploaded = await uploadCapturedPicture( + context, + barcode: widget.product.barcode!, + imageField: ImageField.INGREDIENTS, + imageUri: croppedImageFile.uri, + ); + croppedImageFile.delete(); + + if (!isUploaded) { + throw Exception('Image could not be uploaded.'); + } + + final OpenFoodFactsLanguage? language = ProductQuery.getLanguage(); + + final User user = ProductQuery.getUser(); + + // Get the ingredients from the image. + final OcrIngredientsResult ingredientsResult = + await OpenFoodAPIClient.extractIngredients( + user, widget.product.barcode!, language!); + + final String? nextIngredients = ingredientsResult.ingredientsTextFromImage; + if (nextIngredients == null || nextIngredients.isEmpty) { + throw Exception('Failed to detect ingredients text in image.'); + } + + // Save the product's ingredients if needed. + if (_controller.text != nextIngredients) { + setState(() { + _controller.text = nextIngredients; + }); + + await _updateIngredientsText(nextIngredients, user); + } + } + + Future _updateIngredientsText(String ingredientsText, User user) async { + widget.product.ingredientsText = ingredientsText; + final LocalDatabase localDatabase = context.read(); + final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( + context: context, + localDatabase: localDatabase, + product: widget.product, + ); + if (!savedAndRefreshed) { + throw Exception("Couldn't save the product."); + } + } + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + + final List children = []; + + if (_imageProvider != null) { + children.add(ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: Image( + image: _imageProvider!, + fit: BoxFit.cover, + ), + )); + } else { + if (widget.imageIngredientsUrl != null) { + children.add(ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: Image( + fit: BoxFit.cover, + image: NetworkImage(widget.imageIngredientsUrl!), + ), + )); + } else { + children.add(Container(color: Colors.white)); + } + } + + if (_updatingImage) { + children.add(const Center( + child: CircularProgressIndicator(), + )); + } else { + children.add(_EditIngredientsBody( + controller: _controller, + imageIngredientsUrl: widget.imageIngredientsUrl, + onTapGetImage: _onTapGetImage, + onSubmitField: _onSubmitField, + updatingIngredients: _updatingIngredients, + )); + } + + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + title: Text(appLocalizations.ingredients_editing_title), + backgroundColor: Colors.transparent, + flexibleSpace: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0), + child: Container( + color: Colors.transparent, + ), + ), + ), + ), + body: Stack( + children: children, + ), + ); + } +} + +class _EditIngredientsBody extends StatelessWidget { + const _EditIngredientsBody({ + Key? key, + required this.controller, + required this.imageIngredientsUrl, + required this.onSubmitField, + required this.onTapGetImage, + required this.updatingIngredients, + }) : super(key: key); + + final TextEditingController controller; + final bool updatingIngredients; + final String? imageIngredientsUrl; + final Future Function() onTapGetImage; + final Future Function(String) onSubmitField; + + @override + Widget build(BuildContext context) { + final ThemeProvider themeProvider = context.watch(); + final ThemeData darkTheme = SmoothTheme.getThemeData( + Brightness.dark, + themeProvider.colorTag, + ); + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + + return Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: LARGE_SPACE), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + flex: 1, + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(bottom: LARGE_SPACE), + child: _ActionButtons( + getImage: onTapGetImage, + hasImage: imageIngredientsUrl != null, + ), + ), + ), + ), + Flexible( + flex: 1, + child: Container( + color: Colors.black, + child: Theme( + data: darkTheme, + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Padding( + padding: const EdgeInsets.all(LARGE_SPACE), + child: Column( + children: [ + TextField( + enabled: !updatingIngredients, + controller: controller, + decoration: const InputDecoration( + border: OutlineInputBorder( + borderRadius: ANGULAR_BORDER_RADIUS, + ), + ), + maxLines: null, + textInputAction: TextInputAction.done, + onSubmitted: onSubmitField, + ), + Text(appLocalizations + .ingredients_editing_instructions), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// The actions for the page in a row of FloatingActionButtons. +class _ActionButtons extends StatelessWidget { + const _ActionButtons({ + Key? key, + required this.hasImage, + required this.getImage, + }) : super(key: key); + + final bool hasImage; + final VoidCallback getImage; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).buttonTheme.colorScheme!; + final List children = hasImage + ? [ + FloatingActionButton.small( + tooltip: 'Retake photo', + backgroundColor: colorScheme.background, + foregroundColor: colorScheme.onBackground, + onPressed: getImage, + child: const Icon(Icons.refresh), + ), + const SizedBox(width: MEDIUM_SPACE), + FloatingActionButton.small( + tooltip: 'Confirm', + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + onPressed: () { + Navigator.pop(context); + }, + child: const Icon(Icons.check), + ), + ] + : [ + FloatingActionButton.small( + tooltip: 'Take photo', + backgroundColor: colorScheme.background, + foregroundColor: colorScheme.onBackground, + onPressed: getImage, + child: const Icon(Icons.camera_alt), + ), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: children, + ); + } +} diff --git a/packages/smooth_app/lib/pages/product/new_product_page.dart b/packages/smooth_app/lib/pages/product/new_product_page.dart index 6053851125b..b722e9eee93 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -290,8 +290,8 @@ class _ProductPageState extends State { KnowledgePanelsBuilder(setState: () => setState(() {})) .buildAll( snapshot.data!, - product: _product, context: context, + product: _product, ); } else if (snapshot.hasError) { // TODO(jasmeet): Retry the request. diff --git a/packages/smooth_app/lib/pages/user_preferences_dev_mode.dart b/packages/smooth_app/lib/pages/user_preferences_dev_mode.dart index e060abcbf12..d4f1d81ea1e 100644 --- a/packages/smooth_app/lib/pages/user_preferences_dev_mode.dart +++ b/packages/smooth_app/lib/pages/user_preferences_dev_mode.dart @@ -40,6 +40,7 @@ class UserPreferencesDevMode extends AbstractUserPreferences { static const String userPreferencesFlagLenientMatching = '__lenientMatching'; static const String userPreferencesFlagAdditionalButton = '__additionalButtonOnProductPage'; + static const String userPreferencesFlagEditIngredients = '__editIngredients'; static const String userPreferencesEnumScanMode = '__scanMode'; final TextEditingController _textFieldController = TextEditingController(); @@ -127,6 +128,17 @@ class UserPreferencesDevMode extends AbstractUserPreferences { .showSnackBar(const SnackBar(content: Text('Ok'))); }, ), + SwitchListTile( + title: const Text('Edit ingredients via a knowledge panel button'), + value: userPreferences.getFlag(userPreferencesFlagEditIngredients) ?? + false, + onChanged: (bool value) async { + await userPreferences.setFlag( + userPreferencesFlagEditIngredients, value); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Ok'))); + }, + ), ListTile( title: const Text('Export History'), onTap: () async {