From edd3be95b26a9c6a21cfe9dc9cb961ae1849c0a7 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Thu, 17 Feb 2022 11:10:55 +0100 Subject: [PATCH 1/3] feat: #944 - added a category picker page to the temporary product button New files: * `category_cache.dart`: Cache where we download and store category data. * `category_picker_page.dart`: Category picker page. * `product_refresher.dart`: Refreshes a product on the BE then on the local database. Impacted files: * `new_product_page.dart`: now the temporary button opens the category picker page. * `nutrition_page_loaded.dart`: refactored using new class `ProductRefresher`. --- .../lib/pages/product/category_cache.dart | 117 ++++++++++++ .../pages/product/category_picker_page.dart | 168 ++++++++++++++++++ .../product/common/product_refresher.dart | 77 ++++++++ .../lib/pages/product/new_product_page.dart | 42 ++++- .../pages/product/nutrition_page_loaded.dart | 63 +------ 5 files changed, 409 insertions(+), 58 deletions(-) create mode 100644 packages/smooth_app/lib/pages/product/category_cache.dart create mode 100644 packages/smooth_app/lib/pages/product/category_picker_page.dart create mode 100644 packages/smooth_app/lib/pages/product/common/product_refresher.dart diff --git a/packages/smooth_app/lib/pages/product/category_cache.dart b/packages/smooth_app/lib/pages/product/category_cache.dart new file mode 100644 index 00000000000..50789a82c96 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/category_cache.dart @@ -0,0 +1,117 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; + +/// Cache where we download and store category data. +class CategoryCache { + CategoryCache(this.language); + + /// Current app language. + final OpenFoodFactsLanguage language; + + /// Languages for category translations. + List get _languages => [ + language, + _alternateLanguage, + ]; + + /// Where we keep everything we've already downloaded. + final Map _cache = {}; + + /// Where we keep the tags we've tried to download but found nothing. + /// + /// e.g. 'ru:хлеб-украинский-новый', child of 'en:breads' + final Set _unknown = {}; + + /// Alternate language, where it's relatively safe to find translations. + static const OpenFoodFactsLanguage _alternateLanguage = + OpenFoodFactsLanguage.ENGLISH; + + /// Fields we retrieve. + static const List _fields = [ + TaxonomyCategoryField.NAME, + TaxonomyCategoryField.CHILDREN, + TaxonomyCategoryField.PARENTS, + ]; + + /// Returns the siblings AND the father (for tree climbing reasons). + Future?> getCategorySiblingsAndFather({ + required final String fatherTag, + }) async { + final Map fatherData = + await _getCategories([fatherTag]); + if (fatherData.isEmpty) { + return null; + } + final List? siblingTags = fatherData[fatherTag]?.children; + if (siblingTags == null || siblingTags.isEmpty) { + return fatherData; + } + final Map result = + await _getCategories(siblingTags); + if (result.isNotEmpty) { + result[fatherTag] = fatherData[fatherTag]!; + } + return result; + } + + /// Returns the best translation of the category name. + String? getBestCategoryName(final TaxonomyCategory category) { + String? result; + if (category.name != null) { + result ??= category.name![language]; + result ??= category.name![_alternateLanguage]; + } + return result; + } + + /// Returns categories, locally cached is possible, or from BE. + Future> _getCategories( + final List tags, + ) async { + final List alreadyTags = []; + final List neededTags = []; + for (final String tag in tags) { + if (_unknown.contains(tag)) { + continue; + } + if (_cache.containsKey(tag)) { + alreadyTags.add(tag); + } else { + neededTags.add(tag); + } + } + final Map? partialResult; + if (neededTags.isEmpty) { + partialResult = null; + } else { + partialResult = await _downloadCategories(neededTags); + } + final Map result = {}; + if (partialResult != null) { + _cache.addAll(partialResult); + result.addAll(partialResult); + for (final String tag in neededTags) { + if (!partialResult.containsKey(tag)) { + _unknown.add(tag); + } + } + } + for (final String tag in alreadyTags) { + result[tag] = _cache[tag]!; + } + return result; + } + + // TODO(monsieurtanuki): add loading dialog + + /// Downloads categories from the BE. + Future?> _downloadCategories( + final List tags, + ) async => + OpenFoodAPIClient.getTaxonomyCategories( + TaxonomyCategoryQueryConfiguration( + tags: tags, + fields: _fields, + languages: _languages, + ), + ); +} diff --git a/packages/smooth_app/lib/pages/product/category_picker_page.dart b/packages/smooth_app/lib/pages/product/category_picker_page.dart new file mode 100644 index 00000000000..cc7c5118191 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/category_picker_page.dart @@ -0,0 +1,168 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/pages/product/category_cache.dart'; +import 'package:smooth_app/pages/product/common/product_refresher.dart'; + +/// Category picker page. +class CategoryPickerPage extends StatefulWidget { + CategoryPickerPage({ + required this.barcode, + required this.initialMap, + required this.initialTree, + required this.categoryCache, + }) { + initialTag = initialTree[initialTree.length - 1]; + initialFatherTag = initialTree[initialTree.length - 2]; + // TODO(monsieurtanuki): manage roots (that have no father) + } + + final String barcode; + final Map initialMap; + final List initialTree; + final CategoryCache categoryCache; + late final String initialFatherTag; + late final String initialTag; + + @override + State createState() => _CategoryPickerPageState(); +} + +class _CategoryPickerPageState extends State { + final Map _map = {}; + final List _tags = []; + String? _fatherTag; + TaxonomyCategory? _fatherCategory; + + @override + void initState() { + super.initState(); + _refresh(widget.initialMap, widget.initialFatherTag); + } + + @override + Widget build(BuildContext context) { + final LocalDatabase localDatabase = context.read(); + return Scaffold( + appBar: AppBar( + title: const Text('categories')), // TODO(monsieurtanuki): localize + body: ListView.builder( + itemBuilder: (final BuildContext context, final int index) { + final String tag = _tags[index]; + final TaxonomyCategory category = _map[tag]!; + final bool isInTree = widget.initialTree.contains(tag); + final bool selected = widget.initialTree.last == tag; + final bool isFather = tag == _fatherTag; + final bool hasFather = _fatherCategory!.parents?.isNotEmpty == true; + final Future Function()? mainAction; + if (isFather) { + mainAction = () async => _displaySiblingsAndFather(fatherTag: tag); + } else { + mainAction = () async => _select(tag, localDatabase); + } + return ListTile( + onTap: mainAction, + selected: isInTree, + title: Text( + widget.categoryCache.getBestCategoryName(category) ?? tag, + ), + trailing: isFather + ? null + : category.children == null + ? null + : IconButton( + icon: const Icon(CupertinoIcons.arrow_down_right), + onPressed: () async => _displaySiblingsAndFather( + fatherTag: tag, + ), + ), + leading: isFather + ? !hasFather + ? null + : IconButton( + icon: const Icon(CupertinoIcons.arrow_up_left), + onPressed: () async { + final String fatherTag = + _fatherCategory!.parents!.last; + final Map? map = + await widget.categoryCache + .getCategorySiblingsAndFather( + fatherTag: fatherTag, + ); + if (map == null) { + // TODO(monsieurtanuki): what shall we do? + return; + } + setState(() => _refresh(map, fatherTag)); + }, + ) + : selected + ? IconButton( + icon: const Icon(Icons.radio_button_checked), + onPressed: () {}, + ) + : IconButton( + icon: const Icon(Icons.radio_button_off), + onPressed: mainAction, + ), + ); + }, + itemCount: _tags.length, + ), + ); + } + + void _refresh(final Map map, final String father) { + final List tags = []; + tags.addAll(map.keys); + // TODO(monsieurtanuki): sort by category name? + _fatherTag = father; + _fatherCategory = map[father]; + tags.remove(father); // we don't need the father here. + tags.insert(0, father); + _tags.clear(); + _tags.addAll(tags); + _map.clear(); + _map.addAll(map); + } + + /// Goes up one level + Future _displaySiblingsAndFather({ + required final String fatherTag, + }) async { + final Map? map = + await widget.categoryCache.getCategorySiblingsAndFather( + fatherTag: fatherTag, + ); + if (map == null) { + // TODO(monsieurtanuki): what shall we do? + return; + } + setState(() => _refresh(map, fatherTag)); + } + + Future _select( + final String tag, + final LocalDatabase localDatabase, + ) async { + if (tag == widget.initialTag) { + Navigator.of(context).pop(); + return; + } + final Product product = Product(barcode: widget.barcode); + product.categoriesTags = [ + tag + ]; // TODO(monsieurtanuki): is the last leaf good enough or should we go down to the roots? + + final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( + context: context, + localDatabase: localDatabase, + product: product, + ); + if (savedAndRefreshed) { + Navigator.of(context).pop(tag); + } + } +} diff --git a/packages/smooth_app/lib/pages/product/common/product_refresher.dart b/packages/smooth_app/lib/pages/product/common/product_refresher.dart new file mode 100644 index 00000000000..e93afc687c6 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/common/product_refresher.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/database/dao_product.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/generic_lib/buttons/smooth_action_button.dart'; +import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/widgets/loading_dialog.dart'; + +/// Refreshes a product on the BE then on the local database. +class ProductRefresher { + Future saveAndRefresh({ + required final BuildContext context, + required final LocalDatabase localDatabase, + required final Product product, + }) async { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + final bool? savedAndRefreshed = await LoadingDialog.run( + future: _saveAndRefresh(product, localDatabase), + context: context, + title: appLocalizations.nutrition_page_update_running, + ); + if (savedAndRefreshed == null) { + // probably the end user stopped the dialog + return false; + } + if (!savedAndRefreshed) { + await LoadingDialog.error(context: context); + return false; + } + await showDialog( + context: context, + builder: (BuildContext context) => SmoothAlertDialog( + body: Text(appLocalizations.nutrition_page_update_done), + actions: [ + SmoothActionButton( + text: appLocalizations.okay, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return true; + } + + /// Saves a product on the BE and refreshes the local database + Future _saveAndRefresh( + final Product inputProduct, + final LocalDatabase localDatabase, + ) async { + try { + final Status status = await OpenFoodAPIClient.saveProduct( + ProductQuery.getUser(), + inputProduct, + ); + if (status.error != null) { + return false; + } + final ProductQueryConfiguration configuration = ProductQueryConfiguration( + inputProduct.barcode!, + fields: ProductQuery.fields, + language: ProductQuery.getLanguage(), + country: ProductQuery.getCountry(), + ); + final ProductResult result = + await OpenFoodAPIClient.getProduct(configuration); + if (result.product != null) { + await DaoProduct(localDatabase).put(result.product!); + return true; + } + } catch (e) { + // + } + return false; + } +} 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 73c472cc1ac..f0b243a8b68 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -12,9 +12,12 @@ import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/knowledge_panels_query.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/database/product_query.dart'; import 'package:smooth_app/helpers/launch_url_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; +import 'package:smooth_app/pages/product/category_cache.dart'; +import 'package:smooth_app/pages/product/category_picker_page.dart'; import 'package:smooth_app/pages/product/common/product_dialog_helper.dart'; import 'package:smooth_app/pages/product/knowledge_panel_product_cards.dart'; import 'package:smooth_app/pages/product/summary_card.dart'; @@ -155,7 +158,44 @@ class _ProductPageState extends State { UserPreferencesDevMode.userPreferencesFlagAdditionalButton) ?? false) ElevatedButton( - onPressed: () {}, + onPressed: () async { + if (_product.categoriesTags == null) { + // TODO(monsieurtanuki): that's another story: how to set an initial category? + return; + } + if (_product.categoriesTags!.length < 2) { + // TODO(monsieurtanuki): no father, we need to do something with roots + return; + } + final String currentTag = + _product.categoriesTags![_product.categoriesTags!.length - 1]; + final String fatherTag = + _product.categoriesTags![_product.categoriesTags!.length - 2]; + final CategoryCache categoryCache = + CategoryCache(ProductQuery.getLanguage()!); + final Map? siblingsData = + await categoryCache.getCategorySiblingsAndFather( + fatherTag: fatherTag, + ); + if (siblingsData == null) { + // TODO(monsieurtanuki): what shall we do? + return; + } + final String? newTag = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => CategoryPickerPage( + barcode: _product.barcode!, + initialMap: siblingsData, + initialTree: _product.categoriesTags!, + categoryCache: categoryCache, + ), + ), + ); + if (newTag != null && newTag != currentTag) { + setState(() {}); + } + }, child: const Text('Additional Button'), ), ]); diff --git a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart index 21c9c9904dd..d8cb74145f3 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -9,13 +9,10 @@ import 'package:openfoodfacts/model/OrderedNutrients.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/utils/UnitHelper.dart'; import 'package:provider/provider.dart'; -import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/product_query.dart'; -import 'package:smooth_app/generic_lib/buttons/smooth_action_button.dart'; -import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; -import 'package:smooth_app/widgets/loading_dialog.dart'; +import 'package:smooth_app/pages/product/common/product_refresher.dart'; /// Actual nutrition page, with data already loaded. class NutritionPageLoaded extends StatefulWidget { @@ -491,61 +488,13 @@ class _NutritionPageLoadedState extends State { servingSize: servingSize, ); - final bool? savedAndRefreshed = await LoadingDialog.run( - future: _saveAndRefresh(inputProduct, localDatabase), + final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( context: context, - title: AppLocalizations.of(context)!.nutrition_page_update_running, + localDatabase: localDatabase, + product: inputProduct, ); - if (savedAndRefreshed == null) { - // probably the end user stopped the dialog - return; - } - if (!savedAndRefreshed) { - await LoadingDialog.error(context: context); - return; - } - await showDialog( - context: context, - builder: (BuildContext context) => SmoothAlertDialog( - body: Text(AppLocalizations.of(context)!.nutrition_page_update_done), - actions: [ - SmoothActionButton( - text: AppLocalizations.of(context)!.okay, - onPressed: () => Navigator.of(context).pop()), - ], - ), - ); - Navigator.of(context).pop(true); - } - - /// Saves a product on the BE and refreshes the local database - Future _saveAndRefresh( - final Product inputProduct, - final LocalDatabase localDatabase, - ) async { - try { - final Status status = await OpenFoodAPIClient.saveProduct( - ProductQuery.getUser(), - inputProduct, - ); - if (status.error != null) { - return false; - } - final ProductQueryConfiguration configuration = ProductQueryConfiguration( - inputProduct.barcode!, - fields: ProductQuery.fields, - language: ProductQuery.getLanguage(), - country: ProductQuery.getCountry(), - ); - final ProductResult result = - await OpenFoodAPIClient.getProduct(configuration); - if (result.product != null) { - await DaoProduct(context.read()).put(result.product!); - return true; - } - } catch (e) { - // + if (savedAndRefreshed) { + Navigator.of(context).pop(true); } - return false; } } From d31b7ffdbd3276037b8bc1c8d65ea788a40a40ee Mon Sep 17 00:00:00 2001 From: Pierre Slamich Date: Fri, 18 Feb 2022 14:36:45 +0100 Subject: [PATCH 2/3] Update label.yml --- .github/workflows/label.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 458edb447c0..7593228e6ce 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -11,7 +11,8 @@ on: [pull_request] jobs: label: runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'openfoodfacts' }} + if: github.event.pull_request.head.repo.full_name == github.repository + #if: ${{ github.repository_owner == 'openfoodfacts' }} permissions: contents: read pull-requests: write From 54e3218efb415a25acd171ce35570f55228a07c7 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 18 Feb 2022 20:19:15 +0100 Subject: [PATCH 3/3] feat: #944 - merge fix --- .../smooth_app/lib/pages/product/nutrition_page_loaded.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart index f501b02d8f6..fba0be8893a 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -327,11 +327,7 @@ class _NutritionPageLoadedState extends State { _nutritionContainer.setControllerText(key, controller.text); } // minimal product: we only want to save the nutrients - final Product inputProduct = Product( - barcode: widget.product.barcode, - nutriments: nutriments, - servingSize: servingSize, - ); + final Product inputProduct = _nutritionContainer.getProduct(); final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( context: context,