From a0396703631f4bad90ba20e17f6b0006c3018082 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Fri, 4 Aug 2023 16:11:59 +0200 Subject: [PATCH] feat: 4223 - comparison page for 3 random products on dev mode New file: * `compare_products3_page.dart`: Test page about comparing 3 products. Work in progress. Impacted files: * `attributes_card_helper.dart`: refactored introducing two new reusable methods * `nutrition_container.dart`: refactored introducing one reusable getter * `product_cards_helper.dart`: refactored introducing one new reusable method * `user_preferences_dev_mode.dart`: added an item computing 3 random products and showing the product comparison page --- .../lib/helpers/attributes_card_helper.dart | 40 +- .../lib/helpers/product_cards_helper.dart | 30 +- .../user_preferences_dev_mode.dart | 66 ++++ .../pages/product/compare_products3_page.dart | 352 ++++++++++++++++++ .../pages/product/nutrition_container.dart | 2 + 5 files changed, 474 insertions(+), 16 deletions(-) create mode 100644 packages/smooth_app/lib/pages/product/compare_products3_page.dart diff --git a/packages/smooth_app/lib/helpers/attributes_card_helper.dart b/packages/smooth_app/lib/helpers/attributes_card_helper.dart index c24cdb318ed..69e43731edf 100644 --- a/packages/smooth_app/lib/helpers/attributes_card_helper.dart +++ b/packages/smooth_app/lib/helpers/attributes_card_helper.dart @@ -32,17 +32,35 @@ enum AttributeEvaluation { } } -Widget getAttributeDisplayIcon(final Attribute attribute) => Padding( - padding: const EdgeInsetsDirectional.only(end: VERY_SMALL_SPACE), - child: _attributeMatchComparison( - attribute, - const Icon(CupertinoIcons.question, color: RED_COLOR), - const Icon(Icons.lens, color: RED_COLOR), - const Icon(Icons.lens, color: LIGHT_ORANGE_COLOR), - const Icon(Icons.lens, color: LIGHT_ORANGE_COLOR), - const Icon(Icons.lens, color: LIGHT_GREEN_COLOR), - const Icon(Icons.lens, color: LIGHT_GREEN_COLOR), - ), +Widget getAttributeDisplayIcon(final Attribute attribute) { + final Color color = getAttributeDisplayBackgroundColor(attribute); + final IconData iconData = getAttributeDisplayIconData(attribute); + return Padding( + padding: const EdgeInsetsDirectional.only(end: VERY_SMALL_SPACE), + child: Icon(iconData, color: color), + ); +} + +Color getAttributeDisplayBackgroundColor(final Attribute attribute) => + _attributeMatchComparison( + attribute, + RED_COLOR, + RED_COLOR, + LIGHT_ORANGE_COLOR, + LIGHT_ORANGE_COLOR, + LIGHT_GREEN_COLOR, + LIGHT_GREEN_COLOR, + ); + +IconData getAttributeDisplayIconData(final Attribute attribute) => + _attributeMatchComparison( + attribute, + CupertinoIcons.question, + Icons.lens, + Icons.lens, + Icons.lens, + Icons.lens, + Icons.lens, ); bool isMatchAvailable(Attribute attribute) { diff --git a/packages/smooth_app/lib/helpers/product_cards_helper.dart b/packages/smooth_app/lib/helpers/product_cards_helper.dart index 34b4ba7760e..75b9666e1ac 100644 --- a/packages/smooth_app/lib/helpers/product_cards_helper.dart +++ b/packages/smooth_app/lib/helpers/product_cards_helper.dart @@ -95,7 +95,24 @@ List getMandatoryAttributes( final List attributeGroupOrder, final Set attributesToExcludeIfStatusIsUnknown, final ProductPreferences preferences, -) { +) => + getSortedAttributes( + product, + attributeGroupOrder, + attributesToExcludeIfStatusIsUnknown, + preferences, + PreferenceImportance.ID_MANDATORY, + ); + +/// Returns the attributes, ordered by importance desc and attribute group order +List getSortedAttributes( + final Product product, + final List attributeGroupOrder, + final Set attributesToExcludeIfStatusIsUnknown, + final ProductPreferences preferences, + final String importance, { + final bool excludeMainScoreAttributes = true, +}) { final List result = []; if (product.attributeGroups == null) { return result; @@ -106,9 +123,10 @@ List getMandatoryAttributes( for (final AttributeGroup attributeGroup in product.attributeGroups!) { mandatoryAttributesByGroup[attributeGroup.id!] = getFilteredAttributes( attributeGroup, - PreferenceImportance.ID_MANDATORY, + importance, attributesToExcludeIfStatusIsUnknown, preferences, + excludeMainScoreAttributes: excludeMainScoreAttributes, ); } @@ -131,15 +149,17 @@ List getFilteredAttributes( final AttributeGroup attributeGroup, final String importance, final Set attributesToExcludeIfStatusIsUnknown, - final ProductPreferences preferences, -) { + final ProductPreferences preferences, { + final bool excludeMainScoreAttributes = true, +}) { final List result = []; if (attributeGroup.attributes == null) { return result; } for (final Attribute attribute in attributeGroup.attributes!) { final String attributeId = attribute.id!; - if (SCORE_ATTRIBUTE_IDS.contains(attributeId)) { + if (excludeMainScoreAttributes && + SCORE_ATTRIBUTE_IDS.contains(attributeId)) { continue; } if (attributeGroup.id == AttributeGroup.ATTRIBUTE_GROUP_LABELS) { diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart index 7206292b126..74ce8a4ae39 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -8,6 +10,7 @@ import 'package:smooth_app/background/background_task_badge.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/data_models/user_preferences.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/generic_lib/dialogs/smooth_alert_dialog.dart'; @@ -18,6 +21,8 @@ import 'package:smooth_app/pages/offline_tasks_page.dart'; import 'package:smooth_app/pages/preferences/abstract_user_preferences.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dev_debug_info.dart'; import 'package:smooth_app/pages/preferences/user_preferences_page.dart'; +import 'package:smooth_app/pages/product/compare_products3_page.dart'; +import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; import 'package:smooth_app/query/product_query.dart'; /// Full page display of "dev mode" for the preferences page. @@ -338,6 +343,67 @@ class UserPreferencesDevMode extends AbstractUserPreferences { ProductQuery.setLanguage(context, userPreferences); }, ), + ListTile( + title: const Text('Side by side comparison: 3 random products'), + onTap: () async { + final LocalDatabase localDatabase = context.read(); + final DaoProduct daoProduct = DaoProduct(localDatabase); + final List allBarcodes = await daoProduct.getAllKeys(); + final int length = allBarcodes.length; + if (length < 3) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'We need at least 3 products in the local database!', + ), + ), + ); + } + return; + } + final Random random = Random(); + final int index1 = random.nextInt(length); + final int index2 = (index1 + 1) % length; + final int index3 = (index1 + 2) % length; + final Product product1 = + (await daoProduct.get(allBarcodes[index1]))!; + final Product product2 = + (await daoProduct.get(allBarcodes[index2]))!; + final Product product3 = + (await daoProduct.get(allBarcodes[index3]))!; + if (context.mounted) { + final OrderedNutrientsCache? cache = + await OrderedNutrientsCache.getCache(context); + if (context.mounted) { + if (cache == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context) + .nutrition_cache_loading_error, + ), + ), + ); + return; + } + await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => CompareProducts3Page( + products: [ + product1, + product2, + product3, + ], + orderedNutrientsCache: cache, + ), + ), + ); + } + } + }, + ), ListTile( title: const Text('Debugging information'), onTap: () async => Navigator.of(context).push(MaterialPageRoute( diff --git a/packages/smooth_app/lib/pages/product/compare_products3_page.dart b/packages/smooth_app/lib/pages/product/compare_products3_page.dart new file mode 100644 index 00000000000..5e5041d6e56 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/compare_products3_page.dart @@ -0,0 +1,352 @@ +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/data_models/product_preferences.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/svg_icon_chip.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_product_image.dart'; +import 'package:smooth_app/helpers/attributes_card_helper.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/helpers/product_compatibility_helper.dart'; +import 'package:smooth_app/pages/product/nutrition_container.dart'; +import 'package:smooth_app/pages/product/ordered_nutrients_cache.dart'; +import 'package:smooth_app/widgets/smooth_app_bar.dart'; +import 'package:smooth_app/widgets/smooth_scaffold.dart'; + +// cf. SummaryCard +const List _ATTRIBUTE_GROUP_ORDER = [ + AttributeGroup.ATTRIBUTE_GROUP_ALLERGENS, + AttributeGroup.ATTRIBUTE_GROUP_INGREDIENT_ANALYSIS, + AttributeGroup.ATTRIBUTE_GROUP_PROCESSING, + AttributeGroup.ATTRIBUTE_GROUP_NUTRITIONAL_QUALITY, + AttributeGroup.ATTRIBUTE_GROUP_LABELS, + AttributeGroup.ATTRIBUTE_GROUP_ENVIRONMENT, +]; + +/// Test page about comparing 3 products. Work in progress. +class CompareProducts3Page extends StatefulWidget { + const CompareProducts3Page({ + required this.products, + required this.orderedNutrientsCache, + }); + + final List products; + final OrderedNutrientsCache orderedNutrientsCache; + + @override + State createState() => _CompareProducts3PageState(); +} + +class _CompareProducts3PageState extends State { + final Set _attributesToExcludeIfStatusIsUnknown = {}; + + static const List _sortedImportances = [ + PreferenceImportance.ID_MANDATORY, + PreferenceImportance.ID_VERY_IMPORTANT, + PreferenceImportance.ID_IMPORTANT, + ]; + + final List _nutritionContainers = []; + + @override + void initState() { + super.initState(); + for (final Product product in widget.products) { + _nutritionContainers.add( + NutritionContainer( + orderedNutrients: widget.orderedNutrientsCache.orderedNutrients, + product: product, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + context.watch(); + + final bool darkMode = Theme.of(context).brightness == Brightness.dark; + + final ProductPreferences productPreferences = + context.watch(); + final List> scoreAttributesArray = >[]; + final List scoreWidgets = []; + for (final Product product in widget.products) { + final MatchedProductV2 matchedProduct = MatchedProductV2( + product, + productPreferences, + ); + final ProductCompatibilityHelper helper = + ProductCompatibilityHelper.product(matchedProduct); + scoreWidgets.add( + Expanded( + child: Container( + color: helper.getHeaderBackgroundColor(darkMode), + child: Center( + child: Text( + matchedProduct.score.toInt().toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ); + } + + final List names = []; + final List brands = []; + final List quantities = []; + final List pictures = []; + final Size screenSize = MediaQuery.of(context).size; + for (final Product product in widget.products) { + names.add(getProductName(product, appLocalizations)); + brands.add(getProductBrands(product, appLocalizations)); + quantities.add(product.quantity ?? ''); + pictures.add(Expanded( + child: Center( + child: SmoothMainProductImage( + product: product, + width: screenSize.width * 0.20, + height: screenSize.width * 0.20, + ), + ), + )); + } + for (final Product product in widget.products) { + final List tmp = []; + for (final String importance in _sortedImportances) { + final List attributes = getSortedAttributes( + product, + _ATTRIBUTE_GROUP_ORDER, + _attributesToExcludeIfStatusIsUnknown, + productPreferences, + importance, + excludeMainScoreAttributes: false, + ); + tmp.addAll(attributes); + } + scoreAttributesArray.add(tmp); + } + + final List nutrientValues = []; + final NutritionContainer backBone = _nutritionContainers.first; + for (final OrderedNutrient orderedNutrient in backBone.allNutrients) { + final Nutrient nutrient = _getNutrient(orderedNutrient); + final List values = []; + bool notNull = false; + for (final NutritionContainer nutritionContainer + in _nutritionContainers) { + final double? value = nutritionContainer.getValue(nutrient); + values.add(value); + if (value != null) { + notNull = true; + } + } + if (notNull) { + nutrientValues.add( + _getNutrientRow( + values: values, + nutrient: nutrient, + ), + ); + } + } + return SmoothScaffold( + contentBehindStatusBar: true, + spaceBehindStatusBar: false, + statusBarBackgroundColor: SmoothScaffold.semiTranslucentStatusBar, + appBar: SmoothAppBar( + title: Text('Compare ${widget.products.length} products'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + const Center(child: Text('Personal compatibility score')), + _getWidgetRow(scoreWidgets), + _getTextRow(names), + const SizedBox(height: 8.0), + _getTextRow(brands), + const SizedBox(height: 8.0), + _getTextRow(quantities), + _getWidgetRow(pictures), + const Divider(), + for (int i = 0; i < scoreAttributesArray.first.length; i++) + _getAttributeRow( + attributesArray: scoreAttributesArray, + index: i, + products: widget.products, + ), + ...nutrientValues, + ], + ), + ), + ); + } + + Row _getTextRow(final List texts) => _getWidgetRow( + [ + for (final String text in texts) Expanded(child: Text(text)), + ], + ); + + Row _getWidgetRow(final List widgets) { + final List children = []; + bool first = true; + for (final Widget widget in widgets) { + if (first) { + first = false; + } else { + children.add(const VerticalDivider()); + } + children.add(widget); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); + } + + Nutrient? _getAttributeNutrient(final String attributeId) { + switch (attributeId) { + case Attribute.ATTRIBUTE_LOW_FAT: + return Nutrient.fat; + case Attribute.ATTRIBUTE_LOW_SATURATED_FAT: + return Nutrient.saturatedFat; + case Attribute.ATTRIBUTE_LOW_SALT: + return Nutrient.salt; + case Attribute.ATTRIBUTE_LOW_SUGARS: + return Nutrient.sugars; + } + return null; + } + + Widget? _getChild( + final Attribute attribute, + final Product product, + ) { + final Nutrient? nutrient = _getAttributeNutrient(attribute.id!); + if (nutrient != null) { + if (product.nutriments == null) { + return null; + } + final double? value = + product.nutriments!.getValue(nutrient, PerSize.oneHundredGrams); + if (value == null) { + return null; + } + return Text( + '${value.toStringAsFixed(2)} ${UnitHelper.unitToString(nutrient.typicalUnit)}', + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ); + } + switch (attribute.id) { + case Attribute.ATTRIBUTE_NOVA: + case Attribute.ATTRIBUTE_NUTRISCORE: + case Attribute.ATTRIBUTE_ECOSCORE: + return SvgIconChip(attribute.iconUrl!, height: 30); + } + return null; + } + + Widget _getAttributeRow({ + required final List> attributesArray, + required final int index, + required final List products, + }) { + final List children = []; + late String title; + for (int i = 0; i < widget.products.length; i++) { + final Attribute attribute = attributesArray[i][index]; + title = attribute.name!; + final Product product = products[i]; + Widget? child = _getChild(attribute, product); + child = Expanded( + child: Container( + height: 36, + color: getAttributeDisplayBackgroundColor(attribute), + child: child, + ), + ); + final bool first = children.isEmpty; + if (!first) { + children.add(const VerticalDivider()); + } + children.add(child); + } + return Column( + children: [ + const Divider(), + Padding( + padding: const EdgeInsets.only(top: SMALL_SPACE), + child: AutoSizeText( + '$title (?)', + maxLines: 2, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: children, + ), + ], + ); + } + + Widget _getNutrientRow({ + required final List values, + required final Nutrient nutrient, + }) { + final List children = []; + for (final double? value in values) { + Widget? child = value == null + ? null + : Center( + child: Text( + '${value.toStringAsFixed(2)} ${UnitHelper.unitToString(nutrient.typicalUnit)}', + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ); + child = Expanded( + child: SizedBox( + height: 36, + child: child, + ), + ); + children.add(child); + } + return Column( + children: [ + const Divider(), + Padding( + padding: const EdgeInsets.only(top: SMALL_SPACE), + child: AutoSizeText(nutrient.name, maxLines: 2), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: children, + ), + ], + ); + } + + Nutrient _getNutrient(final OrderedNutrient orderedNutrient) { + if (orderedNutrient.nutrient != null) { + return orderedNutrient.nutrient!; + } + if (orderedNutrient.id == 'energy') { + return Nutrient.energyKJ; + } + throw Exception('unknown nutrient for "${orderedNutrient.id}"'); + } +} diff --git a/packages/smooth_app/lib/pages/product/nutrition_container.dart b/packages/smooth_app/lib/pages/product/nutrition_container.dart index 46bce5afa9d..f56ec31dce7 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_container.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_container.dart @@ -38,6 +38,8 @@ class NutritionContainer { /// All the nutrients (country-related) that do match [Nutrient]s. final List _nutrients = []; + List get allNutrients => _nutrients; + /// Nutrient values. final Map _values = {};