Skip to content

feat: Nutrition facts: check values before sending them #4446 #6562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from

Conversation

LorenzoMascia
Copy link
Contributor

@LorenzoMascia LorenzoMascia commented Apr 24, 2025

Changes:
modified: packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart
new file: packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart
modified: packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_facts_editor.dart
modified: packages/smooth_app/lib/pages/text_field_helper.dart

What

Created a class to perform nutrient validation for each product. The NutritionValidator class normalizes the input values based on the weight of the Serving Size, converting them into values relative to 100g or 100ml. The normalized values are then compared to the maximum values typically found in food products. The values are verified and displayed in red when the save button is pressed.

Screenshot

Screenshot 2025-04-24 153859

Fixes bug(s)

Enhancement: Nutrition facts: check values before sending them #4446

Part of

@codecov-commenter
Copy link

codecov-commenter commented Apr 25, 2025

Codecov Report

Attention: Patch coverage is 0% with 54 lines in your changes missing coverage. Please review.

Project coverage is 5.72%. Comparing base (4d9c7fc) to head (8a15f1c).
Report is 881 commits behind head on develop.

Files with missing lines Patch % Lines
...es/product/nutrition_page/nutrition_validator.dart 0.00% 30 Missing ⚠️
...b/pages/product/nutrition_page/nutrition_page.dart 0.00% 14 Missing ⚠️
...nutrition_page/widgets/nutrition_facts_editor.dart 0.00% 6 Missing ⚠️
...ckages/smooth_app/lib/pages/text_field_helper.dart 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           develop   #6562      +/-   ##
==========================================
- Coverage     9.54%   5.72%   -3.83%     
==========================================
  Files          325     511     +186     
  Lines        16411   30462   +14051     
==========================================
+ Hits          1567    1744     +177     
- Misses       14844   28718   +13874     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@monsieurtanuki monsieurtanuki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @LorenzoMascia!
Please have a look at my comments.
Remember also that I'm expecting a screenshot in #6555.

Comment on lines 12 to 14
switch (nutrient) {
case Nutrient.addedSugars:
return _validateAddedSugars(quantity, unit, servingSize);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not very elegant.
An extension on Nutrient would be much better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the code using a Map. Please, let me know if in this way it's better.

}
}

bool _validateAddedSugars(double q, Unit u, double s) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can afford to use words instead of letters for variables.
A computer wouldn't make the difference. A reviewer and a developer would.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I appreciate the suggestion.

}

bool _validateAddedSugars(double q, Unit u, double s) =>
_validateMax(_normalize(q, u, Unit.G, s), 50.0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea where you picked the min/max values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I obtained the values as the maximum possible values in foods. Some must simply be less than the weight, such as carbohydrate or sugar content, but in other cases, like zinc or calcium, it's not possible to simply assume a content less to the weight. The check prevents also potential errors in the use of measurement units, like 50g instead of 50mg

}
// Energy conversions
if (from == Unit.KCAL && to == Unit.KJ) {
return 4.184;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use NutrimentsHelper.fromKCalToKJ and NutrimentsHelper.fromKJtoKCal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure on how to use them, This method does not converts values but returns conversion factors.

return 1.0;
}

double? _parseDouble(String? input) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there are other places in the code where we parse a double. Please don't reinvent the wheel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the name of this method because it does not simply parse a double but it also reads the unit specified in the input string

Comment on lines 255 to 260
String servingSize = '100.0';

if (_nutritionContainer.perSize == PerSize.serving) {
servingSize = _nutritionContainer.servingSize;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you want to do here, but a cleaner syntax would be:

Suggested change
String servingSize = '100.0';
if (_nutritionContainer.perSize == PerSize.serving) {
servingSize = _nutritionContainer.servingSize;
}
final String servingSize;
if (_nutritionContainer.perSize == PerSize.serving) {
servingSize = _nutritionContainer.servingSize;
} else {
servingSize = '100.0';
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scope is to obtain the serving size weight. There are 2 options: per serving and per 100 g/ml

Screenshot 2025-04-27 191707

Screenshot 2025-04-27 191722

In the first case the weight is specified in the serving size field (and the user can specify the unit).

final TextEditingControllerWithHistory controller =
_controllers[nutrient]!;
final String quantity = controller.text.trim();
final Unit u = _nutritionContainer.getUnit(nutrient);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PLEASE USE WORDS!

Suggested change
final Unit u = _nutritionContainer.getUnit(nutrient);
final Unit unit = _nutritionContainer.getUnit(nutrient);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it

return 1.0;
}
// Mass conversions
if (from == Unit.G && to == Unit.MILLI_G) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure there are conversion methods somewhere else in the app.
Please use them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did the search looking for all occurrences of the Unit enum. I didn't find a similar method.

LorenzoMascia added a commit to LorenzoMascia/smooth-app that referenced this pull request Apr 27, 2025
…#4446 openfoodfacts#6562 Fixes after first review

Changes:
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart
@LorenzoMascia LorenzoMascia force-pushed the feat/check-values-before-seding#4446 branch 3 times, most recently from 26aa31b to 22c6dd4 Compare April 28, 2025 17:33
@monsieurtanuki
Copy link
Contributor

Hi @LorenzoMascia!
Conflicts to be resolved. More generally speaking, I suggest that your code format matches the project format (basically, comma endings).

@teolemon
Copy link
Member

/gemini review

@teolemon teolemon requested a review from Copilot May 16, 2025 19:32
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds client-side validation of nutrition facts to ensure values normalized per 100 g/ml don’t exceed typical maxima, highlights invalid inputs, and blocks saving invalid data.

  • Introduces NutritionValidator for parsing, normalizing, and checking nutrient values.
  • Extends TextEditingControllerWithHistory with a validity flag and updates editor styling to visually flag invalid entries.
  • Integrates validation logic in NutritionPage and replaces custom question dialogs with standard Navigator.push flows.

Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/smooth_app/lib/pages/text_field_helper.dart Added _valid flag, getter/setter to track input validity.
packages/smooth_app/lib/pages/product/nutrition_page Imported and integrated NutritionValidator to block invalid saves.
packages/smooth_app/lib/pages/product/nutrition_validator.dart New class normalizing inputs and enforcing nutrient maxima.
packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_facts_editor.dart Updated text field style to show invalid state and reset on change.
packages/smooth_app/lib/pages/product/product_questions_widget.dart & user_preferences_contribute.dart Switched custom dialogs to Navigator.push.
packages/smooth_app/lib/pages/hunger_games/*.dart Renamed dialog classes to pages, refactored UI and state.
Comments suppressed due to low confidence (5)

packages/smooth_app/lib/pages/text_field_helper.dart:13

  • [nitpick] The property _valid and its accessor valid don’t follow Dart boolean naming conventions. Consider renaming to isValid for clarity.
bool _valid = true;

packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart:4

  • This map is mutable at runtime. Consider declaring it as a static const map to prevent accidental modifications.
final Map<Nutrient, double> nutrientMaxContentPer100g = <Nutrient, double>{

packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart:4

  • [nitpick] The map contains many hardcoded numeric limits. Extract these magic numbers into named constants or an external config for readability.
final Map<Nutrient, double> nutrientMaxContentPer100g = <Nutrient, double>{

packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart:262

  • [nitpick] The variable error is ambiguous. Rename it to something like hasValidationError to convey its purpose more clearly.
bool error = false;

packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart:1

  • There are no unit tests for NutritionValidator. Add tests to cover parsing edge cases, unit conversions, and validation logic.
import 'package:openfoodfacts/openfoodfacts.dart';


input = input.trim().toLowerCase();

final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
Copy link
Preview

Copilot AI May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex doesn’t match values starting with a decimal point (e.g. ".5"). Update it to allow optional leading digits or a leading dot.

Suggested change
final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExp regex = RegExp(r'^([0-9]*\.?[0-9]+)\s*([a-zA-Z%]+)?$');

Copilot uses AI. Check for mistakes.

Comment on lines 154 to 172
input = input.trim().toLowerCase();

final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExpMatch? match = regex.firstMatch(input);

if (match == null) {
throw FormatException('Invalid input format: $input');
}

final double value = double.parse(match.group(1)!);

// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';

// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;

// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
Copy link
Preview

Copilot AI May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing errors bubble up as exceptions and can crash validation. Wrap parsing in try/catch and return false to mark invalid input instead of throwing.

Suggested change
input = input.trim().toLowerCase();
final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExpMatch? match = regex.firstMatch(input);
if (match == null) {
throw FormatException('Invalid input format: $input');
}
final double value = double.parse(match.group(1)!);
// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';
// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;
// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
try {
input = input.trim().toLowerCase();
final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExpMatch? match = regex.firstMatch(input);
if (match == null) {
return null; // Invalid format
}
final double value = double.parse(match.group(1)!);
// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';
// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;
// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
} catch (e) {
return null; // Handle any parsing errors
}

Copilot uses AI. Check for mistakes.

@@ -249,6 +250,42 @@ class _NutritionPageLoadedState extends State<NutritionPageLoaded>
}
}

final NutritionValidator validator = NutritionValidator();
Copy link
Preview

Copilot AI May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] A new NutritionValidator is created on each save. Consider reusing a single instance to reduce allocations.

Copilot uses AI. Check for mistakes.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces nutrition facts validation and refactors the Hunger Games question UI. The validation logic is a great step towards improving data quality. The Hunger Games UI changes seem to modernize its presentation.

Summary of Findings

  • Unhandled Exception Risk in Validator: NutritionValidator._parseValueWithUnit can throw a FormatException if input strings are malformed. This is not caught in nutrition_page.dart when validator.validate is called, potentially leading to a crash during the save process. This is marked as a critical issue.
  • Hunger Games Question Count: The refactored QuestionsPage uses a local integer _questionsAnswered for the pop result, but CongratsWidget still has a ChangeNotifierProvider that is never updated. This will likely result in CongratsWidget always showing 0 answered questions. This is marked as a high severity issue.
  • Validator Robustness: _parseValueWithUnit could be made internally safer by handling potential FormatException from double.parse and returning null. Also, the fallback unit conversion factor of 1.0 in _unitConversionFactor might hide issues with unsupported unit pairs. These are marked as medium severity.

Merge Readiness

The pull request introduces important validation features and refactors parts of the UI. However, due to the critical issue in NutritionValidator's error handling and the high-severity bug in the Hunger Games question counting, I recommend that these changes not be merged until these issues are addressed. Once these are resolved, the PR will be in much better shape. I am not authorized to approve pull requests, so please ensure further review and approval by authorized team members after addressing the feedback.

Comment on lines 264 to 281
for (final Nutrient nutrient in _controllers.keys) {
final TextEditingControllerWithHistory controller =
_controllers[nutrient]!;
final String quantity = controller.text.trim();
final Unit unit = _nutritionContainer.getUnit(nutrient);

if (!validator.validate(nutrient, quantity, unit, servingSize)) {
error = true;
controller.valid = false;
} else {
controller.valid = true;
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The validator.validate call might throw an unhandled exception if _parseValueWithUnit encounters a malformed numeric string that double.parse cannot handle. This could lead to a crash or prevent saving. Consider making _parseValueWithUnit more robust by catching FormatExceptions and returning null instead. This would allow the existing if ((quantity == null) || (servingSize == null)) { return true; } logic in validate to handle malformed inputs gracefully.

try {
      if (!validator.validate(nutrient, quantity, unit, servingSize)) {
        error = true;
        controller.valid = false;
      } else {
        controller.valid = true;
      }
    } catch (e) {
      // Handle the exception, possibly setting error = true and logging the error
      error = true;
      controller.valid = false; // Or some other appropriate error handling
      print('Validation error: $e');
    }

title: const Text('Hunger Games'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(_questionsAnswered),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Navigator.of(context).pop(_questionsAnswered) call is using the local _questionsAnswered variable, which is good. However, the ChangeNotifierProvider<_QuestionsAnsweredNotifier> is still present but its value is never updated. Is this notifier still needed, or can it be removed to avoid confusion?

Navigator.of(context).pop(_questionsAnswered);

Comment on lines 159 to 171
if (match == null) {
throw FormatException('Invalid input format: $input');
}

final double value = double.parse(match.group(1)!);

// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';

// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;

// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To make _parseValueWithUnit itself safer, you could wrap the parsing logic in a try-catch block here and return null if a FormatException occurs during double.parse(match.group(1)!).

if (match == null) {
      return null;
    }

    try {
      final double value = double.parse(match.group(1)!);
      final String unitStr = match.group(2) ?? 'g';
      final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;
      return value * _unitConversionFactor(unit, Unit.G);
    } catch (e) {
      return null;
    }

return 1.0;
}

return 1.0;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback return 1.0; for unit conversions could potentially mask errors if an unexpected pair of units is encountered. Would it be safer to throw an ArgumentError or log a warning if a conversion is attempted for an undefined pair, rather than silently defaulting to a 1.0 factor?

assert(false, 'Unexpected unit conversion');
    return 1.0;

@github-project-automation github-project-automation bot moved this from In progress to Needs review in 🧽 Ensuring Data Quality May 16, 2025
@github-project-automation github-project-automation bot moved this from 💬 To discuss and validate to 🏗️ In Progress in 🤳🥫 The Open Food Facts mobile app (Android & iOS) May 16, 2025
Lorenzo Mascia and others added 9 commits May 17, 2025 10:45
…ad of overlay

Committer: Lorenzo Mascia <[email protected]>

Changes :
	modified:   packages/smooth_app/lib/pages/hunger_games/question_card.dart
	modified:   packages/smooth_app/lib/pages/hunger_games/question_page.dart
	modified:   packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart
	modified:   packages/smooth_app/lib/pages/product/product_questions_widget.dart
Committer: Lorenzo Mascia <[email protected]>

Changes:
	modified:   packages/smooth_app/lib/pages/hunger_games/question_card.dart
	modified:   packages/smooth_app/lib/pages/hunger_games/question_image_thumbnail.dart
	modified:   packages/smooth_app/lib/pages/hunger_games/question_page.dart
	modified:   packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart
	modified:   packages/smooth_app/lib/pages/product/product_questions_widget.dart
Committer: Lorenzo Mascia <[email protected]>

Changes:
	modified:   packages/smooth_app/lib/pages/hunger_games/question_image_thumbnail.dart
	modified:   packages/smooth_app/lib/pages/hunger_games/question_page.dart
	modified:   packages/smooth_app/lib/pages/preferences/user_preferences_contribute.dart
	modified:   packages/smooth_app/lib/pages/product/product_questions_widget.dart
Committer: Lorenzo Mascia <[email protected]>

Changes:
	modified:   lib/pages/hunger_games/question_image_thumbnail.dart
	modified:   lib/pages/hunger_games/question_page.dart
…4446

Changes:
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart
	new file:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/widgets/nutrition_facts_editor.dart
	modified:   packages/smooth_app/lib/pages/text_field_helper.dart
Changes:
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_page.dart
	modified:   packages/smooth_app/lib/pages/product/nutrition_page/nutrition_validator.dart
@LorenzoMascia LorenzoMascia force-pushed the feat/check-values-before-seding#4446 branch from 328d5ac to 8a15f1c Compare May 17, 2025 10:19
@LorenzoMascia
Copy link
Contributor Author

I’ve rebased the branch onto the latest develop to resolve all conflicts, and I've applied gemini's suggestions.

Copy link

Thanks for the update, @LorenzoMascia! It's great to hear that you've rebased the branch and incorporated the suggestions.

To ensure everything is looking good after the changes and rebase, I recommend running another review. You can do this by adding a new issue comment with /gemini review.

Let me know if you have any other questions!

@LorenzoMascia
Copy link
Contributor Author

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature to validate nutrition facts data before saving, which is crucial for data quality. The approach of creating a dedicated NutritionValidator class is a good step towards separating concerns. However, the implementation of the validation logic, particularly the parsing, unit conversion, and normalization, contains critical issues that need to be addressed. Additionally, the lack of unit tests for this core logic is a significant concern.

Summary of Findings

  • Critical Validation Logic Errors: The parsing of values with units, the unit conversion logic, and the normalization calculation in NutritionValidator are fundamentally flawed. This leads to incorrect validation results.
  • Serving Size Unit Handling: The code does not correctly handle serving size units (g vs ml) based on product type or perSize when parsing and normalizing, potentially leading to incorrect calculations for liquid products.
  • Lack of Unit Tests for Validator: There are no unit tests for the critical parsing, unit conversion, and normalization logic in the NutritionValidator class, making it difficult to verify correctness and prevent regressions.
  • Broad Exception Handling: A generic catch (e) is used in _mayExitPage, which can hide specific error types and make debugging difficult.
  • Hardcoded Max Nutrient Values: The maximum nutrient content values are hardcoded in NutritionValidator, which might make future updates or configuration changes more difficult.
  • Minor UI Logic Issue: The onChanged callback in _NutrientValueCell has a minor logic issue regarding resetting the valid flag.

Merge Readiness

This pull request introduces critical correctness issues in the core validation logic and lacks necessary unit tests to ensure the reliability of this feature. Therefore, I recommend that these issues be addressed before merging. I am unable to approve this pull request; please have other reviewers review and approve this code before merging.

Comment on lines +255 to +260
final String servingSize;
if (_nutritionContainer.perSize == PerSize.serving) {
servingSize = _nutritionContainer.servingSize;
} else {
servingSize = '100.0';
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The serving size is determined here. If the perSize is not serving, it defaults to '100.0'. This assumes the base unit is grams. However, for liquid products, the base unit should be milliliters. The validation logic needs to correctly handle both grams and milliliters as base units for normalization, depending on the product type or perSize.

Comment on lines +264 to +281
for (final Nutrient nutrient in _controllers.keys) {
final TextEditingControllerWithHistory controller =
_controllers[nutrient]!;
final String quantity = controller.text.trim();
final Unit unit = _nutritionContainer.getUnit(nutrient);

try {
if (!validator.validate(nutrient, quantity, unit, servingSize)) {
error = true;
controller.valid = false;
} else {
controller.valid = true;
}
} catch (e) {
error = true;
controller.valid = false;
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This loop iterates through the controllers and calls the validator.validate method. As noted in comments on nutrition_validator.dart, the parsing, unit conversion, and normalization logic within the validator are currently flawed. This means the validation performed here will not be accurate.

Comment on lines +84 to +94
bool validate(Nutrient nutrient, String? qty, Unit unit, String? servSize) {
final double? quantity = _parseValueWithUnit(qty);
final double? servingSize = _parseValueWithUnit(servSize);

if ((quantity == null) || (servingSize == null)) {
return true;
}

final double limit = nutrientMaxContentPer100g[nutrient] ?? 100;
return _normalize(quantity, unit, nutrient.typicalUnit, servingSize) <=
limit;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This validate method relies on _parseValueWithUnit and _normalize. As detailed in comments on those methods, the logic for parsing values with units and normalizing them per 100g/100ml is currently incorrect, leading to inaccurate validation results.

Comment on lines +97 to +101
double _normalize(
double quantity, Unit inputUnit, Unit typicalUnit, double servingSize) {
final double factor = _unitConversionFactor(inputUnit, typicalUnit);
final double normalized = (quantity * factor) * (100.0 / servingSize);
return normalized;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The normalization logic here is incorrect due to the flawed input from _parseValueWithUnit and potential unit mismatches. The quantity and servingSize parameters are already potentially incorrectly converted to grams by _parseValueWithUnit. The calculation (quantity * factor) * (100.0 / servingSize) does not correctly normalize the quantity per 100g/100ml while accounting for the correct units of the quantity, serving size, and the target limit.

Comment on lines +149 to +171
double? _parseValueWithUnit(String? input) {
if (input == null || input.isEmpty) {
return null;
}

try {
input = input.trim().toLowerCase();
final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExpMatch? match = regex.firstMatch(input);
if (match == null) {
return null; // Invalid format
}
final double value = double.parse(match.group(1)!);
// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';
// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;
// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
} catch (e) {
return null; // Handle any parsing errors
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This parsing method has several issues:

  1. It attempts to convert the parsed value to grams (value * _unitConversionFactor(unit, Unit.G)). This is incorrect; the raw value and its unit should be returned, and conversion should happen later based on the nutrient's typical unit and the serving size unit.
  2. It assumes 'g' as the default unit if none is specified. This is incorrect for serving size when the product is liquid (should be ml).
  3. It uses UnitHelper.stringToUnit(unitStr) ?? Unit.G which means any unrecognized unit string will default to grams, which is likely wrong.

Comment on lines +3 to +172
Nutrient.elaidicAcid: 5.0,
Nutrient.gammaLinolenicAcid: 5.0,
Nutrient.gondoicAcid: 5.0,
Nutrient.lignocericAcid: 5.0,
Nutrient.meadAcid: 5.0,
Nutrient.melissicAcid: 5.0,
Nutrient.montanicAcid: 5.0,
Nutrient.nervonicAcid: 5.0,
Nutrient.transFat: 100.0,
};

bool validate(Nutrient nutrient, String? qty, Unit unit, String? servSize) {
final double? quantity = _parseValueWithUnit(qty);
final double? servingSize = _parseValueWithUnit(servSize);

if ((quantity == null) || (servingSize == null)) {
return true;
}

final double limit = nutrientMaxContentPer100g[nutrient] ?? 100;
return _normalize(quantity, unit, nutrient.typicalUnit, servingSize) <=
limit;
}

double _normalize(
double quantity, Unit inputUnit, Unit typicalUnit, double servingSize) {
final double factor = _unitConversionFactor(inputUnit, typicalUnit);
final double normalized = (quantity * factor) * (100.0 / servingSize);
return normalized;
}

double _unitConversionFactor(Unit from, Unit to) {
if (from == to) {
return 1.0;
}
// Mass conversions
if (from == Unit.G && to == Unit.MILLI_G) {
return 1000.0;
}
if (from == Unit.MILLI_G && to == Unit.G) {
return 0.001;
}
if (from == Unit.MICRO_G && to == Unit.G) {
return 0.000001;
}
if (from == Unit.G && to == Unit.MICRO_G) {
return 1000000.0;
}
if (from == Unit.MILLI_G && to == Unit.MICRO_G) {
return 1000.0;
}
if (from == Unit.MICRO_G && to == Unit.MILLI_G) {
return 0.001;
}
// Energy conversions
if (from == Unit.KCAL && to == Unit.KJ) {
return 4.184;
}
if (from == Unit.KJ && to == Unit.KCAL) {
return 0.239006;
}
// Volume conversions
if (from == Unit.L && to == Unit.MILLI_L) {
return 1000.0;
}
if (from == Unit.MILLI_L && to == Unit.L) {
return 0.001;
}
// Percentage is dimensionless - no conversion needed
if (from == Unit.PERCENT || to == Unit.PERCENT) {
return 1.0;
}

return 1.0;
}

double? _parseValueWithUnit(String? input) {
if (input == null || input.isEmpty) {
return null;
}

try {
input = input.trim().toLowerCase();
final RegExp regex = RegExp(r'^([0-9]+\.?[0-9]*)\s*([a-zA-Z%]+)?$');
final RegExpMatch? match = regex.firstMatch(input);
if (match == null) {
return null; // Invalid format
}
final double value = double.parse(match.group(1)!);
// Default to grams if unit not specified
final String unitStr = match.group(2) ?? 'g';
// Convert unit string to Unit enum
final Unit unit = UnitHelper.stringToUnit(unitStr) ?? Unit.G;
// Use _unitConversionFactor to convert from source unit to grams
return value * _unitConversionFactor(unit, Unit.G);
} catch (e) {
return null; // Handle any parsing errors
}
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The NutritionValidator class implements core logic for parsing, unit conversion, and validation. There are no unit tests provided for this class. Given the complexity and critical nature of this logic, comprehensive unit tests are essential to ensure correctness, cover edge cases, and prevent regressions.

Comment on lines +277 to +280
} catch (e) {
error = true;
controller.valid = false;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching a generic catch (e) is generally discouraged as it can hide unexpected errors. It would be better to catch more specific exceptions if possible, or at least log the error (e) for debugging purposes.

Comment on lines +4 to +82
final Map<Nutrient, double> nutrientMaxContentPer100g = <Nutrient, double>{
Nutrient.salt: 100.0,
Nutrient.sodium: 40.0,
Nutrient.fiber: 100.0,
Nutrient.sugars: 100.0,
Nutrient.addedSugars: 100.0,
Nutrient.fat: 100.0,
Nutrient.saturatedFat: 100.0,
Nutrient.proteins: 100.0,
Nutrient.energyKCal: 900.0,
Nutrient.energyKJ: 3700.0,
Nutrient.carbohydrates: 100.0,
Nutrient.caffeine: 80.0,
Nutrient.calcium: 5000.0,
Nutrient.iron: 100.0,
Nutrient.vitaminC: 6000.0,
Nutrient.magnesium: 2000.0,
Nutrient.phosphorus: 5000.0,
Nutrient.potassium: 5000.0,
Nutrient.zinc: 1000.0,
Nutrient.copper: 1000.0,
Nutrient.selenium: 1000.0,
Nutrient.vitaminA: 3000.0,
Nutrient.vitaminE: 1000.0,
Nutrient.vitaminD: 100.0,
Nutrient.vitaminB1: 5.0,
Nutrient.vitaminB2: 5.0,
Nutrient.vitaminPP: 50.0,
Nutrient.vitaminB6: 5.0,
Nutrient.vitaminB12: 500.0,
Nutrient.vitaminB9: 1000.0,
Nutrient.vitaminK: 1000.0,
Nutrient.cholesterol: 500.0,
Nutrient.butyricAcid: 5.0,
Nutrient.caproicAcid: 5.0,
Nutrient.caprylicAcid: 5.0,
Nutrient.capricAcid: 5.0,
Nutrient.lauricAcid: 5.0,
Nutrient.myristicAcid: 10.0,
Nutrient.palmiticAcid: 20.0,
Nutrient.stearicAcid: 20.0,
Nutrient.oleicAcid: 80.0,
Nutrient.linoleicAcid: 70.0,
Nutrient.docosahexaenoicAcid: 5.0,
Nutrient.eicosapentaenoicAcid: 5.0,
Nutrient.erucicAcid: 5.0,
Nutrient.monounsaturatedFat: 80.0,
Nutrient.polyunsaturatedFat: 70.0,
Nutrient.alcohol: 100.0,
Nutrient.pantothenicAcid: 20.0,
Nutrient.biotin: 500.0,
Nutrient.chloride: 1000.0,
Nutrient.chromium: 500.0,
Nutrient.fluoride: 10.0,
Nutrient.iodine: 1000.0,
Nutrient.manganese: 20.0,
Nutrient.molybdenum: 1000.0,
Nutrient.omega3: 30.0,
Nutrient.omega6: 70.0,
Nutrient.omega9: 80.0,
Nutrient.betaCarotene: 50.0,
Nutrient.bicarbonate: 500.0,
Nutrient.sugarAlcohol: 100.0,
Nutrient.alphaLinolenicAcid: 10.0,
Nutrient.arachidicAcid: 5.0,
Nutrient.arachidonicAcid: 5.0,
Nutrient.behenicAcid: 5.0,
Nutrient.ceroticAcid: 5.0,
Nutrient.dihomoGammaLinolenicAcid: 5.0,
Nutrient.elaidicAcid: 5.0,
Nutrient.gammaLinolenicAcid: 5.0,
Nutrient.gondoicAcid: 5.0,
Nutrient.lignocericAcid: 5.0,
Nutrient.meadAcid: 5.0,
Nutrient.melissicAcid: 5.0,
Nutrient.montanicAcid: 5.0,
Nutrient.nervonicAcid: 5.0,
Nutrient.transFat: 100.0,
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The maximum nutrient content values are hardcoded in this map. While this works, consider if these values might need to be configurable or potentially sourced from an external API or configuration file in the future for easier updates and maintenance.

Comment on lines +104 to +147
double _unitConversionFactor(Unit from, Unit to) {
if (from == to) {
return 1.0;
}
// Mass conversions
if (from == Unit.G && to == Unit.MILLI_G) {
return 1000.0;
}
if (from == Unit.MILLI_G && to == Unit.G) {
return 0.001;
}
if (from == Unit.MICRO_G && to == Unit.G) {
return 0.000001;
}
if (from == Unit.G && to == Unit.MICRO_G) {
return 1000000.0;
}
if (from == Unit.MILLI_G && to == Unit.MICRO_G) {
return 1000.0;
}
if (from == Unit.MICRO_G && to == Unit.MILLI_G) {
return 0.001;
}
// Energy conversions
if (from == Unit.KCAL && to == Unit.KJ) {
return 4.184;
}
if (from == Unit.KJ && to == Unit.KCAL) {
return 0.239006;
}
// Volume conversions
if (from == Unit.L && to == Unit.MILLI_L) {
return 1000.0;
}
if (from == Unit.MILLI_L && to == Unit.L) {
return 0.001;
}
// Percentage is dimensionless - no conversion needed
if (from == Unit.PERCENT || to == Unit.PERCENT) {
return 1.0;
}

return 1.0;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _unitConversionFactor method provides conversions only between a limited set of units (g, mg, mcg, kcal, kj, l, ml, %). It does not include conversions for other units like IU (International Units) which are common for vitamins. A more comprehensive unit conversion system might be needed.

Comment on lines +343 to +346
onChanged: (String? value) {
if (!controller.valid) {
controller.valid = true;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onChanged callback only sets controller.valid = true if !controller.valid is true. It might be simpler and more robust to always set controller.valid = true when the text changes, as the user is now editing the value, and the previous validation status is no longer relevant to the current input.

onChanged: (String? value) {
                          controller.valid = true;
                        },

@teolemon teolemon linked an issue May 18, 2025 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Needs review
Development

Successfully merging this pull request may close these issues.

Nutrition facts: check values before sending them
4 participants