From 45b7e7b39e0d8040ab3a85d5b41c8856de93dac2 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Mon, 11 Nov 2024 11:48:27 +0100 Subject: [PATCH] feat: 5430 - "producer provided" icon for nutrients and 4 product fields (#5777) * feat: 5430 - "producer provided" icon for nutrients and 4 product fields Impacted files: * `add_basic_details_page.dart`: display "producer provided" icon for name, brands and quantity * `nutrition_page_loaded.dart`: display "producer provided" icon for serving size and each individual nutrient * `product_query.dart`: set the icon to display when a product field value is "producer provided" * `smooth_autocomplete_text_field.dart`: added parameter `suffixIcon` * `smooth_text_form_field.dart`: added parameter `suffixIcon` * Minor code cleaning * Minor code cleaning * Standard info tile about "owner fields" New file: * `owner_field_info.dart`: Standard info tile about "owner fields". Impacted files: * `add_basic_details_page.dart`: now displaying `OwnerFieldInfo` if relevant; minor refactoring * `nutrition_page_loaded.dart`: now displaying `OwnerFieldInfo` if relevant; minor refactoring * `product_query.dart`: moved field to new file `owner_field_info.dart` * translations and semantics Impacted files: * add_basic_details_page.dart: added semantics * app_en.arb: 2 new labels for "owner field info" * nutrition_page_loaded.dart: added semantics * owner_field_info.dart: translated labels * typo fix --- .../widgets/smooth_text_form_field.dart | 27 ++++---- packages/smooth_app/lib/l10n/app_en.arb | 8 +++ .../input/smooth_autocomplete_text_field.dart | 3 + .../pages/product/add_basic_details_page.dart | 64 +++++++++++++++++- .../pages/product/nutrition_page_loaded.dart | 66 +++++++++++++++++-- .../lib/pages/product/owner_field_info.dart | 24 +++++++ 6 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 packages/smooth_app/lib/pages/product/owner_field_info.dart diff --git a/packages/smooth_app/lib/generic_lib/widgets/smooth_text_form_field.dart b/packages/smooth_app/lib/generic_lib/widgets/smooth_text_form_field.dart index 0c2acb0bb9e..bcdb6296b3f 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/smooth_text_form_field.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/smooth_text_form_field.dart @@ -21,6 +21,7 @@ class SmoothTextFormField extends StatefulWidget { required this.hintText, this.hintTextFontSize, this.prefixIcon, + this.suffixIcon, this.textInputType, this.onChanged, this.onFieldSubmitted, @@ -34,6 +35,7 @@ class SmoothTextFormField extends StatefulWidget { final TextEditingController? controller; final String hintText; final Widget? prefixIcon; + final Widget? suffixIcon; final bool? enabled; final TextInputAction? textInputAction; final String? Function(String?)? validator; @@ -121,18 +123,19 @@ class _SmoothTextFormFieldState extends State { width: 5.0, ), ), - suffixIcon: widget.type == TextFieldTypes.PASSWORD - ? IconButton( - tooltip: appLocalization.show_password, - splashRadius: 10.0, - onPressed: () => setState(() { - _obscureText = !_obscureText; - }), - icon: _obscureText - ? const Icon(Icons.visibility_off) - : const Icon(Icons.visibility), - ) - : null, + suffixIcon: widget.suffixIcon ?? + (widget.type == TextFieldTypes.PASSWORD + ? IconButton( + tooltip: appLocalization.show_password, + splashRadius: 10.0, + onPressed: () => setState(() { + _obscureText = !_obscureText; + }), + icon: _obscureText + ? const Icon(Icons.visibility_off) + : const Icon(Icons.visibility), + ) + : null), errorMaxLines: 2, ), ); diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 64768a41e1b..c0106c39e35 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -2587,6 +2587,14 @@ "app_rating_dialog_title_enjoying_positive_actions": "Yeah!", "not_really": "Not really", "app_rating_dialog_title_not_enjoying_app": "We are so sorry to hear that! Could you tell us what happened?", + "owner_field_info_title": "Producer provided values", + "@owner_field_info_title": { + "description": "Title of the 'producer provided' info list-tile" + }, + "owner_field_info_message": "With that logo we highlight data provided by the producer, and that may not be editable.", + "@owner_field_info_message": { + "description": "Title of the 'producer provided' info list-tile" + }, "edit_packagings_title": "Packaging components", "@edit_packagings_title": { "description": "Title of the structured packagings page" diff --git a/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart index 478bd00201f..68bbdf505f3 100644 --- a/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart +++ b/packages/smooth_app/lib/pages/input/smooth_autocomplete_text_field.dart @@ -19,6 +19,7 @@ class SmoothAutocompleteTextField extends StatefulWidget { required this.manager, this.minLengthForSuggestions = 1, this.allowEmojis = true, + this.suffixIcon, }); final FocusNode focusNode; @@ -29,6 +30,7 @@ class SmoothAutocompleteTextField extends StatefulWidget { final int minLengthForSuggestions; final AutocompleteManager? manager; final bool allowEmojis; + final Widget? suffixIcon; @override State createState() => @@ -82,6 +84,7 @@ class _SmoothAutocompleteTextFieldState FilteringTextInputFormatter.deny(TextHelper.emojiRegex), ], decoration: InputDecoration( + suffixIcon: widget.suffixIcon, filled: true, border: const OutlineInputBorder( borderRadius: ANGULAR_BORDER_RADIUS, diff --git a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart index 8a18e9cfff9..036d0f616b8 100644 --- a/packages/smooth_app/lib/pages/product/add_basic_details_page.dart +++ b/packages/smooth_app/lib/pages/product/add_basic_details_page.dart @@ -18,6 +18,7 @@ import 'package:smooth_app/pages/product/common/product_buttons.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; import 'package:smooth_app/pages/product/multilingual_helper.dart'; +import 'package:smooth_app/pages/product/owner_field_info.dart'; import 'package:smooth_app/pages/text_field_helper.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; @@ -99,7 +100,7 @@ class _AddBasicDetailsPageState extends State { appBar: buildEditProductAppBar( context: context, title: appLocalizations.basic_details, - product: widget.product, + product: _product, ), body: Form( key: _formKey, @@ -142,6 +143,9 @@ class _AddBasicDetailsPageState extends State { Widget? child) { if (_multilingualHelper.isMonolingual()) { return SmoothTextFormField( + suffixIcon: _getOwnerFieldIcon( + ProductField.NAME, + ), controller: _productNameController, type: TextFieldTypes.PLAIN_TEXT, hintText: appLocalizations.product_name, @@ -164,6 +168,11 @@ class _AddBasicDetailsPageState extends State { Padding( padding: const EdgeInsets.all(8.0), child: SmoothTextFormField( + suffixIcon: _getOwnerFieldIcon( + ProductField.NAME_IN_LANGUAGES, + language: _multilingualHelper + .getCurrentLanguage(), + ), controller: _productNameController, type: TextFieldTypes.PLAIN_TEXT, hintText: appLocalizations.product_name, @@ -197,6 +206,9 @@ class _AddBasicDetailsPageState extends State { allowEmojis: false, hintText: appLocalizations.brand_name, constraints: constraints, + suffixIcon: _getOwnerFieldIcon( + ProductField.BRANDS, + ), manager: AutocompleteManager( TaxonomyNameAutocompleter( taxonomyNames: [ @@ -208,7 +220,7 @@ class _AddBasicDetailsPageState extends State { limit: 25, fuzziness: Fuzziness.none, uriHelper: ProductQuery.getUriProductHelper( - productType: widget.product.productType, + productType: _product.productType, ), ), ), @@ -216,10 +228,18 @@ class _AddBasicDetailsPageState extends State { ), SizedBox(height: _heightSpace), SmoothTextFormField( + suffixIcon: _getOwnerFieldIcon( + ProductField.QUANTITY, + ), controller: _weightController, type: TextFieldTypes.PLAIN_TEXT, hintText: appLocalizations.quantity, ), + if (_hasOwnerField()) + const Padding( + padding: EdgeInsets.only(top: LARGE_SPACE), + child: Card(child: OwnerFieldInfo()), + ), // in order to be able to scroll suggestions SizedBox(height: MediaQuery.sizeOf(context).height), ], @@ -332,4 +352,44 @@ class _AddBasicDetailsPageState extends State { } return result; } + + Widget? _getOwnerFieldIcon( + final ProductField productField, { + final OpenFoodFactsLanguage? language, + }) => + _isOwnerField(productField, language: language) + ? Semantics( + label: AppLocalizations.of(context).owner_field_info_title, + child: const Icon(OwnerFieldInfo.ownerFieldIconData), + ) + : null; + + bool _hasOwnerField() { + if (_multilingualHelper.isMonolingual()) { + if (_isOwnerField(ProductField.NAME)) { + return true; + } + } else { + if (_isOwnerField( + ProductField.NAME_IN_LANGUAGES, + language: _multilingualHelper.getCurrentLanguage(), + )) { + return true; + } + } + return _isOwnerField(ProductField.BRANDS) || + _isOwnerField(ProductField.QUANTITY); + } + + bool _isOwnerField( + final ProductField productField, { + final OpenFoodFactsLanguage? language, + }) => + _product.getOwnerFieldTimestamp( + OwnerField.productField( + productField, + language ?? ProductQuery.getLanguage(), + ), + ) != + null; } 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 7822e6d40e9..9bde7e3924c 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -20,8 +20,10 @@ import 'package:smooth_app/pages/product/may_exit_page_helper.dart'; import 'package:smooth_app/pages/product/nutrition_add_nutrient_button.dart'; import 'package:smooth_app/pages/product/nutrition_container.dart'; import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; +import 'package:smooth_app/pages/product/owner_field_info.dart'; import 'package:smooth_app/pages/product/simple_input_number_field.dart'; import 'package:smooth_app/pages/text_field_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; import 'package:smooth_app/widgets/will_pop_scope.dart'; @@ -130,6 +132,9 @@ class _NutritionPageLoadedState extends State children.add(_switchNoNutrition(appLocalizations)); if (!_nutritionContainer.noNutritionData) { + final Iterable displayableNutrients = + _nutritionContainer.getDisplayableNutrients(); + children.add( Padding( padding: const EdgeInsets.symmetric(vertical: MEDIUM_SPACE), @@ -140,12 +145,13 @@ class _NutritionPageLoadedState extends State ), ), ); + if (_hasOwnerField(displayableNutrients)) { + children.add(const OwnerFieldInfo()); + } + children.add(_getServingField(appLocalizations)); children.add(_getServingSwitch(appLocalizations)); - final Iterable displayableNutrients = - _nutritionContainer.getDisplayableNutrients(); - if (_focusNodes.length != displayableNutrients.length) { _focusNodes.clear(); _focusNodes.addAll( @@ -177,6 +183,7 @@ class _NutritionPageLoadedState extends State _decimalNumberFormat, orderedNutrient, i, + upToDateProduct, ), ), ); @@ -225,6 +232,30 @@ class _NutritionPageLoadedState extends State ); } + bool _hasOwnerField( + final Iterable displayableNutrients, + ) { + if (upToDateProduct.getOwnerFieldTimestamp( + OwnerField.productField( + ProductField.SERVING_SIZE, + ProductQuery.getLanguage(), + ), + ) != + null) { + return true; + } + for (final OrderedNutrient orderedNutrient in displayableNutrients) { + final Nutrient nutrient = _getNutrient(orderedNutrient); + if (upToDateProduct.getOwnerFieldTimestamp( + OwnerField.nutrient(nutrient), + ) != + null) { + return true; + } + } + return false; + } + Widget _getServingField(final AppLocalizations appLocalizations) { final String value = _nutritionContainer.servingSize; @@ -245,6 +276,18 @@ class _NutritionPageLoadedState extends State decoration: InputDecoration( enabledBorder: const UnderlineInputBorder(), labelText: appLocalizations.nutrition_page_serving_size, + suffixIcon: widget.product.getOwnerFieldTimestamp( + OwnerField.productField( + ProductField.SERVING_SIZE, + ProductQuery.getLanguage(), + ), + ) == + null + ? null + : Semantics( + label: appLocalizations.owner_field_info_title, + child: const Icon(OwnerFieldInfo.ownerFieldIconData), + ), ), textInputAction: TextInputAction.next, onFieldSubmitted: (_) { @@ -442,12 +485,14 @@ class _NutrientRow extends StatelessWidget { this.decimalNumberFormat, this.orderedNutrient, this.position, + this.product, ); final NutritionContainer nutritionContainer; final NumberFormat decimalNumberFormat; final OrderedNutrient orderedNutrient; final int position; + final Product product; @override Widget build(BuildContext context) { @@ -465,6 +510,7 @@ class _NutrientRow extends StatelessWidget { decimalNumberFormat, orderedNutrient, position, + product, ), ), ), @@ -492,14 +538,17 @@ class _NutrientValueCell extends StatelessWidget { this.decimalNumberFormat, this.orderedNutrient, this.position, + this.product, ); final NumberFormat decimalNumberFormat; final OrderedNutrient orderedNutrient; final int position; + final Product product; @override Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); final List focusNodes = Provider.of>( context, listen: false, @@ -507,6 +556,7 @@ class _NutrientValueCell extends StatelessWidget { final TextEditingControllerWithHistory controller = context.watch(); final bool isLast = position == focusNodes.length - 1; + final Nutrient? nutrient = orderedNutrient.nutrient; return TextFormField( controller: controller, @@ -515,6 +565,14 @@ class _NutrientValueCell extends StatelessWidget { decoration: InputDecoration( enabledBorder: const UnderlineInputBorder(), labelText: orderedNutrient.name, + suffixIcon: nutrient == null || + product.getOwnerFieldTimestamp(OwnerField.nutrient(nutrient)) == + null + ? null + : Semantics( + label: appLocalizations.owner_field_info_title, + child: const Icon(OwnerFieldInfo.ownerFieldIconData), + ), ), keyboardType: const TextInputType.numberWithOptions( signed: false, @@ -540,7 +598,7 @@ class _NutrientValueCell extends StatelessWidget { decimalNumberFormat.parse(value); return null; } catch (e) { - return AppLocalizations.of(context).nutrition_page_invalid_number; + return appLocalizations.nutrition_page_invalid_number; } }, ); diff --git a/packages/smooth_app/lib/pages/product/owner_field_info.dart b/packages/smooth_app/lib/pages/product/owner_field_info.dart new file mode 100644 index 00000000000..189efd9521e --- /dev/null +++ b/packages/smooth_app/lib/pages/product/owner_field_info.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +/// Standard info tile about "owner fields". +class OwnerFieldInfo extends StatelessWidget { + const OwnerFieldInfo({super.key}); + + /// Icon to display when the product field value is "producer provided". + static const IconData ownerFieldIconData = Icons.factory; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final bool dark = Theme.of(context).brightness == Brightness.dark; + final Color? darkGrey = Colors.grey[700]; + final Color? lightGrey = Colors.grey[300]; + return ListTile( + tileColor: dark ? darkGrey : lightGrey, + leading: const Icon(ownerFieldIconData), + title: Text(appLocalizations.owner_field_info_title), + subtitle: Text(appLocalizations.owner_field_info_message), + ); + } +}