Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: #944 - added a category picker page to the temporary product button #1148

Merged
merged 6 commits into from
Feb 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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