diff --git a/packages/smooth_app/lib/database/product_query.dart b/packages/smooth_app/lib/database/product_query.dart index 52e6579a548..17c5be8bd9f 100644 --- a/packages/smooth_app/lib/database/product_query.dart +++ b/packages/smooth_app/lib/database/product_query.dart @@ -95,6 +95,7 @@ abstract class ProductQuery { ProductField.SELECTED_IMAGE, ProductField.QUANTITY, ProductField.SERVING_SIZE, + ProductField.STORES, ProductField.PACKAGING_QUANTITY, ProductField.NUTRIMENTS, ProductField.NUTRIENT_LEVELS, diff --git a/packages/smooth_app/lib/pages/product/category_picker_page.dart b/packages/smooth_app/lib/pages/product/category_picker_page.dart index c9b49ad604b..09d57bbc870 100644 --- a/packages/smooth_app/lib/pages/product/category_picker_page.dart +++ b/packages/smooth_app/lib/pages/product/category_picker_page.dart @@ -161,12 +161,12 @@ class _CategoryPickerPageState extends State { tag ]; // TODO(monsieurtanuki): is the last leaf good enough or should we go down to the roots? - final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( + final Product? savedAndRefreshed = await ProductRefresher().saveAndRefresh( context: context, localDatabase: localDatabase, product: product, ); - if (savedAndRefreshed) { + if (savedAndRefreshed != null) { if (!mounted) { return; } diff --git a/packages/smooth_app/lib/pages/product/common/product_refresher.dart b/packages/smooth_app/lib/pages/product/common/product_refresher.dart index fb68af54a86..81bd849beb2 100644 --- a/packages/smooth_app/lib/pages/product/common/product_refresher.dart +++ b/packages/smooth_app/lib/pages/product/common/product_refresher.dart @@ -9,24 +9,26 @@ import 'package:smooth_app/generic_lib/loading_dialog.dart'; /// Refreshes a product on the BE then on the local database. class ProductRefresher { - Future saveAndRefresh({ + /// Returns a saved and refreshed [Product] if successful, or null. + 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( + final _MetaProductRefresher? savedAndRefreshed = + await LoadingDialog.run<_MetaProductRefresher>( future: _saveAndRefresh(product, localDatabase), context: context, title: appLocalizations.nutrition_page_update_running, ); if (savedAndRefreshed == null) { // probably the end user stopped the dialog - return false; + return null; } - if (!savedAndRefreshed) { + if (savedAndRefreshed.product == null) { await LoadingDialog.error(context: context); - return false; + return null; } await showDialog( context: context, @@ -38,11 +40,11 @@ class ProductRefresher { ), ), ); - return true; + return savedAndRefreshed.product; } /// Saves a product on the BE and refreshes the local database - Future _saveAndRefresh( + Future<_MetaProductRefresher> _saveAndRefresh( final Product inputProduct, final LocalDatabase localDatabase, ) async { @@ -52,7 +54,7 @@ class ProductRefresher { inputProduct, ); if (status.error != null) { - return false; + return _MetaProductRefresher.error(status.error); } final ProductQueryConfiguration configuration = ProductQueryConfiguration( inputProduct.barcode!, @@ -65,11 +67,20 @@ class ProductRefresher { ); if (result.product != null) { await DaoProduct(localDatabase).put(result.product!); - return true; + localDatabase.notifyListeners(); + return _MetaProductRefresher.product(result.product); } } catch (e) { // } - return false; + return const _MetaProductRefresher.error(null); } } + +class _MetaProductRefresher { + const _MetaProductRefresher.error(this.error) : product = null; + const _MetaProductRefresher.product(this.product) : error = null; + + final String? error; + final Product? product; +} diff --git a/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart index fc0a43dfe61..ec346986158 100644 --- a/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_ingredients_page.dart @@ -148,12 +148,12 @@ class _EditIngredientsPageState extends State { Future _updateIngredientsText(String ingredientsText) async { widget.product.ingredientsText = ingredientsText; final LocalDatabase localDatabase = context.read(); - final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( + final Product? savedAndRefreshed = await ProductRefresher().saveAndRefresh( context: context, localDatabase: localDatabase, product: widget.product, ); - if (savedAndRefreshed) { + if (savedAndRefreshed != null) { if (!mounted) { return; } diff --git a/packages/smooth_app/lib/pages/product/edit_product_page.dart b/packages/smooth_app/lib/pages/product/edit_product_page.dart index d64b0994071..bdd54da30b7 100644 --- a/packages/smooth_app/lib/pages/product/edit_product_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_product_page.dart @@ -10,6 +10,8 @@ import 'package:smooth_app/pages/product/edit_ingredients_page.dart'; import 'package:smooth_app/pages/product/nutrition_page_loaded.dart'; import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; import 'package:smooth_app/pages/product/product_image_gallery_view.dart'; +import 'package:smooth_app/pages/product/simple_input_page.dart'; +import 'package:smooth_app/pages/product/simple_input_page_helpers.dart'; /// Page where we can indirectly edit all data about a product. class EditProductPage extends StatefulWidget { @@ -23,6 +25,13 @@ class EditProductPage extends StatefulWidget { class _EditProductPageState extends State { int _changes = 0; + late Product _product; + + @override + void initState() { + super.initState(); + _product = widget.product; + } @override Widget build(BuildContext context) { @@ -30,7 +39,7 @@ class _EditProductPageState extends State { return Scaffold( appBar: AppBar( title: AutoSizeText( - getProductName(widget.product, appLocalizations), + getProductName(_product, appLocalizations), maxLines: 2, ), systemOverlayStyle: const SystemUiOverlayStyle( @@ -52,9 +61,8 @@ class _EditProductPageState extends State { title: Text( appLocalizations.edit_product_form_item_barcode, ), - subtitle: widget.product.barcode == null - ? null - : Text(widget.product.barcode!), + subtitle: + _product.barcode == null ? null : Text(_product.barcode!), ), _ListTitleItem( title: appLocalizations.edit_product_form_item_details_title, @@ -65,7 +73,7 @@ class _EditProductPageState extends State { context, MaterialPageRoute( builder: (BuildContext context) => - AddBasicDetailsPage(widget.product), + AddBasicDetailsPage(_product), ), ); if (refreshed ?? false) { @@ -78,7 +86,7 @@ class _EditProductPageState extends State { subtitle: appLocalizations.edit_product_form_item_photos_subtitle, onTap: () async { final List allProductImagesData = - getAllProductImagesData(widget.product, appLocalizations); + getAllProductImagesData(_product, appLocalizations); final bool? refreshed = await Navigator.push( context, MaterialPageRoute( @@ -86,7 +94,7 @@ class _EditProductPageState extends State { productImageData: allProductImagesData.first, allProductImagesData: allProductImagesData, title: allProductImagesData.first.title, - barcode: widget.product.barcode, + barcode: _product.barcode, ), ), ); @@ -106,7 +114,7 @@ class _EditProductPageState extends State { context, MaterialPageRoute( builder: (BuildContext context) => EditIngredientsPage( - product: widget.product, + product: _product, ), ), ); @@ -118,6 +126,26 @@ class _EditProductPageState extends State { _ListTitleItem( title: appLocalizations.edit_product_form_item_packaging_title, ), + _ListTitleItem( + title: 'Stores', // TODO(monsieurtanuki): translate + onTap: () async { + final Product? refreshed = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => SimpleInputPage( + SimpleInputPageStoreHelper( + _product, + appLocalizations, + ), + ), + ), + ); + if (refreshed != null) { + _product = refreshed; + } + return; + }, + ), _ListTitleItem( title: appLocalizations.edit_product_form_item_nutrition_facts_title, @@ -136,7 +164,7 @@ class _EditProductPageState extends State { context, MaterialPageRoute( builder: (BuildContext context) => NutritionPageLoaded( - widget.product, + _product, cache.orderedNutrients, ), ), @@ -145,7 +173,7 @@ class _EditProductPageState extends State { _changes++; } }, - ) + ), ], ), ), 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 40da40b9500..b2258b914c9 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -454,12 +454,12 @@ class _NutritionPageLoadedState extends State { // minimal product: we only want to save the nutrients final Product inputProduct = _nutritionContainer.getProduct(); - final bool savedAndRefreshed = await ProductRefresher().saveAndRefresh( + final Product? savedAndRefreshed = await ProductRefresher().saveAndRefresh( context: context, localDatabase: localDatabase, product: inputProduct, ); - if (savedAndRefreshed) { + if (savedAndRefreshed != null) { if (!mounted) { return; } diff --git a/packages/smooth_app/lib/pages/product/simple_input_page.dart b/packages/smooth_app/lib/pages/product/simple_input_page.dart new file mode 100644 index 00000000000..0ca3bb4b070 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/simple_input_page.dart @@ -0,0 +1,161 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/pages/product/simple_input_page_helpers.dart'; + +/// Simple input page: we have a list of labels, we add, we remove, we save. +class SimpleInputPage extends StatefulWidget { + const SimpleInputPage(this.helper) : super(); + + final AbstractSimpleInputPageHelper helper; + + @override + State createState() => _SimpleInputPageState(); +} + +class _SimpleInputPageState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final LocalDatabase localDatabase = context.watch(); + // that's a bit tricky here. + // 1. we want to decide if we can go out of this page. + // 1a. for this, we return an async bool, according to onWillPop. + // 2. but we also want to return the changed Product. + return WillPopScope( + onWillPop: () async { + final Product? changedProduct = widget.helper.getChangedProduct(); + if (changedProduct == null) { + return true; + } + final bool? pleaseSave = await showDialog( + context: context, + builder: (final BuildContext context) => SmoothAlertDialog( + body: + const Text('You are about to leave this page without saving.'), + title: widget.helper.getTitle(), + negativeAction: SmoothActionButton( + text: 'Ignore', + onPressed: () => Navigator.pop(context, false), + ), + positiveAction: SmoothActionButton( + text: 'Save', + onPressed: () => Navigator.pop(context, true), + ), + neutralAction: SmoothActionButton( + text: 'Cancel', + onPressed: () => Navigator.pop(context, null), + ), + ), + ); + if (pleaseSave == null) { + return false; + } + if (pleaseSave == false) { + return true; + } + final Product? savedAndRefreshed = + await ProductRefresher().saveAndRefresh( + context: context, + localDatabase: localDatabase, + product: changedProduct, + ); + if (savedAndRefreshed == null) { + // it failed: we stay on the same page + return false; + } + // tricky part (cf. https://stackoverflow.com/questions/53995673/willpopscope-should-i-use-return-future-valuetrue-after-navigator-pop) + // 1. we return true to get out of this page. + // 2. we pop the product because the calling page needs it. + //ignore: use_build_context_synchronously + Navigator.pop(context, savedAndRefreshed); + return Future(() => true); + }, + child: Scaffold( + appBar: AppBar( + title: AutoSizeText( + getProductName(widget.helper.product, appLocalizations), + maxLines: 2, + ), + ), + body: Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: ListView( + children: [ + Text( + widget.helper.getTitle(), + style: themeData.textTheme.headline1, + ), + const SizedBox(height: LARGE_SPACE), + Text(widget.helper.getAddTitle()), + Row( + children: [ + ElevatedButton( + onPressed: () { + if (widget.helper.addLabel(_controller.text)) { + setState(() => _controller.text = ''); + } + }, + child: const Icon(Icons.add), + ), + Flexible( + flex: 1, // maximum size, as the other guy has no flex + child: Padding( + padding: const EdgeInsets.all(LARGE_SPACE), + child: TextField( + decoration: InputDecoration( + filled: true, + border: const OutlineInputBorder( + borderRadius: CIRCULAR_BORDER_RADIUS, + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: SMALL_SPACE, + vertical: SMALL_SPACE, + ), + hintText: widget.helper.getAddHint(), + ), + controller: _controller, + ), + ), + ), + ], + ), + Divider(color: themeData.colorScheme.onBackground), + Wrap( + direction: Axis.horizontal, + spacing: LARGE_SPACE, + runSpacing: VERY_SMALL_SPACE, + children: List.generate( + widget.helper.getLabels().length, + (final int index) { + final String label = widget.helper.getLabels()[index]; + return ElevatedButton.icon( + icon: const Icon(Icons.clear), + label: Text(label), + onPressed: () async { + if (widget.helper.removeLabel(label)) { + setState(() {}); + } + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/product/simple_input_page_helpers.dart b/packages/smooth_app/lib/pages/product/simple_input_page_helpers.dart new file mode 100644 index 00000000000..f708f086945 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/simple_input_page_helpers.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; + +/// Abstract helper for Simple Input Page. +/// +/// * we retrieve the initial list of labels. +/// * we add a label to the list. +/// * we remove a label from the list. +abstract class AbstractSimpleInputPageHelper { + AbstractSimpleInputPageHelper( + this.product, + this.appLocalizations, + ) { + _labels = initLabels(); + } + + final Product product; + final AppLocalizations appLocalizations; + + /// Labels as they were initially then edited by the user. + late List _labels; + + /// "Have the labels been changed?" + bool _changed = false; + + /// Returns the labels as they were initially in the product. + @protected + List initLabels(); + + /// Returns the current labels to be displayed. + List getLabels() => _labels; + + /// Returns true if the label was not in the list and then was added. + bool addLabel(String label) { + label = label.trim(); + if (label.isEmpty) { + return false; + } + if (_labels.contains(label)) { + return false; + } + _labels.add(label); + _changed = true; + return true; + } + + /// Returns true if the label was in the list and then was removed. + /// + /// The things we build the interface, very unlikely to return false, + /// as we remove existing items. + bool removeLabel(final String label) { + if (_labels.remove(label)) { + _changed = true; + return true; + } + return false; + } + + /// Returns the title on the main "edit product" page. + String getTitle(); + + /// Returns the title of the "add" paragraph. + String getAddTitle(); + + /// Returns the hint of the "add" text field. + String getAddHint(); + + /// Impacts a product in order to take the changes into account. + @protected + void changeProduct(final Product changedProduct); + + /// Returns null is no change was made, or a Product to be saved on the BE. + Product? getChangedProduct() { + if (!_changed) { + return null; + } + final Product changedProduct = Product(barcode: product.barcode); + changeProduct(changedProduct); + return changedProduct; + } + + List _splitString(final String? input) { + if (input == null) { + return []; + } + final List result = input.split(','); + for (int i = 0; i < result.length; i++) { + final int pos = result[i].indexOf(':'); + if (pos == 2) { + // we get rid of the language, e.g. 'fr:Sac' + result[i] = result[i].substring(pos + 1); + } + } + return result; + } +} + +/// Implementation for "Stores" of an [AbstractSimpleInputPageHelper]. +class SimpleInputPageStoreHelper extends AbstractSimpleInputPageHelper { + SimpleInputPageStoreHelper( + final Product product, + final AppLocalizations appLocalizations, + ) : super( + product, + appLocalizations, + ); + + @override + List initLabels() => _splitString(product.stores); + + @override + void changeProduct(final Product changedProduct) => + changedProduct.stores = _labels.join(','); + + @override + String getTitle() => 'Stores'; // TODO(monsieurtanuki): translate + + @override + String getAddTitle() => 'Add a store'; // TODO(monsieurtanuki): translate + + @override + String getAddHint() => 'store'; // TODO(monsieurtanuki): translate +}