From e9ec9b325c391133f079972fe76014d557e82d1c Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Wed, 6 Apr 2022 20:12:04 +0200 Subject: [PATCH] feat: #1333 - now the back-end results are paged. (#1437) New files: * `paged_product_query.dart`: Paged product query (with pageSize and pageNumber). * `partial_product_list.dart`: List of Products out of partial results (e.g. paged results). Impacted files: * `category_product_query.dart`: now extending new class `PagedProductQuery`. * `dao_product_list.dart`: now storing "total size" of paged results; now including page size and page number in the primary key; now returning a bool for method `delete`. * `database_product_list_supplier.dart`: now loads paged results page after page. * `keywords_product_query.dart`: now extending new class `PagedProductQuery`. * `new_product_page.dart`: unrelated fix about back icon on Android/iOS. * `onboarding_flow_navigator.dart`: unrelated fix about back icon on Android/iOS. * `personalized_ranking_page.dart`: refactoring. * `Podfile.lock`: wtf * `product_list.dart`: removed now unused product list type (pnns); added fields page size, page number and total size for paged results; refactored. * `product_list_page.dart`: refactored. * `product_list_supplier.dart`: now using `PagedProductQuery` and `PartialProductList`. * `product_query_model.dart`: added a `clear` method to go back to top page; added method `loadNextPage` and `loadFromTop`. * `product_query_page.dart`: now displaying pages results with a "load next" button; unrelated fix about back icon on Android/iOS. * `product_query_page_helper.dart`: refactored. * `query_product_list_supplier.dart`: now using `PagedProductQuery`. * `scan_page_helper.dart`: refactored. * `search_page.dart`: refactored. * `summary_card.dart`: refactored. --- packages/smooth_app/ios/Podfile.lock | 12 +- .../database_product_list_supplier.dart | 34 ++- .../lib/data_models/partial_product_list.dart | 23 ++ .../lib/data_models/product_list.dart | 75 ++++--- .../data_models/product_list_supplier.dart | 26 ++- .../lib/data_models/product_query_model.dart | 67 ++++-- .../query_product_list_supplier.dart | 10 +- .../lib/database/category_product_query.dart | 41 ++-- .../lib/database/dao_product_list.dart | 46 +++- .../lib/database/keywords_product_query.dart | 36 ++-- .../lib/database/paged_product_query.dart | 37 ++++ .../onboarding/onboarding_flow_navigator.dart | 3 +- .../lib/pages/personalized_ranking_page.dart | 8 +- .../product/common/product_list_page.dart | 3 +- .../product/common/product_query_page.dart | 200 ++++++++++++------ .../common/product_query_page_helper.dart | 18 +- .../lib/pages/product/new_product_page.dart | 5 +- .../lib/pages/product/summary_card.dart | 3 +- .../lib/pages/scan/scan_page_helper.dart | 2 +- .../lib/pages/scan/search_page.dart | 2 +- 20 files changed, 424 insertions(+), 227 deletions(-) create mode 100644 packages/smooth_app/lib/data_models/partial_product_list.dart create mode 100644 packages/smooth_app/lib/database/paged_product_query.dart diff --git a/packages/smooth_app/ios/Podfile.lock b/packages/smooth_app/ios/Podfile.lock index e17824ecbba..e4bcc137952 100644 --- a/packages/smooth_app/ios/Podfile.lock +++ b/packages/smooth_app/ios/Podfile.lock @@ -39,6 +39,9 @@ PODS: - GoogleUtilitiesComponents (1.1.0): - GoogleUtilities/Logger - GTMSessionFetcher/Core (1.7.1) + - image_cropper (0.0.4): + - Flutter + - TOCropViewController (~> 2.6.1) - image_picker (0.0.1): - Flutter - iso_countries (0.0.1): @@ -91,6 +94,7 @@ PODS: - Sentry (~> 7.10.1) - shared_preferences_ios (0.0.1): - Flutter + - TOCropViewController (2.6.1) - url_launcher_ios (0.0.1): - Flutter @@ -100,6 +104,7 @@ DEPENDENCIES: - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - google_ml_barcode_scanner (from `.symlinks/plugins/google_ml_barcode_scanner/ios`) + - image_cropper (from `.symlinks/plugins/image_cropper/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) - iso_countries (from `.symlinks/plugins/iso_countries/ios`) - matomo_forever (from `.symlinks/plugins/matomo_forever/ios`) @@ -128,6 +133,7 @@ SPEC REPOS: - PromisesObjC - Protobuf - Sentry + - TOCropViewController EXTERNAL SOURCES: camera: @@ -140,6 +146,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" google_ml_barcode_scanner: :path: ".symlinks/plugins/google_ml_barcode_scanner/ios" + image_cropper: + :path: ".symlinks/plugins/image_cropper/ios" image_picker: :path: ".symlinks/plugins/image_picker/ios" iso_countries: @@ -173,6 +181,7 @@ SPEC CHECKSUMS: GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe GTMSessionFetcher: 4577a4cc914a5a07c40a8a0ad0acc22080418c2d + image_cropper: 60c2789d1f1a78c873235d4319ca0c34a69f2d98 image_picker: 541dcbb3b9cf32d87eacbd957845d8651d6c62c3 iso_countries: eb09d40f388e4c65e291e0bb36a701dfe7de6c74 matomo_forever: 7e5e5fd8f355f64979591282cad4e858fa4c9fae @@ -191,8 +200,9 @@ SPEC CHECKSUMS: Sentry: 7bf9bfe713692cf87812e55f0999260494ba7982 sentry_flutter: 77ccdac346608b8ce7e428e7284e7a3e4e7f4a02 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de PODFILE CHECKSUM: e1ffd3daa5042cd516081f94f61b857c0deb822d -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/packages/smooth_app/lib/data_models/database_product_list_supplier.dart b/packages/smooth_app/lib/data_models/database_product_list_supplier.dart index 2c3f2025786..39bcd73f392 100644 --- a/packages/smooth_app/lib/data_models/database_product_list_supplier.dart +++ b/packages/smooth_app/lib/data_models/database_product_list_supplier.dart @@ -3,22 +3,42 @@ import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/data_models/query_product_list_supplier.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; +/// Supplier of previous back-end results now stored in the local database. class DatabaseProductListSupplier extends ProductListSupplier { DatabaseProductListSupplier( - final ProductQuery productQuery, + final PagedProductQuery pagedProductQuery, final LocalDatabase localDatabase, final int timestamp, - ) : super(productQuery, localDatabase, timestamp: timestamp); + ) : super(pagedProductQuery, localDatabase, timestamp: timestamp); + /// Loads all results page after page. @override Future asyncLoad() async { try { - final ProductList loadedProductList = productQuery.getProductList(); - await DaoProductList(localDatabase).get(loadedProductList); - productList = loadedProductList; - return null; + // we start from page 1 + ProductList productList = productQuery.getProductList(); + bool first = true; + do { + // we try to get the locally saved data for the current page + await DaoProductList(localDatabase).get(productList); + if (productList.barcodes.isEmpty) { + // we found nothing + if (first) { + // we save an empty list + partialProductList.add(productList); + } + // that's it, we've just loaded all the non-empty pages we could + return null; + } + // we found something: let's add it to the partial product list + partialProductList.add(productList); + // and try again with the next page + productQuery.toNextPage(); + productList = productQuery.getProductList(); + first = false; + } while (true); } catch (e) { return e.toString(); } diff --git a/packages/smooth_app/lib/data_models/partial_product_list.dart b/packages/smooth_app/lib/data_models/partial_product_list.dart new file mode 100644 index 00000000000..391c95a9d89 --- /dev/null +++ b/packages/smooth_app/lib/data_models/partial_product_list.dart @@ -0,0 +1,23 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/data_models/product_list.dart'; + +/// List of [Product]s out of partial results (e.g. paged results). +class PartialProductList { + final List _products = []; + int _totalSize = 0; + + /// Total size of the list from which this partial list is taken. + int get totalSize => _totalSize; + + List getProducts() => _products; + + void add(final ProductList productList) { + _products.addAll(productList.getList()); + _totalSize = productList.totalSize; + } + + void clear() { + _products.clear(); + _totalSize = 0; + } +} diff --git a/packages/smooth_app/lib/data_models/product_list.dart b/packages/smooth_app/lib/data_models/product_list.dart index 17e8b0265d2..e8fd81825a1 100644 --- a/packages/smooth_app/lib/data_models/product_list.dart +++ b/packages/smooth_app/lib/data_models/product_list.dart @@ -1,10 +1,6 @@ import 'package:openfoodfacts/model/Product.dart'; -import 'package:openfoodfacts/utils/PnnsGroups.dart'; enum ProductListType { - /// API search for [PnnsGroup2Filter] related food groups - HTTP_SEARCH_GROUP, - /// API search by [SearchTerms] keywords HTTP_SEARCH_KEYWORDS, @@ -20,7 +16,6 @@ enum ProductListType { extension ProductListTypeExtension on ProductListType { static const Map _keys = { - ProductListType.HTTP_SEARCH_GROUP: 'http/search/group', ProductListType.HTTP_SEARCH_KEYWORDS: 'http/search/keywords', ProductListType.HTTP_SEARCH_CATEGORY: 'http/search/category', ProductListType.SCAN_SESSION: 'scan_session', @@ -31,24 +26,33 @@ extension ProductListTypeExtension on ProductListType { } class ProductList { - ProductList._({required this.listType, this.parameters = ''}); - - ProductList.keywordSearch(final String keywords) - : this._( + ProductList._({ + required this.listType, + this.parameters = '', + this.pageSize = 0, + this.pageNumber = 0, + }); + + ProductList.keywordSearch( + final String keywords, { + required int pageSize, + required int pageNumber, + }) : this._( listType: ProductListType.HTTP_SEARCH_KEYWORDS, parameters: keywords, + pageSize: pageSize, + pageNumber: pageNumber, ); - ProductList.categorySearch(final String category) - : this._( + ProductList.categorySearch( + final String category, { + required int pageSize, + required int pageNumber, + }) : this._( listType: ProductListType.HTTP_SEARCH_CATEGORY, parameters: category, - ); - - ProductList.groupSearch(final PnnsGroup2 group) - : this._( - listType: ProductListType.HTTP_SEARCH_GROUP, - parameters: group.id, + pageSize: pageSize, + pageNumber: pageNumber, ); ProductList.history() : this._(listType: ProductListType.HISTORY); @@ -58,23 +62,17 @@ class ProductList { final ProductListType listType; final String parameters; - final List _barcodes = []; - final Map _products = {}; - - /// API search for [PnnsGroup2Filter] related food groups - static const String LIST_TYPE_HTTP_SEARCH_GROUP = 'http/search/group'; - - /// API search by [SearchTerms] keywords - static const String LIST_TYPE_HTTP_SEARCH_KEYWORDS = 'http/search/keywords'; + /// Page size at query time. + final int? pageSize; - /// API search for [CategoryProductQuery] category - static const String LIST_TYPE_HTTP_SEARCH_CATEGORY = 'http/search/category'; + /// Page number at query time. + final int? pageNumber; - /// Current scan session; can be easily cleared by the end-user - static const String LIST_TYPE_SCAN_SESSION = 'scan_session'; + /// "Total size" returned by the query. + int totalSize = 0; - /// History of products seen by the end-user - static const String LIST_TYPE_HISTORY = 'history'; + final List _barcodes = []; + final Map _products = {}; List get barcodes => _barcodes; @@ -82,9 +80,6 @@ class ProductList { Product getProduct(final String barcode) => _products[barcode]!; - bool isSameAs(final ProductList other) => - listType == other.listType && parameters == other.parameters; - void refresh(final Product product) { final String? barcode = product.barcode; if (barcode == null) { @@ -144,7 +139,6 @@ class ProductList { bool _isReversed() { switch (listType) { - case ProductListType.HTTP_SEARCH_GROUP: case ProductListType.HTTP_SEARCH_KEYWORDS: case ProductListType.HTTP_SEARCH_CATEGORY: return false; @@ -153,4 +147,15 @@ class ProductList { return true; } } + + String getParametersKey() { + switch (listType) { + case ProductListType.SCAN_SESSION: + case ProductListType.HISTORY: + return parameters; + case ProductListType.HTTP_SEARCH_KEYWORDS: + case ProductListType.HTTP_SEARCH_CATEGORY: + return '$parameters,$pageSize,$pageNumber'; + } + } } diff --git a/packages/smooth_app/lib/data_models/product_list_supplier.dart b/packages/smooth_app/lib/data_models/product_list_supplier.dart index 8faf83fab39..9bbdcbcc47b 100644 --- a/packages/smooth_app/lib/data_models/product_list_supplier.dart +++ b/packages/smooth_app/lib/data_models/product_list_supplier.dart @@ -1,10 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:smooth_app/data_models/database_product_list_supplier.dart'; +import 'package:smooth_app/data_models/partial_product_list.dart'; import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/query_product_list_supplier.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; /// Asynchronously loads a [ProductList] with products abstract class ProductListSupplier { @@ -14,23 +14,33 @@ abstract class ProductListSupplier { this.timestamp, }); - final ProductQuery productQuery; + final PagedProductQuery productQuery; final LocalDatabase localDatabase; final int? timestamp; - @protected - late ProductList productList; + final PartialProductList partialProductList = PartialProductList(); /// Returns null if OK, or the message error Future asyncLoad(); - ProductList getProductList() => productList; - /// Returns a helper supplier in order to refresh the data ProductListSupplier? getRefreshSupplier(); + /// Clears the database and restarts from top page. + Future clear() async { + final DaoProductList daoProductList = DaoProductList(localDatabase); + productQuery.toTopPage(); + while (await daoProductList.delete(productQuery.getProductList())) { + productQuery.toNextPage(); + } + + productQuery.toTopPage(); + + partialProductList.clear(); + } + /// Returns the fastest data supplier: database if possible, or server query static Future getBestSupplier( - final ProductQuery productQuery, + final PagedProductQuery productQuery, final LocalDatabase localDatabase, ) async { final int? timestamp = await DaoProductList(localDatabase).getTimestamp( diff --git a/packages/smooth_app/lib/data_models/product_query_model.dart b/packages/smooth_app/lib/data_models/product_query_model.dart index 4e2c7658132..362f506c642 100644 --- a/packages/smooth_app/lib/data_models/product_query_model.dart +++ b/packages/smooth_app/lib/data_models/product_query_model.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:openfoodfacts/model/Product.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/database/product_query.dart'; @@ -14,20 +13,23 @@ enum LoadingStatus { } class ProductQueryModel with ChangeNotifier { - ProductQueryModel(this.supplier) { + ProductQueryModel(this._supplier) { + _clear(); _asyncLoad(); } - final ProductListSupplier supplier; + ProductListSupplier _supplier; + + ProductListSupplier get supplier => _supplier; static const String _CATEGORY_ALL = 'all'; - String currentCategory = _CATEGORY_ALL; + late String currentCategory; - LoadingStatus _loadingStatus = LoadingStatus.LOADING; + late LoadingStatus _loadingStatus; String? _loadingError; - List? _products; + final List _products = []; List? displayProducts; - bool isNotEmpty() => _products != null && _products!.isNotEmpty; + bool isNotEmpty() => _products.isNotEmpty; /// [Map] final Map categories = {}; @@ -41,15 +43,55 @@ class ProductQueryModel with ChangeNotifier { String? get loadingError => _loadingError; LoadingStatus get loadingStatus => _loadingStatus; - Future _asyncLoad() async { + void _clear() { + currentCategory = _CATEGORY_ALL; + _loadingStatus = LoadingStatus.LOADING; + _loadingError = null; + _products.clear(); + displayProducts = null; + categories.clear(); + _categoriesCounter.clear(); + sortedCategories.clear(); + } + + Future _asyncLoad() async { _loadingError = await supplier.asyncLoad(); if (_loadingError != null) { _loadingStatus = LoadingStatus.ERROR; } else { _loadingStatus = LoadingStatus.LOADED; - _products = supplier.getProductList().getList(); + _products.addAll(supplier.partialProductList.getProducts()); } notifyListeners(); + return _loadingStatus == LoadingStatus.LOADED; + } + + Future loadNextPage() async { + final ProductListSupplier? refreshSupplier = supplier.getRefreshSupplier(); + if (refreshSupplier != null) { + // in that case, we were on a database supplier, on an empty page + _supplier = refreshSupplier; + } else { + // in that case, we were on a back-end supplier, on a loaded page + supplier.productQuery.toNextPage(); + } + return _asyncLoad(); + } + + // TODO(monsieurtanuki): don't clear everything if it fails? + Future loadFromTop() async { + _clear(); + + final ProductListSupplier? refreshSupplier = supplier.getRefreshSupplier(); + if (refreshSupplier != null) { + // in that case, we were on a database supplier + _supplier = refreshSupplier; + } else { + // in that case, we were already on a back-end supplier + } + await supplier.clear(); + supplier.productQuery.toTopPage(); + return _asyncLoad(); } /// Sorts the products by category. @@ -61,14 +103,11 @@ class ProductQueryModel with ChangeNotifier { } _loadingStatus = LoadingStatus.POST_LOAD_STARTED; - final ProductList productList = supplier.getProductList(); - _products = productList.getList(); - displayProducts = _products; categories[_CATEGORY_ALL] = translationForAll; - for (final Product product in _products!) { + for (final Product product in _products) { if (product.categoriesTagsInLanguages != null) { final List? translatedCategories = product.categoriesTagsInLanguages![ProductQuery.getLanguage()]; @@ -113,7 +152,7 @@ class ProductQueryModel with ChangeNotifier { if (category == _CATEGORY_ALL) { displayProducts = _products; } else { - displayProducts = _products! + displayProducts = _products .where((Product product) => product.categoriesTagsInLanguages?[ProductQuery.getLanguage()] ?.contains(category) ?? diff --git a/packages/smooth_app/lib/data_models/query_product_list_supplier.dart b/packages/smooth_app/lib/data_models/query_product_list_supplier.dart index 1fa6886d516..b6284fc28cc 100644 --- a/packages/smooth_app/lib/data_models/query_product_list_supplier.dart +++ b/packages/smooth_app/lib/data_models/query_product_list_supplier.dart @@ -1,14 +1,15 @@ import 'package:openfoodfacts/model/SearchResult.dart'; +import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; /// [ProductListSupplier] with a server query flavor class QueryProductListSupplier extends ProductListSupplier { QueryProductListSupplier( - final ProductQuery productQuery, + final PagedProductQuery productQuery, final LocalDatabase localDatabase, ) : super(productQuery, localDatabase); @@ -16,9 +17,12 @@ class QueryProductListSupplier extends ProductListSupplier { Future asyncLoad() async { try { final SearchResult searchResult = await productQuery.getSearchResult(); - productList = productQuery.getProductList(); + final ProductList productList = productQuery.getProductList(); + partialProductList.clear(); if (searchResult.products != null) { productList.setAll(searchResult.products!); + productList.totalSize = searchResult.count!; + partialProductList.add(productList); await DaoProduct(localDatabase).putAll(searchResult.products!); } await DaoProductList(localDatabase).put(productList); diff --git a/packages/smooth_app/lib/database/category_product_query.dart b/packages/smooth_app/lib/database/category_product_query.dart index c643dc8cd53..0fd547afd15 100644 --- a/packages/smooth_app/lib/database/category_product_query.dart +++ b/packages/smooth_app/lib/database/category_product_query.dart @@ -1,43 +1,30 @@ -import 'dart:async'; - import 'package:openfoodfacts/model/parameter/TagFilter.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/data_models/product_list.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; /// Back-end query about a category. -class CategoryProductQuery implements ProductQuery { - CategoryProductQuery({ - required this.categoryTag, - required this.size, - }); +class CategoryProductQuery extends PagedProductQuery { + CategoryProductQuery(this.categoryTag); // e.g. 'en:unsweetened-natural-soy-milks' final String categoryTag; - final int size; @override - Future getSearchResult() async => - OpenFoodAPIClient.searchProducts( - ProductQuery.getUser(), - ProductSearchQueryConfiguration( - fields: ProductQuery.fields, - parametersList: [ - PageSize(size: size), - TagFilter.fromType( - tagFilterType: TagFilterType.CATEGORIES, - contains: true, - tagName: categoryTag, - ), - ], - language: ProductQuery.getLanguage(), - country: ProductQuery.getCountry(), - ), + Parameter getParameter() => TagFilter.fromType( + tagFilterType: TagFilterType.CATEGORIES, + contains: true, + tagName: categoryTag, ); @override - ProductList getProductList() => ProductList.categorySearch(categoryTag); + ProductList getProductList() => ProductList.categorySearch( + categoryTag, + pageSize: pageSize, + pageNumber: pageNumber, + ); @override - String toString() => 'CategoryProductQuery("$categoryTag", $size)'; + String toString() => + 'CategoryProductQuery("$categoryTag", $pageSize, $pageNumber)'; } diff --git a/packages/smooth_app/lib/database/dao_product_list.dart b/packages/smooth_app/lib/database/dao_product_list.dart index e9abd5d891b..89e4ae7fe81 100644 --- a/packages/smooth_app/lib/database/dao_product_list.dart +++ b/packages/smooth_app/lib/database/dao_product_list.dart @@ -9,15 +9,30 @@ import 'package:smooth_app/database/abstract_dao.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/local_database.dart'; +/// "Total size" fake value for lists that are not partial/paged. +const int _uselessTotalSizeValue = 0; + /// An immutable barcode list; e.g. my search yesterday about "Nutella" class _BarcodeList { - _BarcodeList(this.timestamp, this.barcodes); + _BarcodeList( + this.timestamp, + this.barcodes, + this.totalSize, + ); _BarcodeList.now(final List barcodes) - : this(LocalDatabase.nowInMillis(), barcodes); + : this( + LocalDatabase.nowInMillis(), + barcodes, + _uselessTotalSizeValue, + ); _BarcodeList.fromProductList(final ProductList productList) - : this(LocalDatabase.nowInMillis(), productList.barcodes); + : this( + LocalDatabase.nowInMillis(), + productList.barcodes, + productList.totalSize, + ); /// Freshness indicator: last time the list was updated. /// @@ -25,6 +40,9 @@ class _BarcodeList { /// Can be used to decide if the data is recent enough or deprecated. final int timestamp; final List barcodes; + + /// Total size of server query results (or 0). + final int totalSize; } /// Hive type adapter for [_BarcodeList] @@ -36,13 +54,20 @@ class _BarcodeListAdapter extends TypeAdapter<_BarcodeList> { _BarcodeList read(BinaryReader reader) { final int timestamp = reader.readInt(); final List barcodes = reader.readStringList(); - return _BarcodeList(timestamp, barcodes); + late final int totalSize; + try { + totalSize = reader.readInt(); + } catch (e) { + totalSize = _uselessTotalSizeValue; + } + return _BarcodeList(timestamp, barcodes, totalSize); } @override void write(BinaryWriter writer, _BarcodeList obj) { writer.writeInt(obj.timestamp); writer.writeStringList(obj.barcodes); + writer.writeInt(obj.totalSize); } } @@ -72,7 +97,7 @@ class DaoProductList extends AbstractDao { // that we'll be under the 255 character length. String _getKey(final ProductList productList) => '${productList.listType.key}' '::' - '${base64.encode(utf8.encode(productList.parameters))}'; + '${base64.encode(utf8.encode(productList.getParametersKey()))}'; Future _put(final String key, final _BarcodeList barcodeList) async => _getBox().put(key, barcodeList); @@ -80,13 +105,22 @@ class DaoProductList extends AbstractDao { Future put(final ProductList productList) async => _put(_getKey(productList), _BarcodeList.fromProductList(productList)); - Future delete(final String key) async => _getBox().delete(key); + Future delete(final ProductList productList) async { + final Box<_BarcodeList> box = _getBox(); + final String key = _getKey(productList); + if (!box.containsKey(key)) { + return false; + } + await box.delete(key); + return true; + } /// Loads the barcodes AND all the products. Future get(final ProductList productList) async { final _BarcodeList? list = await _get(productList); final List barcodes = []; final Map products = {}; + productList.totalSize = list?.totalSize ?? 0; if (list == null || list.barcodes.isEmpty) { productList.set(barcodes, products); return; diff --git a/packages/smooth_app/lib/database/keywords_product_query.dart b/packages/smooth_app/lib/database/keywords_product_query.dart index 6dd6e82dfbe..96f841d9167 100644 --- a/packages/smooth_app/lib/database/keywords_product_query.dart +++ b/packages/smooth_app/lib/database/keywords_product_query.dart @@ -1,37 +1,25 @@ -import 'dart:async'; - import 'package:openfoodfacts/model/parameter/SearchTerms.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/data_models/product_list.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; -class KeywordsProductQuery implements ProductQuery { - KeywordsProductQuery({ - required this.keywords, - required this.size, - }); +/// Back-end query around user-entered keywords. +class KeywordsProductQuery extends PagedProductQuery { + KeywordsProductQuery(this.keywords); final String keywords; - final int size; @override - Future getSearchResult() async => - OpenFoodAPIClient.searchProducts( - ProductQuery.getUser(), - ProductSearchQueryConfiguration( - fields: ProductQuery.fields, - parametersList: [ - PageSize(size: size), - SearchTerms(terms: [keywords]), - ], - language: ProductQuery.getLanguage(), - country: ProductQuery.getCountry(), - ), - ); + Parameter getParameter() => SearchTerms(terms: [keywords]); @override - ProductList getProductList() => ProductList.keywordSearch(keywords); + ProductList getProductList() => ProductList.keywordSearch( + keywords, + pageSize: pageSize, + pageNumber: pageNumber, + ); @override - String toString() => 'KeywordsProductQuery("$keywords", $size)'; + String toString() => + 'KeywordsProductQuery("$keywords", $pageSize, $pageNumber)'; } diff --git a/packages/smooth_app/lib/database/paged_product_query.dart b/packages/smooth_app/lib/database/paged_product_query.dart new file mode 100644 index 00000000000..413437b6438 --- /dev/null +++ b/packages/smooth_app/lib/database/paged_product_query.dart @@ -0,0 +1,37 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/database/product_query.dart'; + +/// Paged product query (with [pageSize] and [pageNumber]). +abstract class PagedProductQuery implements ProductQuery { + final int pageSize = _typicalPageSize; + + /// Likely to change: to next page, and back to top. + int _pageNumber = _startPageNumber; + + int get pageNumber => _pageNumber; + + static const int _typicalPageSize = 25; + static const int _startPageNumber = 1; + + void toNextPage() => _pageNumber++; + + void toTopPage() => _pageNumber = _startPageNumber; + + Parameter getParameter(); + + @override + Future getSearchResult() async => + OpenFoodAPIClient.searchProducts( + ProductQuery.getUser(), + ProductSearchQueryConfiguration( + fields: ProductQuery.fields, + parametersList: [ + PageSize(size: pageSize), + Page(page: _pageNumber), + getParameter(), + ], + language: ProductQuery.getLanguage(), + country: ProductQuery.getCountry(), + ), + ); +} diff --git a/packages/smooth_app/lib/pages/onboarding/onboarding_flow_navigator.dart b/packages/smooth_app/lib/pages/onboarding/onboarding_flow_navigator.dart index 71f60572ba2..dc606ab091e 100644 --- a/packages/smooth_app/lib/pages/onboarding/onboarding_flow_navigator.dart +++ b/packages/smooth_app/lib/pages/onboarding/onboarding_flow_navigator.dart @@ -9,6 +9,7 @@ import 'package:smooth_app/pages/onboarding/sample_health_card_page.dart'; import 'package:smooth_app/pages/onboarding/scan_example.dart'; import 'package:smooth_app/pages/onboarding/welcome_page.dart'; import 'package:smooth_app/pages/page_manager.dart'; +import 'package:smooth_app/themes/constant_icons.dart'; enum OnboardingPage { NOT_STARTED, @@ -122,7 +123,7 @@ class OnboardingFlowNavigator { body: widget, appBar: AppBar( leading: IconButton( - icon: const Icon(Icons.arrow_back), + icon: Icon(ConstantIcons.instance.getBackIcon()), onPressed: () => navigateToPage(context, _getPrevPage(currentPage)), ), diff --git a/packages/smooth_app/lib/pages/personalized_ranking_page.dart b/packages/smooth_app/lib/pages/personalized_ranking_page.dart index 0d35c0f5aa0..17031723e92 100644 --- a/packages/smooth_app/lib/pages/personalized_ranking_page.dart +++ b/packages/smooth_app/lib/pages/personalized_ranking_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_found.dart'; -import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; import 'package:smooth_app/data_models/smooth_it_model.dart'; import 'package:smooth_app/data_models/user_preferences.dart'; @@ -14,12 +13,7 @@ import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/smooth_matched_product.dart'; class PersonalizedRankingPage extends StatefulWidget { - PersonalizedRankingPage({ - required final ProductList productList, - required this.title, - }) : products = productList.getList(); - - const PersonalizedRankingPage.fromItems({ + const PersonalizedRankingPage({ required this.products, required this.title, }); diff --git a/packages/smooth_app/lib/pages/product/common/product_list_page.dart b/packages/smooth_app/lib/pages/product/common/product_list_page.dart index cc13d4a6a07..18f7ca5cdb9 100644 --- a/packages/smooth_app/lib/pages/product/common/product_list_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_list_page.dart @@ -49,7 +49,6 @@ class _ProductListPageState extends State { break; case ProductListType.HTTP_SEARCH_CATEGORY: case ProductListType.HTTP_SEARCH_KEYWORDS: - case ProductListType.HTTP_SEARCH_GROUP: dismissible = false; } return Scaffold( @@ -80,7 +79,7 @@ class _ProductListPageState extends State { context, MaterialPageRoute( builder: (BuildContext context) => - PersonalizedRankingPage.fromItems( + PersonalizedRankingPage( products: list, title: appLocalizations.product_list_your_ranking, ), diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page.dart b/packages/smooth_app/lib/pages/product/common/product_query_page.dart index 0e8586cf7e2..1742d319b81 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page.dart @@ -1,13 +1,16 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_found.dart'; import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/data_models/product_query_model.dart'; import 'package:smooth_app/generic_lib/animations/smooth_reveal_animation.dart'; +import 'package:smooth_app/generic_lib/loading_dialog.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/pages/personalized_ranking_page.dart'; import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; @@ -152,7 +155,7 @@ class _ProductQueryPageState extends State { title: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - getBackArrow(context, widget.mainColor), + _getBackArrow(context, widget.mainColor), ]), flexibleSpace: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { @@ -191,7 +194,7 @@ class _ProductQueryPageState extends State { context, MaterialPageRoute( builder: (BuildContext context) => PersonalizedRankingPage( - productList: _model.supplier.getProductList(), + products: _model.displayProducts!, title: widget.name, ), ), @@ -239,81 +242,133 @@ class _ProductQueryPageState extends State { elevation: 0, automaticallyImplyLeading: false, title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - getBackArrow(context, widget.mainColor), - Padding( - padding: const EdgeInsets.only(top: 24.0), - child: TextButton.icon( - icon: Icon( - Icons.filter_list, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _getBackArrow(context, widget.mainColor), + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: TextButton.icon( + icon: Icon( + Icons.filter_list, + color: widget.mainColor, + ), + label: Text(AppLocalizations.of(context)!.filter, + style: themeData.textTheme.subtitle1! + .copyWith(color: widget.mainColor)), + style: TextButton.styleFrom( + primary: widget.mainColor, + textStyle: TextStyle( color: widget.mainColor, ), - label: Text( - AppLocalizations.of(context)!.filter, - style: themeData.textTheme.subtitle1! - .copyWith(color: widget.mainColor)), - style: TextButton.styleFrom( - primary: widget.mainColor, - textStyle: TextStyle( - color: widget.mainColor, - ), - ), - onPressed: () { - showCupertinoModalBottomSheet( - expand: false, - context: context, - backgroundColor: Colors.transparent, - bounce: true, - builder: (BuildContext context) => - GroupQueryFilterView( - categories: _model.categories, - categoriesList: _model.sortedCategories, - callback: (String category) { - _model.selectCategory(category); - setState(() {}); - }, - ), - ); - }, ), - ) - ]), - flexibleSpace: LayoutBuilder(builder: - (BuildContext context, BoxConstraints constraints) { - return FlexibleSpaceBar( - centerTitle: true, - title: SizedBox( - width: screenSize.width * 0.50, - child: FittedBox( - child: Text( - widget.name, - textAlign: TextAlign.center, - style: themeData.textTheme.headline1! - .copyWith(color: widget.mainColor), - ), + onPressed: () { + showCupertinoModalBottomSheet( + expand: false, + context: context, + backgroundColor: Colors.transparent, + bounce: true, + builder: (BuildContext context) => + GroupQueryFilterView( + categories: _model.categories, + categoriesList: _model.sortedCategories, + callback: (String category) { + _model.selectCategory(category); + setState(() {}); + }, + ), + ); + }, + ), + ), + ], + ), + flexibleSpace: LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints constraints, + ) => + FlexibleSpaceBar( + centerTitle: true, + title: SizedBox( + width: screenSize.width * 0.50, + child: FittedBox( + child: Text( + widget.name, + textAlign: TextAlign.center, + style: themeData.textTheme.headline1! + .copyWith(color: widget.mainColor), ), ), - background: _getHero(screenSize, themeData)); - }), + ), + ), + ), ), SliverList( delegate: SliverChildBuilderDelegate( - (_, int index) { + (BuildContext _context, int index) { + if (index >= _model.displayProducts!.length) { + // final button + final int already = _model.displayProducts!.length; + final int totalSize = + _model.supplier.partialProductList.totalSize; + final int next = max( + 0, + min( + _model.supplier.productQuery.pageSize, + totalSize - already, + ), + ); + final Widget child; + if (next == 0) { + child = Text(// TODO(monsieurtanuki): localize + "You've downloaded all the $totalSize products."); + } else { + child = ElevatedButton.icon( + icon: const Icon(Icons.download_rounded), + label: Text( + 'Download $next more products' + '\n' + 'Already downloaded $already out of $totalSize.', + ), + onPressed: () async { + final bool? error = + await LoadingDialog.run( + context: context, + future: _model.loadNextPage(), + ); + if (error != true) { + await LoadingDialog.error( + context: context, + title: _model.loadingError, + ); + } + }, + ); + } + return Padding( + padding: const EdgeInsets.only( + bottom: 90.0, left: 20, right: 20), + child: child, + ); + } + final Product product = + _model.displayProducts![index]; return Padding( padding: const EdgeInsets.symmetric( - horizontal: 12.0, vertical: 8.0), + horizontal: 12.0, + vertical: 8.0, + ), child: SmoothProductCardFound( - heroTag: _model.displayProducts![index].barcode!, - product: _model.displayProducts![index], - elevation: Theme.of(context).brightness == - Brightness.light - ? 0.0 - : 4.0, - ).build(context), + heroTag: product.barcode!, + product: product, + elevation: + themeData.brightness == Brightness.light + ? 0.0 + : 4.0, + ).build(_context), ); }, - childCount: _model.displayProducts!.length, + childCount: _model.displayProducts!.length + 1, ), ), ], @@ -360,7 +415,7 @@ class _ProductQueryPageState extends State { return; } final ProductListSupplier? refreshSupplier = - widget.productListSupplier.getRefreshSupplier(); + _model.supplier.getRefreshSupplier(); if (refreshSupplier == null) { return; } @@ -379,17 +434,22 @@ class _ProductQueryPageState extends State { duration: const Duration(seconds: 5), action: SnackBarAction( label: AppLocalizations.of(context)!.label_refresh, - onPressed: () => setState( - () => _model = ProductQueryModel(refreshSupplier), - ), + onPressed: () async { + final bool? error = await LoadingDialog.run( + context: context, + future: _model.loadFromTop(), + ); + if (error != true) { + await LoadingDialog.error(context: context); + } + }, ), ), ), ); } - // TODO(monsieurtanuki): move to an appropriate class? - static Widget getBackArrow(final BuildContext context, final Color color) => + static Widget _getBackArrow(final BuildContext context, final Color color) => Padding( padding: const EdgeInsets.only(top: 28.0), child: IconButton( diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart index 59714658d31..c6a33e129d3 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:openfoodfacts/utils/PnnsGroups.dart'; import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/database/paged_product_query.dart'; import 'package:smooth_app/pages/product/common/product_query_page.dart'; class ProductQueryPageHelper { Future openBestChoice({ - required final ProductQuery productQuery, + required final PagedProductQuery productQuery, required final LocalDatabase localDatabase, required final Color color, required final String heroTag, @@ -77,9 +76,6 @@ class ProductQueryPageHelper { {final bool verbose = true}) { final AppLocalizations appLocalizations = AppLocalizations.of(context)!; switch (productList.listType) { - case ProductListType.HTTP_SEARCH_GROUP: - return '${_getGroupName(productList.parameters, appLocalizations)}' - '${verbose ? ' ${appLocalizations.category_search}' : ''}'; case ProductListType.HTTP_SEARCH_KEYWORDS: return '${productList.parameters}' '${verbose ? ' ${appLocalizations.category_search}' : ''}'; @@ -92,14 +88,4 @@ class ProductQueryPageHelper { return appLocalizations.recently_seen_products; } } - - static String _getGroupName( - final String groupId, final AppLocalizations appLocalizations) { - for (final PnnsGroup2 group2 in PnnsGroup2.values) { - if (group2.id == groupId) { - return group2.name; - } - } - return '${appLocalizations.not_found} $groupId'; - } } 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 470f6f30280..29fee31d3e8 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -24,6 +24,7 @@ import 'package:smooth_app/pages/product/edit_product_page.dart'; import 'package:smooth_app/pages/product/knowledge_panel_product_cards.dart'; import 'package:smooth_app/pages/product/summary_card.dart'; import 'package:smooth_app/pages/user_preferences_dev_mode.dart'; +import 'package:smooth_app/themes/constant_icons.dart'; import 'package:smooth_app/themes/smooth_theme.dart'; class ProductPage extends StatefulWidget { @@ -71,8 +72,8 @@ class _ProductPageState extends State { onPressed: () { Navigator.pop(context); }, - child: const Icon( - Icons.arrow_back, + child: Icon( + ConstantIcons.instance.getBackIcon(), color: Colors.white, ), ) diff --git a/packages/smooth_app/lib/pages/product/summary_card.dart b/packages/smooth_app/lib/pages/product/summary_card.dart index f0fb7b46a35..3a89e34d99a 100644 --- a/packages/smooth_app/lib/pages/product/summary_card.dart +++ b/packages/smooth_app/lib/pages/product/summary_card.dart @@ -255,8 +255,7 @@ class _SummaryCardState extends State { name: categoryLabel!, localDatabase: localDatabase, productQuery: CategoryProductQuery( - categoryTag: widget._product.categoriesTags!.last, - size: 500, + widget._product.categoriesTags!.last, ), context: context, ), diff --git a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart index 8ee2aff9ee9..599dd5752fc 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart @@ -11,7 +11,7 @@ Future openPersonalizedRankingPage(BuildContext context) async { context, MaterialPageRoute( builder: (BuildContext context) => PersonalizedRankingPage( - productList: model.productList, + products: model.productList.getList(), title: ProductQueryPageHelper.getProductListLabel( model.productList, context, diff --git a/packages/smooth_app/lib/pages/scan/search_page.dart b/packages/smooth_app/lib/pages/scan/search_page.dart index 095ae1db63c..603f7c5bcef 100644 --- a/packages/smooth_app/lib/pages/scan/search_page.dart +++ b/packages/smooth_app/lib/pages/scan/search_page.dart @@ -81,7 +81,7 @@ Future _onSubmittedText( heroTag: 'search_bar', name: value, localDatabase: localDatabase, - productQuery: KeywordsProductQuery(keywords: value, size: 500), + productQuery: KeywordsProductQuery(value), context: context, );