From 08ead0d2faaf9245c46d61e91e2cb295b31d7e8b Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Sun, 20 Feb 2022 12:14:57 +0100 Subject: [PATCH] feat: #944 - added a category picker page to the temporary product button (#1148) 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: * `label.yml` - Co-authored-by @teolemon * `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 | 66 +------ 5 files changed, 411 insertions(+), 59 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 a62519c546e..9833a9a0316 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -12,10 +12,13 @@ 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/analytics_helper.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'; @@ -159,7 +162,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 0c9d483c9ed..fba0be8893a 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -7,14 +7,11 @@ 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/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/nutrition_container.dart'; -import 'package:smooth_app/widgets/loading_dialog.dart'; /// Actual nutrition page, with data already loaded. class NutritionPageLoaded extends StatefulWidget { @@ -330,62 +327,15 @@ class _NutritionPageLoadedState extends State { _nutritionContainer.setControllerText(key, controller.text); } // minimal product: we only want to save the nutrients - final Product minimalProduct = _nutritionContainer.getProduct(); - final bool? savedAndRefreshed = await LoadingDialog.run( - future: _saveAndRefresh(minimalProduct, localDatabase), - context: context, - title: AppLocalizations.of(context)!.nutrition_page_update_running, - ); - if (savedAndRefreshed == null) { - // probably the end user stopped the dialog - return; - } - if (!savedAndRefreshed) { - await LoadingDialog.error(context: context); - return; - } - await showDialog( + final Product inputProduct = _nutritionContainer.getProduct(); + + final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( 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()), - ], - ), + localDatabase: localDatabase, + product: inputProduct, ); - Navigator.of(context).pop(true); - } - - /// Saves a product on the BE and refreshes the local database - Future _saveAndRefresh( - final Product minimalProduct, - final LocalDatabase localDatabase, - ) async { - try { - final Status status = await OpenFoodAPIClient.saveProduct( - ProductQuery.getUser(), - minimalProduct, - ); - if (status.error != null) { - return false; - } - final ProductQueryConfiguration configuration = ProductQueryConfiguration( - minimalProduct.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; } }