-
-
Notifications
You must be signed in to change notification settings - Fork 287
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: #944 - added a category picker page to the temporary product bu…
…tton (#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`.
- Loading branch information
1 parent
f5819cc
commit 08ead0d
Showing
5 changed files
with
411 additions
and
59 deletions.
There are no files selected for viewing
117 changes: 117 additions & 0 deletions
117
packages/smooth_app/lib/pages/product/category_cache.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<OpenFoodFactsLanguage> get _languages => <OpenFoodFactsLanguage>[ | ||
language, | ||
_alternateLanguage, | ||
]; | ||
|
||
/// Where we keep everything we've already downloaded. | ||
final Map<String, TaxonomyCategory> _cache = <String, TaxonomyCategory>{}; | ||
|
||
/// Where we keep the tags we've tried to download but found nothing. | ||
/// | ||
/// e.g. 'ru:хлеб-украинский-новый', child of 'en:breads' | ||
final Set<String> _unknown = <String>{}; | ||
|
||
/// Alternate language, where it's relatively safe to find translations. | ||
static const OpenFoodFactsLanguage _alternateLanguage = | ||
OpenFoodFactsLanguage.ENGLISH; | ||
|
||
/// Fields we retrieve. | ||
static const List<TaxonomyCategoryField> _fields = <TaxonomyCategoryField>[ | ||
TaxonomyCategoryField.NAME, | ||
TaxonomyCategoryField.CHILDREN, | ||
TaxonomyCategoryField.PARENTS, | ||
]; | ||
|
||
/// Returns the siblings AND the father (for tree climbing reasons). | ||
Future<Map<String, TaxonomyCategory>?> getCategorySiblingsAndFather({ | ||
required final String fatherTag, | ||
}) async { | ||
final Map<String, TaxonomyCategory> fatherData = | ||
await _getCategories(<String>[fatherTag]); | ||
if (fatherData.isEmpty) { | ||
return null; | ||
} | ||
final List<String>? siblingTags = fatherData[fatherTag]?.children; | ||
if (siblingTags == null || siblingTags.isEmpty) { | ||
return fatherData; | ||
} | ||
final Map<String, TaxonomyCategory> 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<Map<String, TaxonomyCategory>> _getCategories( | ||
final List<String> tags, | ||
) async { | ||
final List<String> alreadyTags = <String>[]; | ||
final List<String> neededTags = <String>[]; | ||
for (final String tag in tags) { | ||
if (_unknown.contains(tag)) { | ||
continue; | ||
} | ||
if (_cache.containsKey(tag)) { | ||
alreadyTags.add(tag); | ||
} else { | ||
neededTags.add(tag); | ||
} | ||
} | ||
final Map<String, TaxonomyCategory>? partialResult; | ||
if (neededTags.isEmpty) { | ||
partialResult = null; | ||
} else { | ||
partialResult = await _downloadCategories(neededTags); | ||
} | ||
final Map<String, TaxonomyCategory> result = <String, TaxonomyCategory>{}; | ||
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<Map<String, TaxonomyCategory>?> _downloadCategories( | ||
final List<String> tags, | ||
) async => | ||
OpenFoodAPIClient.getTaxonomyCategories( | ||
TaxonomyCategoryQueryConfiguration( | ||
tags: tags, | ||
fields: _fields, | ||
languages: _languages, | ||
), | ||
); | ||
} |
168 changes: 168 additions & 0 deletions
168
packages/smooth_app/lib/pages/product/category_picker_page.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, TaxonomyCategory> initialMap; | ||
final List<String> initialTree; | ||
final CategoryCache categoryCache; | ||
late final String initialFatherTag; | ||
late final String initialTag; | ||
|
||
@override | ||
State<CategoryPickerPage> createState() => _CategoryPickerPageState(); | ||
} | ||
|
||
class _CategoryPickerPageState extends State<CategoryPickerPage> { | ||
final Map<String, TaxonomyCategory> _map = <String, TaxonomyCategory>{}; | ||
final List<String> _tags = <String>[]; | ||
String? _fatherTag; | ||
TaxonomyCategory? _fatherCategory; | ||
|
||
@override | ||
void initState() { | ||
super.initState(); | ||
_refresh(widget.initialMap, widget.initialFatherTag); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final LocalDatabase localDatabase = context.read<LocalDatabase>(); | ||
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<void> 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<String, TaxonomyCategory>? 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<String, TaxonomyCategory> map, final String father) { | ||
final List<String> tags = <String>[]; | ||
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<void> _displaySiblingsAndFather({ | ||
required final String fatherTag, | ||
}) async { | ||
final Map<String, TaxonomyCategory>? map = | ||
await widget.categoryCache.getCategorySiblingsAndFather( | ||
fatherTag: fatherTag, | ||
); | ||
if (map == null) { | ||
// TODO(monsieurtanuki): what shall we do? | ||
return; | ||
} | ||
setState(() => _refresh(map, fatherTag)); | ||
} | ||
|
||
Future<void> _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 = <String>[ | ||
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); | ||
} | ||
} | ||
} |
77 changes: 77 additions & 0 deletions
77
packages/smooth_app/lib/pages/product/common/product_refresher.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<bool> 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<bool>( | ||
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<void>( | ||
context: context, | ||
builder: (BuildContext context) => SmoothAlertDialog( | ||
body: Text(appLocalizations.nutrition_page_update_done), | ||
actions: <SmoothActionButton>[ | ||
SmoothActionButton( | ||
text: appLocalizations.okay, | ||
onPressed: () => Navigator.of(context).pop(), | ||
), | ||
], | ||
), | ||
); | ||
return true; | ||
} | ||
|
||
/// Saves a product on the BE and refreshes the local database | ||
Future<bool> _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; | ||
} | ||
} |
Oops, something went wrong.