Skip to content

Commit

Permalink
feat: #944 - added a category picker page to the temporary product bu…
Browse files Browse the repository at this point in the history
…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
monsieurtanuki authored Feb 20, 2022
1 parent f5819cc commit 08ead0d
Show file tree
Hide file tree
Showing 5 changed files with 411 additions and 59 deletions.
117 changes: 117 additions & 0 deletions packages/smooth_app/lib/pages/product/category_cache.dart
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 packages/smooth_app/lib/pages/product/category_picker_page.dart
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);
}
}
}
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;
}
}
Loading

0 comments on commit 08ead0d

Please sign in to comment.