From 74fe8560be8ddc2430e5251e8f316017dbf37e80 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Mon, 27 Jan 2025 08:01:05 +0100 Subject: [PATCH] feat: Multi-lingual input for product name + help banner (#6286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Explanation banner for the product name * Bad examples with the wrong title * Reorder header icons * Oops… dark mode * Dark mode x2 * Fix "Nesquik" typo * With banner * Remove the warning icon from the Help modal sheet (too ugly) --- .../preferences/user_preferences.dart | 15 + .../bottom_sheets/smooth_bottom_sheet.dart | 26 +- .../html/smooth_html_marker_chip.dart | 2 +- .../html/smooth_html_marker_decimal.dart | 2 +- packages/smooth_app/lib/l10n/app_en.arb | 64 ++- .../pages/prices/product_price_add_page.dart | 2 +- .../add_basic_details_name.dart | 276 +++++++++++- .../product/edit_ocr/edit_ocr_textfield.dart | 2 +- .../nutrition_page/nutrition_page.dart | 4 +- .../nutrition_availability_container.dart | 4 +- .../widgets/nutrition_serving_size.dart | 4 +- .../footer/new_product_footer.dart | 2 +- .../pages/product/simple_input_widget.dart | 54 +-- .../lib/themes/smooth_theme_colors.dart | 13 +- .../widgets/smooth_explanation_banner.dart | 415 ++++++++++++++++++ packages/smooth_app/pubspec.lock | 2 +- 16 files changed, 790 insertions(+), 97 deletions(-) create mode 100644 packages/smooth_app/lib/widgets/smooth_explanation_banner.dart diff --git a/packages/smooth_app/lib/data_models/preferences/user_preferences.dart b/packages/smooth_app/lib/data_models/preferences/user_preferences.dart index 8331338948a2..cd22ea695e5c 100644 --- a/packages/smooth_app/lib/data_models/preferences/user_preferences.dart +++ b/packages/smooth_app/lib/data_models/preferences/user_preferences.dart @@ -128,6 +128,10 @@ class UserPreferences extends ChangeNotifier { 'taglineFeedNewsDisplayed'; static const String _TAG_TAGLINE_FEED_NEWS_CLICKED = 'taglineFeedNewsClicked'; + /// Info messages + static const String _TAG_SHOW_BANNER_INPUT_PRODUCT_NAME = + 'bannerInputProductName'; + Future init(final ProductPreferences productPreferences) async { await _onMigrate(); @@ -483,6 +487,17 @@ class UserPreferences extends ChangeNotifier { } } + bool showInputProductNameBanner() => + _sharedPreferences.getBool(_TAG_SHOW_BANNER_INPUT_PRODUCT_NAME) ?? true; + + Future hideInputProductNameBanner() async { + await _sharedPreferences.setBool( + _TAG_SHOW_BANNER_INPUT_PRODUCT_NAME, + false, + ); + notifyListeners(); + } + ProductType get latestProductType => ProductType.fromOffTag( _sharedPreferences.getString(_TAG_LATEST_PRODUCT_TYPE)) ?? diff --git a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart index bb600355685f..ca10bd62440f 100644 --- a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart +++ b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart @@ -682,20 +682,34 @@ abstract class SizeWidget implements Widget { class SmoothModalSheetBodyContainer extends StatelessWidget { const SmoothModalSheetBodyContainer({ required this.child, + this.padding, + this.safeArea = true, super.key, }); final Widget child; + final EdgeInsetsGeometry? padding; + final bool safeArea; @override Widget build(BuildContext context) { + EdgeInsetsGeometry padding = this.padding ?? + const EdgeInsetsDirectional.only( + start: MEDIUM_SPACE, + end: MEDIUM_SPACE, + top: VERY_SMALL_SPACE, + bottom: VERY_SMALL_SPACE, + ); + + if (safeArea) { + padding = padding.add( + EdgeInsetsDirectional.only( + bottom: MediaQuery.viewPaddingOf(context).bottom, + ), + ); + } return Padding( - padding: EdgeInsetsDirectional.only( - start: MEDIUM_SPACE, - end: MEDIUM_SPACE, - top: VERY_SMALL_SPACE, - bottom: VERY_SMALL_SPACE + MediaQuery.viewPaddingOf(context).bottom, - ), + padding: padding, child: DefaultTextStyle.merge( style: const TextStyle( fontSize: 15.0, diff --git a/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_chip.dart b/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_chip.dart index 36b16c1c9f53..e3f5c6df8934 100644 --- a/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_chip.dart +++ b/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_chip.dart @@ -16,7 +16,7 @@ class SmoothHtmlChip extends StatelessWidget { return CustomPaint( painter: _HtmlChipPainter( color: - context.lightTheme() ? extension.greyLight : extension.greyNormal, + context.lightTheme() ? extension.greyMedium : extension.greyNormal, textDirection: Directionality.of(context), ), child: const SizedBox.square(dimension: 10.0), diff --git a/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_decimal.dart b/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_decimal.dart index 4eceba05ff25..5ebc5ccfccf0 100644 --- a/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_decimal.dart +++ b/packages/smooth_app/lib/generic_lib/html/smooth_html_marker_decimal.dart @@ -19,7 +19,7 @@ class SmoothHtmlDecimal extends StatelessWidget { return CustomPaint( painter: _HtmlDecimalPainter( color: - context.lightTheme() ? extension.greyLight : extension.greyNormal, + context.lightTheme() ? extension.greyMedium : extension.greyNormal, index: index, textDirection: Directionality.of(context), textStyle: const TextStyle( diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 33bd142fc0be..198f25bfe90f 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -2517,11 +2517,73 @@ "basic_details": "Basic Details", "product_name": "Product Name", "product_names": "Product Names", + "@product_names": { + "description": "Title for the section to edit the product name (in multiple languages)" + }, "add_basic_details_product_name_add_translation": "Add a new translation", + "@add_basic_details_product_name_add_translation": { + "description": "Button to add a new translation for the product name" + }, + "add_basic_details_product_name_warning_translations": "Before validating, please ensure you only add a translation **if the language is not present on the packaging**", + "@add_basic_details_product_name_warning_translations": { + "description": "Warning message displayed on top of new translations for the product name" + }, "add_basic_details_product_name_open_photo": "View front photo", + "@add_basic_details_product_name_open_photo": { + "description": "Button to view the front photo of the product (on top of the screen)" + }, "add_basic_details_product_name_take_photo": "Take front photo", - "add_basic_details_product_name_error": "Please enter the product name", + "@add_basic_details_product_name_take_photo": { + "description": "Button to take a photo of the front of the product (when there is no photo yet)" + }, "add_basic_details_product_name_hint": "Input the name of the product (eg: Nutella)", + "@add_basic_details_product_name_hint": { + "description": "Placeholder when the product name text-field is empty" + }, + "add_basic_details_product_name_help_title": "Good practices: product name", + "@add_basic_details_product_name_help_title": { + "description": "Title for the help section about the product name" + }, + "add_basic_details_product_name_help_info1": "The product name is the **main name printed on the packaging**. It can be a registered trademark.", + "@add_basic_details_product_name_help_info1": { + "description": "Text explaining how to write the product name" + }, + "add_basic_details_product_name_help_info2": "**Note:** Please don't add a translation **if the language is not present on the packaging**.", + "@add_basic_details_product_name_help_info2": { + "description": "Text explaining how to write the product name" + }, + "explanation_section_good_examples": "Good examples", + "@explanation_section_good_examples": { + "description": "Title for the section with good examples" + }, + "explanation_section_bad_examples": "Bad examples", + "@explanation_section_bad_examples": { + "description": "Title for the section with bad examples" + }, + "add_basic_details_product_name_help_good_examples_1": "Nesquik", + "@add_basic_details_product_name_help_good_examples_1": { + "description": "A 1st good example for the product name (you can change it if necessary)" + }, + "add_basic_details_product_name_help_good_examples_2": "Tomato Ketchup", + "@add_basic_details_product_name_help_good_examples_2": { + "description": "A 2nd good example for the product name (you can change it if necessary)" + }, + "add_basic_details_product_name_help_bad_examples_1_explanation": "Don't include the brand in the name", + "@add_basic_details_product_name_help_bad_examples_1_explanation": { + "description": "Explanation for the first bad example" + }, + "add_basic_details_product_name_help_bad_examples_1_example": "Tomato Ketchup **by Heinz**", + "@add_basic_details_product_name_help_bad_examples_1_example": { + "description": "First bad example for the product name" + }, + "add_basic_details_product_name_help_bad_examples_2_explanation": "Don't use symbols ®, ™, © or similar", + "@add_basic_details_product_name_help_bad_examples_2_explanation": { + "description": "Explanation for the second bad example" + }, + "add_basic_details_product_name_help_bad_examples_2_example": "Nesquik**®**", + "@add_basic_details_product_name_help_bad_examples_2_example": { + "description": "Second bad example for the product name" + }, "add_basic_details_product_name_other_translations": "{count,plural, one{{count} other translation} other{{count} other translations}}", "@add_basic_details_product_name_other_translations": { "description": "The number of other translations for a product name (count is always >= 1)", diff --git a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart index 6d840495a4af..0a51744aa830 100644 --- a/packages/smooth_app/lib/pages/prices/product_price_add_page.dart +++ b/packages/smooth_app/lib/pages/prices/product_price_add_page.dart @@ -304,7 +304,7 @@ class _ProductPriceAddPageState extends State const SizedBox(width: MEDIUM_SPACE), CircleAvatar( radius: radius, - backgroundColor: extension.greyLight, + backgroundColor: extension.greyMedium, child: const app_icons.Arrow.right( color: Colors.white, size: defaultIconSize, diff --git a/packages/smooth_app/lib/pages/product/add_basic_details/add_basic_details_name.dart b/packages/smooth_app/lib/pages/product/add_basic_details/add_basic_details_name.dart index 922124ce0d92..6cf9c40f169d 100644 --- a/packages/smooth_app/lib/pages/product/add_basic_details/add_basic_details_name.dart +++ b/packages/smooth_app/lib/pages/product/add_basic_details/add_basic_details_name.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide Listener; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; @@ -10,6 +10,7 @@ import 'package:smooth_app/data_models/preferences/user_preferences.dart'; import 'package:smooth_app/database/dao_string_list.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/language_priority.dart'; import 'package:smooth_app/generic_lib/widgets/languages_selector.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; @@ -17,6 +18,7 @@ import 'package:smooth_app/generic_lib/widgets/smooth_text_form_field.dart'; import 'package:smooth_app/helpers/paint_helper.dart'; import 'package:smooth_app/helpers/provider_helper.dart'; import 'package:smooth_app/pages/input/debounced_text_editing_controller.dart'; +import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dart'; import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view.dart'; import 'package:smooth_app/pages/product/multilingual_helper.dart'; import 'package:smooth_app/pages/product/owner_field_info.dart'; @@ -24,7 +26,11 @@ import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; +import 'package:smooth_app/widgets/widget_height.dart'; +/// Widget to edit the product name in multiple languages class AddProductNameInputWidget extends StatefulWidget { const AddProductNameInputWidget({ required this.product, @@ -63,9 +69,10 @@ class _AddProductNameInputWidgetState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - const _ProductNameAddNewLanguage(), if (widget.product.hasOwnerField(ProductField.NAME_IN_LANGUAGES)) const OwnerFieldSmoothCardIcon(), + const _ProductNameAddNewLanguage(), + const _ProductNameExplanation(), ], ), contentPadding: EdgeInsets.zero, @@ -110,11 +117,14 @@ class _AddProductNameInputWidgetState extends State { _, ) { final int count = _collapsed - ? math.min(value.productNames.length, MIN_COLLAPSED_COUNT) + ? math.min( + value.productNames.length - value.addedLanguages.length, + MIN_COLLAPSED_COUNT) : value.productNames.length; - final bool collapsed = - _collapsed && value.productNames.length > MIN_COLLAPSED_COUNT; + final bool collapsed = _collapsed && + value.productNames.length - value.addedLanguages.length > + MIN_COLLAPSED_COUNT; return Column( children: [ @@ -131,7 +141,9 @@ class _AddProductNameInputWidgetState extends State { }, ), ), - if (value.addedLanguages.isNotEmpty && _collapsed) + if (value.addedLanguages.isNotEmpty && + _collapsed) ...[ + const _ProductNameNewTranslationWarning(), ...value.productNames .sublist(value.productNames.length - value.addedLanguages.length) @@ -148,6 +160,7 @@ class _AddProductNameInputWidgetState extends State { }, ), ), + ], if (collapsed) _ProductNameCollapsedSection( onTap: () => setState(() { @@ -250,7 +263,7 @@ class _ProductNameInputWidgetState extends State<_ProductNameInputWidget> { padding: const EdgeInsetsDirectional.only( top: BALANCED_SPACE, start: 11.0, - end: VERY_SMALL_SPACE, + end: 8.0, ), child: IconButtonTheme( data: const IconButtonThemeData( @@ -268,18 +281,23 @@ class _ProductNameInputWidgetState extends State<_ProductNameInputWidget> { children: [ AspectRatio( aspectRatio: 1.0, - child: CircleAvatar( - backgroundColor: lightTheme - ? extension.primaryMedium - : extension.primarySemiDark, - child: AutoSizeText( - widget.productName.language.offTag.toUpperCase(), - style: TextStyle( - color: lightTheme - ? extension.primaryDark - : extension.primaryLight, - fontWeight: FontWeight.w700, - fontSize: 17.0, + child: Tooltip( + message: Languages().getNameInEnglish( + widget.productName.language, + ), + child: CircleAvatar( + backgroundColor: lightTheme + ? extension.primaryMedium + : extension.primarySemiDark, + child: AutoSizeText( + widget.productName.language.offTag.toUpperCase(), + style: TextStyle( + color: lightTheme + ? extension.primaryDark + : extension.primaryLight, + fontWeight: FontWeight.w700, + fontSize: 17.0, + ), ), ), ), @@ -357,12 +375,14 @@ class _ProductNameCollapsedSection extends StatelessWidget { Widget build(BuildContext context) { final SmoothColorsThemeExtension extension = context.extension(); + final bool lightTheme = context.lightTheme(); final _ProductNameEditorProviderState state = context.watch().value; final int count = state.productNames.length - - _AddProductNameInputWidgetState.MIN_COLLAPSED_COUNT + - state.addedLanguages.length; + (math.min(state.productNames.length, + _AddProductNameInputWidgetState.MIN_COLLAPSED_COUNT) + + state.addedLanguages.length); return Column( mainAxisSize: MainAxisSize.min, @@ -381,7 +401,9 @@ class _ProductNameCollapsedSection extends StatelessWidget { bottomLeft: ROUNDED_RADIUS, bottomRight: ROUNDED_RADIUS, ), - color: extension.primaryLight, + color: lightTheme + ? extension.primaryLight + : extension.primarySemiDark, ), child: InkWell( onTap: onTap, @@ -394,7 +416,9 @@ class _ProductNameCollapsedSection extends StatelessWidget { vertical: SMALL_SPACE, ), child: icons.AppIconTheme( - color: extension.greyLight, + color: lightTheme + ? extension.greyMedium + : extension.primaryLight, size: 8.0, child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -410,7 +434,9 @@ class _ProductNameCollapsedSection extends StatelessWidget { style: TextStyle( fontSize: 15.0, fontStyle: FontStyle.italic, - color: extension.primaryDark, + color: lightTheme + ? extension.primaryDark + : extension.primaryMedium, ), ), const SizedBox(width: MEDIUM_SPACE), @@ -427,6 +453,208 @@ class _ProductNameCollapsedSection extends StatelessWidget { } } +class _ProductNameExplanation extends StatelessWidget { + const _ProductNameExplanation(); + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return ExplanationTitleIcon( + title: appLocalizations.add_basic_details_product_name_help_title, + margin: EdgeInsets.zero, + padding: EdgeInsets.zero, + safeArea: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExplanationBodyInfo( + text: appLocalizations.add_basic_details_product_name_help_info1, + ), + ExplanationGoodExamplesContainer( + items: [ + appLocalizations + .add_basic_details_product_name_help_good_examples_1, + appLocalizations + .add_basic_details_product_name_help_good_examples_2, + ], + ), + const SizedBox(height: SMALL_SPACE), + ExplanationBadExamplesContainer( + items: [ + appLocalizations + .add_basic_details_product_name_help_bad_examples_1_example, + appLocalizations + .add_basic_details_product_name_help_bad_examples_2_example, + ], + explanations: [ + appLocalizations + .add_basic_details_product_name_help_bad_examples_1_explanation, + appLocalizations + .add_basic_details_product_name_help_bad_examples_2_explanation, + ], + ), + const SizedBox(height: VERY_LARGE_SPACE), + ExplanationBodyInfo( + text: appLocalizations.add_basic_details_product_name_help_info2, + icon: false, + safeArea: true, + ), + ], + ), + ); + } +} + +class _ProductNameNewTranslationWarning extends StatefulWidget { + const _ProductNameNewTranslationWarning(); + + @override + State<_ProductNameNewTranslationWarning> createState() => + _ProductNameNewTranslationWarningState(); +} + +class _ProductNameNewTranslationWarningState + extends State<_ProductNameNewTranslationWarning> + with SingleTickerProviderStateMixin { + /// Animation to hide the banner + AnimationController? _controller; + Animation? _animation; + Size? _size; + + @override + Widget build(BuildContext context) { + if (!context.watch().showInputProductNameBanner()) { + return EMPTY_WIDGET; + } + + final SmoothColorsThemeExtension extension = + context.extension(); + final bool lightTheme = context.lightTheme(); + + double? height; + if (_size != null && _animation != null) { + height = _size!.height - _animation!.value; + } else { + height = _size?.height; + } + + return SizedBox( + width: _size?.width, + height: height, + child: MeasureSize( + onChange: (Size size) { + if (size != _size && _controller == null) { + _size = size; + } + }, + child: Padding( + padding: const EdgeInsetsDirectional.only(top: MEDIUM_SPACE), + child: Container( + color: lightTheme ? extension.greyLight : extension.primaryDark, + child: ClipRRect( + child: Stack( + children: [ + PositionedDirectional( + bottom: 0.0, + start: 0.0, + child: ExcludeSemantics( + child: Transform.translate( + offset: const Offset(-12.0, 07.0), + child: icons.Warning( + size: 55.0, + color: lightTheme + ? extension.greyMedium + : extension.primaryTone, + ), + ), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only( + start: 62.0, + top: 9.0, + bottom: BALANCED_SPACE, + end: 17.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextWithBoldParts( + text: AppLocalizations.of(context) + .add_basic_details_product_name_warning_translations, + textStyle: const TextStyle(height: 1.6), + ), + ), + const SizedBox(width: VERY_SMALL_SPACE), + DecoratedBox( + decoration: ShapeDecoration( + shape: const CircleBorder(), + color: lightTheme + ? extension.greyMedium + : extension.primaryTone, + ), + child: Material( + type: MaterialType.transparency, + child: Tooltip( + message: MaterialLocalizations.of(context) + .closeButtonTooltip, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _startAnimation, + child: const Padding( + padding: + EdgeInsetsDirectional.all(SMALL_SPACE), + child: icons.Close( + size: 10.0, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + void _startAnimation() { + if (_size == null) { + return; + } + + _controller = AnimationController( + duration: SmoothAnimationsDuration.medium, + vsync: this, + ) + ..addListener(() => setState(() {})) + ..addStatusListener((final AnimationStatus status) { + if (status == AnimationStatus.completed) { + context.read().hideInputProductNameBanner(); + } + }); + _animation = Tween(begin: 0.0, end: _size!.height).animate( + CurvedAnimation(curve: Curves.easeInOutCubic, parent: _controller!), + ); + _controller!.forward(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} + class ProductNameEditorProvider extends ValueNotifier<_ProductNameEditorProviderState> { ProductNameEditorProvider() diff --git a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_textfield.dart b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_textfield.dart index 568d255ff4d0..0e0dbec0f014 100644 --- a/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_textfield.dart +++ b/packages/smooth_app/lib/pages/product/edit_ocr/edit_ocr_textfield.dart @@ -15,8 +15,8 @@ import 'package:smooth_app/pages/product/edit_ocr/edit_ocr_page.dart'; import 'package:smooth_app/pages/product/edit_ocr/ocr_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/product/simple_input_widget.dart'; import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; class EditOCRTextField extends StatelessWidget { const EditOCRTextField({ diff --git a/packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart b/packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart index 99ff06a49a30..5eaa334a7a5e 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart @@ -27,12 +27,12 @@ import 'package:smooth_app/pages/product/nutrition_page/widgets/nutrition_facts_ import 'package:smooth_app/pages/product/nutrition_page/widgets/nutrition_serving_size.dart'; import 'package:smooth_app/pages/product/nutrition_page/widgets/nutrition_serving_switch.dart'; import 'package:smooth_app/pages/product/simple_input_number_field.dart'; -import 'package:smooth_app/pages/product/simple_input_widget.dart'; import 'package:smooth_app/pages/text_field_helper.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/resources/app_icons.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; import 'package:smooth_app/widgets/will_pop_scope.dart'; @@ -420,7 +420,7 @@ class _NutritionPageBodyState extends State<_NutritionPageBody> { setState(() => _nutrientToHighlight = nutrient); }, ), - ExplanationTitleIcon( + ExplanationTitleIcon.text( title: appLocalizations.edit_product_form_item_nutrition_facts_title, text: appLocalizations diff --git a/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_availability_container.dart b/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_availability_container.dart index b337c3cd8996..48805d7c6d17 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_availability_container.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_availability_container.dart @@ -4,9 +4,9 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/pages/product/nutrition_page/widgets/nutrition_container_helper.dart'; -import 'package:smooth_app/pages/product/simple_input_widget.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/widgets/smooth_dropdown.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; /// A toggle to indicate whether a product has nutrition facts. class NutritionAvailabilityContainer extends StatelessWidget { @@ -24,7 +24,7 @@ class NutritionAvailabilityContainer extends StatelessWidget { child: SmoothCardWithRoundedHeader( title: appLocalizations.nutrition_page_nutritional_info_title, leading: const icons.Milk.happy(), - trailing: ExplanationTitleIcon( + trailing: ExplanationTitleIcon.text( title: appLocalizations.nutrition_page_nutritional_info_title, text: appLocalizations.nutrition_page_nutritional_info_explanation, ), diff --git a/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_serving_size.dart b/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_serving_size.dart index 7720d373cf83..79d39bab0627 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_serving_size.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_serving_size.dart @@ -5,9 +5,9 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_text_form_field.dart'; -import 'package:smooth_app/pages/product/simple_input_widget.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/resources/app_animations.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; /// A toggle to indicate whether a product has nutrition facts. class NutritionServingSize extends StatelessWidget { @@ -42,7 +42,7 @@ class NutritionServingSize extends StatelessWidget { end: 4.0, bottom: 4.5, ), - trailing: ExplanationTitleIcon( + trailing: ExplanationTitleIcon.text( title: appLocalizations.nutrition_page_serving_size, text: appLocalizations.nutrition_page_serving_size_explanation, ), diff --git a/packages/smooth_app/lib/pages/product/product_page/footer/new_product_footer.dart b/packages/smooth_app/lib/pages/product/product_page/footer/new_product_footer.dart index fa91a6648ef8..0d993edabab1 100644 --- a/packages/smooth_app/lib/pages/product/product_page/footer/new_product_footer.dart +++ b/packages/smooth_app/lib/pages/product/product_page/footer/new_product_footer.dart @@ -99,7 +99,7 @@ class _ProductFooterButtonsBar extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20.0), ), - side: BorderSide(color: themeExtension.greyLight), + side: BorderSide(color: themeExtension.greyMedium), padding: const EdgeInsetsDirectional.symmetric( horizontal: 19.0, ), diff --git a/packages/smooth_app/lib/pages/product/simple_input_widget.dart b/packages/smooth_app/lib/pages/product/simple_input_widget.dart index 4904e9ff70df..fbf7dc8ca8ad 100644 --- a/packages/smooth_app/lib/pages/product/simple_input_widget.dart +++ b/packages/smooth_app/lib/pages/product/simple_input_widget.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:smooth_app/generic_lib/bottom_sheets/smooth_bottom_sheet.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; @@ -19,6 +18,7 @@ import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_explanation_banner.dart'; /// Simple input widget: we have a list of terms, we add, we remove. class SimpleInputWidget extends StatefulWidget { @@ -352,58 +352,6 @@ class _SimpleInputWidgetState extends State { } } -class ExplanationTitleIcon extends StatelessWidget { - const ExplanationTitleIcon({ - required this.title, - required this.text, - }) : - // ignore: avoid_field_initializers_in_const_classes - type = null; - - const ExplanationTitleIcon.type({ - required this.type, - required this.text, - }) : - // ignore: avoid_field_initializers_in_const_classes - title = null; - - final String? title; - final String? type; - final String text; - - @override - Widget build(BuildContext context) { - final String title = this.title ?? - AppLocalizations.of(context).edit_product_form_item_help(type!); - - return SmoothCardHeaderButton( - tooltip: title, - child: const icons.Help(), - onTap: () { - showSmoothModalSheet( - context: context, - builder: (BuildContext context) { - return SmoothModalSheet( - title: title, - prefixIndicator: true, - headerBackgroundColor: - SmoothCardWithRoundedHeaderTop.getHeaderColor( - context, - ), - body: SmoothModalSheetBodyContainer( - child: Align( - alignment: AlignmentDirectional.topStart, - child: Text(text), - ), - ), - ); - }, - ); - }, - ); - } -} - class _SimpleInputListItem extends StatefulWidget { const _SimpleInputListItem({ required this.term, diff --git a/packages/smooth_app/lib/themes/smooth_theme_colors.dart b/packages/smooth_app/lib/themes/smooth_theme_colors.dart index 2d7f0fdbe41a..d10867caabc7 100644 --- a/packages/smooth_app/lib/themes/smooth_theme_colors.dart +++ b/packages/smooth_app/lib/themes/smooth_theme_colors.dart @@ -21,6 +21,7 @@ class SmoothColorsThemeExtension required this.red, required this.greyDark, required this.greyNormal, + required this.greyMedium, required this.greyLight, required this.cellOdd, required this.cellEven, @@ -45,7 +46,8 @@ class SmoothColorsThemeExtension red = const Color(0xFFEB5757), greyDark = const Color(0xFF666666), greyNormal = const Color(0xFF6C6C6C), - greyLight = const Color(0xFF8F8F8F), + greyMedium = const Color(0xFF8F8F8F), + greyLight = const Color(0xFFE0E0E0), cellOdd = lightTheme ? const Color(0xFFFAF8F6) : const Color(0xFF2D251E), cellEven = @@ -84,8 +86,10 @@ class SmoothColorsThemeExtension final Color green; final Color orange; final Color red; + final Color greyDark; final Color greyNormal; + final Color greyMedium; final Color greyLight; final Color cellOdd; @@ -111,6 +115,7 @@ class SmoothColorsThemeExtension Color? red, Color? greyDark, Color? greyNormal, + Color? greyMedium, Color? greyLight, Color? cellOdd, Color? cellEven, @@ -134,6 +139,7 @@ class SmoothColorsThemeExtension red: red ?? this.red, greyDark: greyDark ?? this.greyDark, greyNormal: greyDark ?? this.greyDark, + greyMedium: greyMedium ?? this.greyMedium, greyLight: greyLight ?? this.greyLight, cellOdd: cellOdd ?? this.cellOdd, cellEven: cellEven ?? this.cellEven, @@ -240,6 +246,11 @@ class SmoothColorsThemeExtension other.greyNormal, t, )!, + greyMedium: Color.lerp( + greyMedium, + other.greyMedium, + t, + )!, greyLight: Color.lerp( greyLight, other.greyLight, diff --git a/packages/smooth_app/lib/widgets/smooth_explanation_banner.dart b/packages/smooth_app/lib/widgets/smooth_explanation_banner.dart new file mode 100644 index 000000000000..05f899e934f0 --- /dev/null +++ b/packages/smooth_app/lib/widgets/smooth_explanation_banner.dart @@ -0,0 +1,415 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:smooth_app/generic_lib/bottom_sheets/smooth_bottom_sheet.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/smooth_theme.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_text.dart'; + +class ExplanationTitleIcon extends StatelessWidget { + const ExplanationTitleIcon({ + required this.title, + required Widget child, + this.margin, + this.padding, + this.safeArea = true, + }) : + // ignore: avoid_field_initializers_in_const_classes + type = null, + _child = child; + + ExplanationTitleIcon.text({ + required this.title, + required String text, + }) : + // ignore: avoid_field_initializers_in_const_classes + type = null, + margin = null, + padding = null, + safeArea = true, + _child = Text(text); + + ExplanationTitleIcon.type({ + required this.type, + required String text, + }) : + // ignore: avoid_field_initializers_in_const_classes + title = null, + margin = null, + padding = null, + safeArea = true, + _child = Text(text); + + final String? title; + final String? type; + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? padding; + final Widget _child; + final bool safeArea; + + @override + Widget build(BuildContext context) { + final String title = this.title ?? + AppLocalizations.of(context).edit_product_form_item_help(type!); + + return SmoothCardHeaderButton( + tooltip: title, + child: const icons.Help(), + onTap: () { + showSmoothModalSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return SmoothModalSheet( + title: title, + prefixIndicator: true, + headerBackgroundColor: + SmoothCardWithRoundedHeaderTop.getHeaderColor( + context, + ), + bodyPadding: margin, + body: SmoothModalSheetBodyContainer( + padding: padding, + safeArea: safeArea, + child: Align( + alignment: AlignmentDirectional.topStart, + child: _child, + ), + ), + ); + }, + ); + }, + ); + } +} + +class ExplanationBodyTitle extends StatelessWidget { + const ExplanationBodyTitle({ + required this.label, + super.key, + }); + + final String label; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + final bool lightTheme = context.lightTheme(); + + return Padding( + padding: const EdgeInsetsDirectional.only( + top: SMALL_SPACE, + bottom: MEDIUM_SPACE, + ), + child: ColoredBox( + color: extension.primaryLight, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: LARGE_SPACE, + vertical: SMALL_SPACE, + ), + child: Row( + children: [ + SmoothModalSheetHeaderPrefixIndicator( + color: lightTheme + ? extension.primaryUltraBlack + : extension.primaryLight, + ), + const SizedBox(width: SMALL_SPACE), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class ExplanationBodyInfo extends StatelessWidget { + const ExplanationBodyInfo({ + required this.text, + this.icon = true, + this.safeArea = false, + }); + + final String text; + final bool icon; + final bool safeArea; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + final bool lightTheme = context.lightTheme(); + + return ColoredBox( + color: lightTheme ? extension.primaryMedium : extension.primaryTone, + child: ClipRect( + child: Padding( + padding: EdgeInsetsDirectional.only( + bottom: safeArea ? MediaQuery.viewPaddingOf(context).bottom : 0.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (icon) + Align( + alignment: AlignmentDirectional.bottomCenter, + child: icons.AppIconTheme( + color: lightTheme + ? extension.primaryNormal + : extension.primaryMedium, + child: Transform.translate( + offset: const Offset(-17.0, 09.0), + child: const icons.Info(size: 55.0), + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: icon ? SMALL_SPACE : LARGE_SPACE, + end: LARGE_SPACE, + top: MEDIUM_SPACE, + bottom: MEDIUM_SPACE, + ), + child: TextWithBoldParts( + text: text, + textStyle: TextStyle( + color: lightTheme ? extension.primaryDark : Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class ExplanationGoodExamplesContainer extends StatelessWidget { + const ExplanationGoodExamplesContainer({required this.items, super.key}) + : assert(items.length > 0); + + final List items; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + + return Column( + children: [ + _ExplanationContainerTitle( + label: AppLocalizations.of(context).explanation_section_good_examples, + foregroundColor: Colors.white, + backgroundColor: extension.success, + ), + ...items.map( + (String item) => _ExplanationBodyListItem( + icon: const icons.Check(size: 11.0), + iconBackgroundColor: extension.success, + iconPadding: EdgeInsets.zero, + example: item, + ), + ), + ], + ); + } +} + +class ExplanationBadExamplesContainer extends StatelessWidget { + const ExplanationBadExamplesContainer( + {required this.items, required this.explanations, super.key}) + : assert(items.length > 0), + assert(items.length == explanations.length); + + final List items; + final List explanations; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + + return Column( + children: [ + _ExplanationContainerTitle( + label: AppLocalizations.of(context).explanation_section_bad_examples, + foregroundColor: Colors.white, + backgroundColor: extension.error, + ), + ...items.mapIndexed( + (int position, String item) => _ExplanationBodyListItem( + icon: const icons.Close(size: 11.0), + iconBackgroundColor: extension.error, + iconPadding: EdgeInsetsDirectional.zero, + example: item, + explanation: explanations[position], + ), + ), + ], + ); + } +} + +class _ExplanationContainerTitle extends StatelessWidget { + const _ExplanationContainerTitle({ + required this.label, + required this.foregroundColor, + required this.backgroundColor, + }); + + final String label; + final Color foregroundColor; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only( + start: LARGE_SPACE, + end: LARGE_SPACE, + top: MEDIUM_SPACE, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: ANGULAR_BORDER_RADIUS, + ), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: LARGE_SPACE, + vertical: SMALL_SPACE, + ), + child: Row( + children: [ + SmoothModalSheetHeaderPrefixIndicator( + color: foregroundColor, + ), + const SizedBox(width: LARGE_SPACE), + Expanded( + child: Text( + label, + style: TextStyle( + color: foregroundColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ExplanationBodyListItem extends StatelessWidget { + const _ExplanationBodyListItem({ + required this.icon, + required this.iconBackgroundColor, + required this.iconPadding, + required this.example, + this.explanation, + }); + + final Widget icon; + final Color iconBackgroundColor; + final EdgeInsetsGeometry iconPadding; + final String example; + final String? explanation; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + final bool lightTheme = context.lightTheme(); + + return Padding( + padding: const EdgeInsetsDirectional.only( + start: 25.0, + end: 25.0, + top: 10.0, + ), + child: Row( + crossAxisAlignment: explanation == null + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + SizedBox.square( + dimension: 24.0, + child: DecoratedBox( + decoration: ShapeDecoration( + shape: const CircleBorder(), + color: iconBackgroundColor, + ), + child: Padding( + padding: iconPadding, + child: icon, + ), + ), + ), + SizedBox(width: explanation != null ? 11.0 : 13.0), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (explanation != null) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(start: 2.0), + child: Text( + explanation!, + style: TextStyle( + color: lightTheme + ? extension.primaryDark + : extension.primaryLight, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: VERY_SMALL_SPACE), + ], + DecoratedBox( + decoration: BoxDecoration( + color: lightTheme + ? extension.primaryLight + : extension.primaryMedium, + borderRadius: ROUNDED_BORDER_RADIUS, + ), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: MEDIUM_SPACE, + vertical: BALANCED_SPACE, + ), + child: TextWithBoldParts( + text: example, + textStyle: const TextStyle(color: Colors.black), + ), + ), + ) + ], + ) + ], + ), + ); + } +} diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index c988f5f044a5..1568f2d52689 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -1914,4 +1914,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.6.1 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.27.0"