Skip to content

Comments

feat: 1072 - Add Nutri-Score details#1079

Open
goerlitz wants to merge 16 commits intoopenfoodfacts:masterfrom
goerlitz:extract-nutriscore-details-from-response
Open

feat: 1072 - Add Nutri-Score details#1079
goerlitz wants to merge 16 commits intoopenfoodfacts:masterfrom
goerlitz:extract-nutriscore-details-from-response

Conversation

@goerlitz
Copy link
Contributor

@goerlitz goerlitz commented May 6, 2025

What

  • Introduces a developer-friendly Nutri-Score domain model to access and interpret structured data from the nutriscore field.
  • add a thin wrapper around the Nutri-Score details
  • Part of Add Nutri-Score Details #1072

JSON data returned by API:

"nutriscore": {
    "2021": {
        "grade": "not-applicable",
        "not_applicable_category": "en:alcoholic-beverages",
        "category_available": 1,
        "nutrients_available": 1,
        "data": {
            "is_beverage": 1,
            "is_cheese": 0,
            "is_fat": 0,
            "is_water": 0,
         },
         ...
    },
    "2023": {
        ...
    }
},

Why

  1. The raw nutriscore field contains rich metadata, including Version-specific data (2021, 2023), grade, score, info, and data availability, multiple overlapping boolean flags (is_*) to indicate food category
  2. This structure is difficult to work with it application code because it is loosely documented.

Challenges

  • The grade field conflates both status and score (a–e, not-applicable, unknown)
  • Food category must be inferred from several mutually exclusive flags (is_*) and no flag or general category
  • Boolean fields are inconsistently typed (e.g., 1.0, 1 vs "1")
  • App developers must reimplement complex logic to make the data usable

Solution

Create 2 layers:

  • a thin wrapper around the Nutri-Score details as returned by the API -> this PR.
  • a domain model layer, i.e. a high-level, strongly typed Nutri-Score model to improve developer experience when working with the Nutri-Score data. -> a separate PR.

Benefits of the high-level domain model:

  • strongly typed and with business logic for easier and more consistent usage across the app
  • Interprets raw data into clearly defined domain concepts
  • Distinguishes between Nutri-Score versions (2021, 2023)
  • Encapsulates common logic (e.g., missing data, applicability)
  • Exposes structured, displayable data such as:
    • Grade and score
    • Computation status (computed, notApplicable, unknown)
    • Inapplicability reason
    • Derived food category (based on is_* flags)

Structure

File Responsibility
model/nutriscore/nutriscore_details.dart Container for parsed JSON structure from API
model/nutriscore/nutriscore_details_2021.dart Parsed JSON structure for Nutri-Score 2021 details
model/nutriscore/nutriscore_details_2023.dart Parsed JSON structure for Nutri-Score 2023 details
model/nutriscore/nutriscore_data_2021.dart Parsed JSON structure for 2021 data included in details
model/nutriscore/nutriscore_data_2023.dart Parsed JSON structure for 2023 data included in details
model/nutriscore/nutriscore.dart High level, strongly typed Nutri-Score domain model
test/nutriscore_from_json_test.dart Test coverage for valid and malformed data

@codecov-commenter
Copy link

Codecov Report

Attention: Patch coverage is 82.84314% with 35 lines in your changes missing coverage. Please review.

Project coverage is 75.75%. Comparing base (820d145) to head (b094efd).
Report is 108 commits behind head on master.

Files with missing lines Patch % Lines
lib/src/model/nutriscore_enums.dart 22.22% 14 Missing ⚠️
lib/src/model/nutriscore_raw.dart 66.66% 11 Missing ⚠️
lib/src/model/nutriscore.dart 86.66% 6 Missing ⚠️
lib/src/model/nutriscore_category.dart 86.20% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1079      +/-   ##
==========================================
- Coverage   76.34%   75.75%   -0.59%     
==========================================
  Files         239      269      +30     
  Lines        8494    10218    +1724     
==========================================
+ Hits         6485     7741    +1256     
- Misses       2009     2477     +468     

☔ 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.

@teolemon
Copy link
Member

teolemon commented May 7, 2025

@stephanegigandet perhaps some inspiration for refactoring

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 @goerlitz, and thank you for your clean code!
I could add other comments, but first I have to address an important point: you must provide a toJson method for your new "nutriscore details" product field. Or wait until #1056 is reviewed.
Besides, a test with server data would be great, too.


@override
String toString() {
return 'NutriScore{status: ${status.name}, grade: ${grade?.name}, version: $version, category: $category, score: $score, notApplicableCategory: $notApplicableCategory, errorMessage: $errorMessage, missingData: $missingData}';
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 we can afford some line jumps like that:

Suggested change
return 'NutriScore{status: ${status.name}, grade: ${grade?.name}, version: $version, category: $category, score: $score, notApplicableCategory: $notApplicableCategory, errorMessage: $errorMessage, missingData: $missingData}';
return 'NutriScore{status: ${status.name}'
', grade: ${grade?.name}'
', version: $version'
', category: $category'

Copy link
Contributor

Choose a reason for hiding this comment

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

You're right, we don't send back this data to the server.
But, as I explained in another comment

We need a toJson too, as in Smoothie we spend our time storing products as json strings in the local database and retrieving them when relevant

I meant a meaningful toJson.
Actually in Smoothie we never display data from the server. We get data from the server and then store it in our local database. Then, we extract data from the local database to display it.
For that we use both fromJson and toJson.

/// but partial information such as [version] and [category] may still be present.
///
/// The [missingData] parameter indicates which data is missing.
factory NutriScore.unknown([
Copy link
Contributor

Choose a reason for hiding this comment

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

Just by curiosity: what's the added value of factory instead of named constructor in this file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question. I started with factories initially, but it is probably overkill as named constructors with simple field initialization should be enough. I'll check.

Comment on lines 418 to 422
@JsonKey(
name: 'nutriscore',
fromJson: NutriScore.fromJson,
includeToJson: false,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

We need a toJson too, as in Smoothie we spend our time storing products as json strings in the local database and retrieving them when relevant.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki, I intentionally left out toJson because the Nutri-Score details are computed on the server side and thus nutriscore should be a read-only field and not sent back to the server.

Is there a technical reason to always require toJson or is this just because the Smoothie app uses the Product object for local storage?

Actually, the openfoodfacts-dart library should be agnostic of what any client app does and not pollute the code with requirements that are just needed by a single app.

Of course, I can add a toJson. However, only a subset of the nutriscore data is currently parsed by fromJson (because the data is complex and convoluted). Also some fields are sanitized. Thus, the output of toJson would be different from the input. This may lead to issues if the assumption is that fromJson needs to work on data retrieved from the API as well as data retrieved from local storage.

What do you think?

fromJson: NutriScore.fromJson,
includeToJson: false,
)
Map<NutriScoreVersion, NutriScore>? nutriscores;
Copy link
Contributor

Choose a reason for hiding this comment

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

I would name it nutriscoreDetails too, as you did as a ProductField. That way it would be easier to keep the track.

}
}

bool parseBool(Map<String, dynamic> json, String key) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We already have JsonObject.parseBool.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, I only found JsonHelper.boolFromJSON which is not appropriate.
I will replace with JsonObject.parseBool.

@goerlitz
Copy link
Contributor Author

goerlitz commented May 7, 2025

@monsieurtanuki, I made the requested changes:

  • replaced factories with named constructors
  • using JsonObject.parseBool
  • added toJson() and put all json-related code into new json helper class
  • added tests for toJson() and fromJson()
  • added == operator and hashcode
  • added tests for server data
  • other minor improvements

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 @goerlitz!
To be honest, I find that your code looks a bit too complex for what it's actually doing.

The first thing would be to use what we already provide in the package, like OffTagged.

The second thing would be to code what we actually need as users: two fields, one for nutriscore2021, the other for nutriscore2023. And probably without dart 3.0 ;)
If needed I'll send you code examples of what I mean.

/// ```dart
/// final nutriScore = product.nutriscoreDetails?[NutriScoreVersion.v2023];
///
/// final category = switch (nutriScore?.category?.as2023) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nutriscore or nutriscoreDetails?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

right, let me update the name of the final variable. (although, conceptually it is correct, because Nutri-Score is not just the grades A-E in isolation but also connected with the food category (and actual score)).


enum NutriScoreInput { category, nutrients, ingredients }

enum NutriScoreVersion {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please extend OffTagged for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

enum NutriScoreInput { category, nutrients, ingredients } are not tags in json.

But let me check if version could be used via OffTagged.


/// Parses a Nutri-Score version from a string such as '2021' or '2023'.
/// Returns `null` if the string does not match any known version.
static NutriScoreVersion? tryParse(String? value) {
Copy link
Contributor

Choose a reason for hiding this comment

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

To be replaced with fromOffTag

Comment on lines 30 to 35
final bool isBeverage;
final bool isCheese;
final bool isFat; // 2021
final bool isFatOilNutsSeeds; // 2023
final bool isRedMeatProduct; // 2023
final bool isWater;
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.

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 underlying JSON is not "very elegant", but let me check if I can handle this with OffTagged instead.

Copy link
Contributor

Choose a reason for hiding this comment

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

What I meant is having to comment your code so that we know which field belong to what version isn't something I would recommend.
I'll probably send you the code as I imagined it, and you'll see how simple and elegant it can be. And easier to maintain of course, that's the whole point.

isWater = JsonObject.parseBool(json['is_water']);

@override
String toString() {
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 not sure we need all those toString methods.
Makes the code harder to maintain, for very limited added value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no problem, I can remove the toString() - but it is handy for debugging, at least for NutriScore object. Not so much for the raw data, I agree.

});
}

final computed = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please put those finals on top of the file and inside the main.

expect(result.product, isNotNull);
expect(result.product!.barcode, barcode);

final nutriScore =
Copy link
Contributor

Choose a reason for hiding this comment

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

Please make a specific test method for that, specifically requesting only ProductField.NUTRISCORE_DETAILS.


expect(result.product!.nutriscore, 'e');

final nutriScore =
Copy link
Contributor

Choose a reason for hiding this comment

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

Please make a specific test method for that, specifically requesting only ProductField.NUTRISCORE_DETAILS.

Comment on lines 122 to 142
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NutriScore &&
status == other.status &&
grade == other.grade &&
version == other.version &&
category?.value == other.category?.value &&
score == other.score &&
notApplicableCategory == other.notApplicableCategory &&
errorMessage == other.errorMessage &&
_unorderedEquals(missingData, other.missingData);

@override
int get hashCode => Object.hash(
status,
grade,
version,
category?.value,
score,
notApplicableCategory,
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to order nutriscores?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is not for ordering - only equality. It is (primarily= used for the toJson/fromJson test to verify that writing and reading the same object gives the same result.

Alternatively, all fields could be compared but in the test - if you prefer.


/// Internal wrapper for [NutriScoreCategory2021].
/// Used by [NutriScoreCategory] to encapsulate version-specific category types.
class _NutriScoreCategory2021 extends NutriScoreCategory {
Copy link
Contributor

Choose a reason for hiding this comment

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

To be honest it's a lot of noise.

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've been going back and forth with the modeling to find a good type safe implementation for the different versions and how it differs in the details.
I agree that this part is a bit complicated. Let me check if I can simplify and avoid some over-engineering.

Copy link
Contributor

Choose a reason for hiding this comment

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

I've been going back and forth with the modeling to find a good type safe implementation for the different versions and how it differs in the details. I agree that this part is a bit complicated. Let me check if I can simplify and avoid some over-engineering.

I'll try on my side and I'll show you. Again, I assume that a good type safe implementation is based on basic OOP. We'll see ;)

@goerlitz
Copy link
Contributor Author

goerlitz commented May 8, 2025

Hi @goerlitz! To be honest, I find that your code looks a bit too complex for what it's actually doing.

The first thing would be to use what we already provide in the package, like OffTagged.

The second thing would be to code what we actually need as users: two fields, one for nutriscore2021, the other for nutriscore2023. And probably without dart 3.0 ;) If needed I'll send you code examples of what I mean.

Actually, my app needs a simple way to access all of this Nutri-Score information - that why I implemented it.

  • Nutri-Score computed? -> category, score, (positive and negative factors)?
  • Nutri-Score not applicable? -> why? which category?
  • Nutri-Score unknown? -> why? what is missing (category?, nutrients?, ingredients?)?

Also the OFF App falls short in making that information transparent. Hence, it would be highly beneficial to have it included in an easily accessible way (the nutriscore field/struct has all the information but fiddling with the underlying JSON format quickly gets messy and is error-prone).

Let me try to leverage OffTagged and simplify some code.

@monsieurtanuki
Copy link
Contributor

@goerlitz I'm not saying that your code doesn't work or isn't appropriate for your app.
I'm saying that reading it, I find it much more complex that I expected. More specifically, as it's a code that needs to be maintained by developers from different backgrounds, the simpler the better.
That said, you have a better understanding of how all those nutriscore values can be exploited in an app.

New files:
* `nutriscore_data_2021.dart`: Detailed data of NutriScore version 2021.
* `nutriscore_data_2021.g.dart`: generated
* `nutriscore_data_2023.dart`: Detailed data of NutriScore version 2023.
* `nutriscore_data_2023.g.dart`: generated
* `nutriscore_detail_2021.dart`: Data of NutriScore version 2021.
* `nutriscore_detail_2021.g.dart`: generated
* `nutriscore_detail_2023.dart`: Data of NutriScore version 2023.
* `nutriscore_detail_2023.g.dart`: generated
* `nutriscore_details.dart`: NutriScore detailed info.
* `nutriscore_details.g.dart`: generated
* `nutriscore_grade.dart`: Grade of NutriScore.

Impacted files:
* `api_get_product_test.dart`: new tests around nutriscoreDetails
* `product.dart`: new nutriscoreDetails field
* `product.g.dart`: generated
* `product_fields.dart`: new nutriscore field
goerlitz added 2 commits May 15, 2025 14:44
- replaced factories with named constructors
- using JsonObject.parseBool
- added toJson()
- put all json-related code into new json helper
- added == operator and hashcode
- added tests for toJson() and fromJson()
- added tests to live product fetching
- other minor improvements
@goerlitz goerlitz force-pushed the extract-nutriscore-details-from-response branch from 79fb11e to 7723586 Compare May 15, 2025 12:57
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 @goerlitz!
Thank you for your work!
Please have a look at my comments.


bool get isComputed => grade != null;
bool get isNotApplicable => notApplicableCategory?.isNotEmpty ?? false;
bool get isUnknown => missingData.isNotEmpty;
Copy link
Contributor

Choose a reason for hiding this comment

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

"isUnknown"? What about hasMissingData?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

isNotApplicable could also have missing data, e.g. when missing nutrients but having category which is not applicable.
I prefer to have isComputed, isNotApplicable and isUnknown to be mutually exclusive. (but I realize that this is currently not the case with this implementation - I need to fix it).

That's why I initially had the enum status {computed, notApplicable, unknown} to clearly distinguid between the 3 states - currently this information is distributed in the JSON data between nutriscore_computed field and not-applicable+ unknown values in grade. :-/

Copy link
Contributor

Choose a reason for hiding this comment

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

@goerlitz I think you're trying to be too smart here, too related to your app needs.
If the "raw" data says it's computed, not applicable, with missing data, who are you to decide otherwise or which one has the priority?
Just keep it straightforward.
Then, in your own app, add an extension if you want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that makes sense. It's probably better not to introduce too much logic at this level and instead handle it in the app layer.

That said, part of the motivation behind the abstraction is that the API model is quite unclear and loosely documented. It's often hard to determine the intended logic (e.g., precedence, or how to interpret certain field combinations), which makes implementations prone to guesswork and inconsistency. I was trying to guard against that.

I'll adjust the implementation accordingly.

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 reintroduced the NutriScoreStatus enum (computed, notApplicable, unknown) for these reasons:

  • Matches the raw API: The grade field conflates status and value ('unknown', 'not-applicable', 'a''e'). The enum reflects these states while clearly separating status from actual grades.

  • Avoids ambiguity: The previous isComputed, isNotApplicable, and hasMissingData flags could overlap. The enum enforces mutual exclusivity by design.

  • Improves clarity: A single status is easier to reason about than multiple booleans with implicit precedence rules.

The raw data remains accessible; this enum is a clean abstraction on top for consistent downstream use.

@goerlitz
Copy link
Contributor Author

@monsieurtanuki , thanks for all the great support and helpful review comments!

I’ve made several updates and added more documentation. I think the code now strikes a good balance between:

  • Capturing the raw JSON structure in a way that’s consistent with the rest of the API (using JsonSerializable)
  • Providing a strongly typed domain model on top to make working with the data easier and less error-prone

Final verdict:

  • No Dart 3 features, no generics — just clean, practical code
  • Logic is kept close to the raw data — not trying to be overly clever, just focusing on the parts where strong typing provides real value
  • Apps are free to choose whether to use the domain model or work directly with the raw data

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.

Thank you @goerlitz for your code simplifications!
There are still parts influenced by your own app requirements or expectations.
Please have a look at my comments.

/// This is used internally by the abstract [NutriScore] base class to enable
/// shared handling of 2021 and 2023 category types. It is intentionally private
/// to prevent accidental external usage or misimplementation.
abstract class _NutriScoreCategory {}
Copy link
Contributor

Choose a reason for hiding this comment

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

As time goes by we saw in the project that we needed to be more flexible.
I'd prefer a public class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fine with me.

That said, keeping _NutriScoreCategory private enforces a stricter and more predictable API surface. Since Nutri-Score categories for 2021 and 2023 are standardized and not open for extension by clients, a private base class helps ensure correctness and discourages misuse.

If flexibility is preferred for future-proofing or unforeseen extensions, I'm happy to make it public, but from a design perspective, encapsulating it internally provides stronger guarantees.

Let me know how you'd like to proceed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fine with me.

Good!

If flexibility is preferred for future-proofing or unforeseen extensions, I'm happy to make it public

Good!

but from a design perspective, encapsulating it internally provides stronger guarantees.

After 5 years developing here, I can tell you that most developers don't understand OOP anyway - or even know that it exists. Let them extend and misuse NutriScoreCategory if they want. We're safe, especially for read-only data.

///
/// Note: In the raw JSON, the `grade` field conflates status and actual grades,
/// using values `'unknown'`, `'not-applicable'`, and `'a'`–`'e'`.
NutriScoreStatus get status {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't believe in your concept of status.
Please move that to your app code if you need it, but it doesn't belong here.

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.
What would you recommend as the preferred way to model the distinction between "not-applicable", "unknown", and actual computed grades ("a"–"e") returned in the grade field? Since they represent semantically different cases in the Nutri-Score logic (ie. state vs. grades), I'd like to ensure the structure reflects that appropriately, without pushing unnecessary complexity into every client implementation.

this.nutrientsAvailable = false,
}) : grade = OffTagged.fromOffTag(grade, NutriScoreGrade.values)
as NutriScoreGrade?,
category = categoryAvailable ? category : null;
Copy link
Contributor

Choose a reason for hiding this comment

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

That's not your business to interpret the raw data.
Just populate categoryAvailable and category. If they're inconsistent that's not your problem, that's the server's problem.
If you have specific needs, in your app you can code a bulletProofCategory getter as an extension.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the feedback. I understand your position, and I appreciate the intent to keep the API client close to the raw server response for transparency and maintainability.

That said, the server response is messy and highly developer-unfriendly. Hence, my intention with this domain model is specifically to wrap the raw data in a way that is safer and easier to consume for app developers. The Open Food Facts API has known inconsistencies and overlapping semantics, which shift unnecessary complexity onto every client using the SDK. (I spend weeks on digging into the api responses and figuring how to use different data correctly and consistently).

By interpreting such edge cases once - within a well-scoped domain model, parallel to the raw data - we prevent each consumer app from having to re-implement and debug the same logic. In this context, the line between “client” and “app” becomes blurry, especially when the API does not offer clean or self-explanatory structures.

That said, I’m happy to align with your vision for the package. If the preference is to stay close to the raw data and leave all interpretation to the application layer, I can remove the domain model entirely.

  1. If a domain model should be included to make it safe and easy for developers to use the Nutri-Score data correctly, the code needs to interpret the raw data.
  2. If the library should stay close to raw data, it will be up to app developers to figure out how to work with the messy data.

Let me know how you'd like to proceed. I’m open either way - just want to ensure the outcome is both useful and long term valuable/maintainable.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the feedback. I understand your position, and I appreciate the intent to keep the API client close to the raw server response for transparency and maintainability.

Awesome!

That said, the server response is messy and highly developer-unfriendly.

Maybe. But what we need in a first approach is access to raw data.
We may need a logical layer on top, later, and we'll need your specific experience.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the response!

To clarify: the current NutriScore class is that logical layer — it’s a domain model, intentionally built on top of the raw JSON. The raw data is already fully exposed via the clearly separated NutriScoreData202* and NutriScoreData202* classes. This gives us a clean architectural separation:

NutriScoreDetail202* and NutriScoreData202*: reflect the raw API structure

NutriScore: provides a normalized, safer-to-use abstraction on top

So we already have access to raw data and a clear place for domain logic. This isn't a "maybe later" scenario - the domain model already exists in parallel, not in place of the raw data.

That said, if the direction for the SDK is to only reflect raw API structures and defer all interpretation to the application layer - with all the inconsistency and poorly documented details, then I’d prefer to remove the NutriScore domain model entirely for now. That keeps things focused and avoids introducing a partially accepted abstraction.

Just let me know how you'd like to proceed — I’m happy to align either way. My main goal is ensuring that the SDK is both cleanly structured and helpful for developers integrating Nutri-Score functionality.


@override
String get offTag => name;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
}
/// Returns the first [NutriScoreGrade] that matches the [offTag].
static NutriScoreGrade? fromOffTag(final String? offTag) =>
OffTagged.fromOffTag(offTag, NutriScoreGrade.values) as NutriScoreGrade?;
}

/// - If `categoryAvailable` is false, `category` will be set to `null`.
/// - Grade tags are parsed to `NutriScoreGrade` (`A`-`E`) via [OffTagged].
NutriScore({
String? grade,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
String? grade,
NutriScoreGrade? grade,

Comment on lines 40 to 41
categoryAvailable: rawNutriScore2021?.categoryAvailable ?? false,
nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable ?? false,
Copy link
Contributor

Choose a reason for hiding this comment

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

If it's null, it's null.

Suggested change
categoryAvailable: rawNutriScore2021?.categoryAvailable ?? false,
nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable ?? false,
categoryAvailable: rawNutriScore2021?.categoryAvailable,
nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable,

final String? notApplicableCategory;

/// Indicates whether the category required to compute the Nutri-Score is available.
final bool categoryAvailable;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
final bool categoryAvailable;
final bool? categoryAvailable;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What would be the difference between null and false?

Copy link
Contributor

Choose a reason for hiding this comment

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

What would be the difference between null and false?

Same difference as between "I don't know" and "no".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the clarification, but I respectfully disagree in the context of the domain model.

The goal of the domain model is to remove ambiguity and provide a clean, semantic abstraction over the raw data. Introducing bool? (i.e., true/false/null) for categoryAvailable shifts the burden of interpretation onto the developer - despite the fact that there's no clear, documented distinction between false and null in the API itself.

In the domain model, categoryAvailable = false already implies "not available"—regardless of whether that came from an explicit false or an absent value in the raw data. That’s the very point: to normalize loosely defined input into reliable, meaningful output for app developers.

If this information truly needs to be disambiguated, it belongs in the raw model. The domain layer should prioritize clarity and eliminate unnecessary branching logic for consumers.

final bool categoryAvailable;

/// Indicates whether the nutrients required to compute the Nutri-Score are available.
final bool nutrientsAvailable;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
final bool nutrientsAvailable;
final bool? nutrientsAvailable;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

same here, what would be the difference between null and false?

Copy link
Contributor

Choose a reason for hiding this comment

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

same here, what would be the difference between null and false?

Same difference as between "I don't know" and "no".


return NutriScore2021(
category: rawNutriScore2021?.data?.category,
grade: rawNutriScore2021?.grade,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
grade: rawNutriScore2021?.grade,
grade: NutriScoreGrade.fromOffTag(rawNutriScore2021?.grade),


return NutriScore2023(
category: rawNutriScore2023?.data?.category,
grade: rawNutriScore2023?.grade,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
grade: rawNutriScore2023?.grade,
grade: NutriScoreGrade.fromOffTag(rawNutriScore2023?.grade),

@monsieurtanuki
Copy link
Contributor

@goerlitz Still working on it?

@goerlitz
Copy link
Contributor Author

@goerlitz Still working on it?

Hi @monsieurtanuki , thanks for checking in!

I’ve been waiting for your feedback on my (last 4) comments regarding the high-level Nutri-Score model implemented in lib/src/model/nutriscore/nutriscore.dart.

From my perspective, this domain model offers a clean and consistent abstraction that effectively addresses the ambiguities and inconsistencies found in the raw Nutri-Score data from the API. I believe it improves developer experience without overstepping into business logic. It's about interpreting flawed data into something usable, not applying app-specific rules.

That said, I understand your position on keeping the library close to the raw data and avoiding higher-level interpretation. If the preference is to exclude such logic at this stage, I’m totally fine with removing the domain model (basically the lib/src/model/nutriscore/nutriscore.dart file) for now to move forward and get the PR wrapped up.

Let me know how you’d like to proceed!

@monsieurtanuki
Copy link
Contributor

@goerlitz If still needed, please give me the links of the 4 comments where you expected my reaction.
Regardless, again, the point here is to start with a boring code that just extract stupid data from a json.
If that step isn't even completed, we can't go further, can we?
For the record we already exchanged 94 comments on the topic, which is a lot.
We may add business rules later in a next PR. Or we may not, depending on the level of flexibility we need.
Thank you.

@goerlitz
Copy link
Contributor Author

@goerlitz If still needed, please give me the links of the 4 comments where you expected my reaction. Regardless, again, the point here is to start with a boring code that just extract stupid data from a json. If that step isn't even completed, we can't go further, can we? For the record we already exchanged 94 comments on the topic, which is a lot. We may add business rules later in a next PR. Or we may not, depending on the level of flexibility we need. Thank you.

@monsieurtanuki Let me split it into two PRs - this will keep a clear separation between

  1. the boring code that is just a wrapper around the data structure returned by the api
  2. the domain model aka logical layer that addresses the ambiguities and inconsistencies to provide a better developer experience.
    Note that everything is already there.

@monsieurtanuki
Copy link
Contributor

@goerlitz Perfect! Please ping me when you're done with the boring code PR.

@goerlitz
Copy link
Contributor Author

goerlitz commented Jul 4, 2025

Hi @monsieurtanuki,
I’ve completely removed the Nutri-Score domain model (i.e., the higher-level logical layer) in commit 073b70a

The remaining code in this PR is now strictly a thin wrapper around the raw Nutri-Score data as returned by the API - along with all of its known flaws and inconsistencies.

If adding a domain model is still something to consider in a future PR, I’d appreciate your input on the open questions in lib/src/model/nutriscore/nutriscore.dart, especially around how to best handle the inconsistencies with minimal logic.

Thanks again! Let me know if there’s anything else you’d like to see adjusted in this PR.

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.

Thank you @goerlitz for your code rewriting, as it may have been a bit frustrating.
Please have a look at my comments.

String? nutriscore;

@JsonKey(name: 'nutriscore')
NutriScoreDetails? nutriScoreDetails;
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like you won't toJson that field.
We do need fromJson / toJson to work, as it's convenient to store a downloaded product into a JSON and then retrieve it unJSONing it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki , toJson works (and is tested in test/nutriscore_from_json_test.dart line 219) because NutriScoreDetails is a fully serializable class due to @JsonSerializable annotation and implementation of fromJson and toJson functions.

It was actually your suggestion to use a custom class with @JsonSerializable instead of Map<String, ...> - I think this is a very neat solution, indeed.

Copy link
Contributor

Choose a reason for hiding this comment

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

@goerlitz I don't think you addressed this comment, did you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki as mentioned before, there is nothing to do. It is fully serializable. See the code of NutriScoreDetails.

Copy link
Contributor

Choose a reason for hiding this comment

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

@goerlitz So let me ask again, have you checked that

  1. if you create a Product named product
  2. and set its nutriscoreDetails to something not null
  3. then store product.toJson() in variable productJson
  4. then store Product.fromJson(productJson) in variable productRestored
  5. and check what you have in productRestored.nutriscoreDetails, you have the same thing as in product.nutriscoreDetails?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @monsieurtanuki ,

Yes, this has already been tested and confirmed.

As I mentioned 2 weeks ago (see the messages above), the test you're asking about was part of the PR. Here is a link to the specific line of code:

final original = NutriScoreDetails.fromJson(json);
final copy = NutriScoreDetails.fromJson(original.toJson());

This, along with the following expect(...) assertions, verifies the behavior you're asking about.

You had asked me to remove that test file, but I can restore it if you’d like. Alternatively, feel free to check out the same commit and run the test locally.

Let me know what you prefer.

Copy link
Contributor

Choose a reason for hiding this comment

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

About the tests I asked you to remove:

  • they were based on a String, not on actual data downloaded from the server, and as you can see in the rest of the tests we don't do that (or maybe rarely)
  • you were testing NutriScoreDetails.toJson/fromJson, which isn't really the point - the question is about NutriScoreDetails within a Product.

So, please add a test:

  1. with a product downloaded from the server, including your new nutriscore field
  2. then store product.toJson() in variable productJson
  3. then store Product.fromJson(productJson) in variable productRestored
  4. and check what you have in productRestored.nutriscoreDetails, you have the same thing as in product.nutriscoreDetails

Copy link
Contributor Author

Choose a reason for hiding this comment

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

okay, I can do it this way if it is the preferred approach.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki you were right to call out the need for a toJson on the nutriscore field and test it with a real product received from the API.

While NutriScoreDetails was fully tested to be correctly serializable it did not work inside Product because Product does not implement @JsonSerializable(explicitToJson: true). That quirk of Product was not obvious to me because with proper @JsonSerializable(explicitToJson: true) and extends JsonObject all those toJson/fromJson would not be necessary.

Anyway, I added the necessary toJson now and extended the tests to cover this.

@goerlitz
Copy link
Contributor Author

@monsieurtanuki did you have the time to check if everything is good now?

@goerlitz
Copy link
Contributor Author

@monsieurtanuki is there anything else that needs to be addressed before merging?

I agree that after this long conversation history it is not easy to unterstand what the final outcome is. Hence, let me summarize:

  1. Thin wrappers have been added for the JSON returned by the API - all 5 classes are @JsonSerializable with fromJson(Map<String, dynamic> json) and Map<String, dynamic> toJson().

    • lib/src/model/nutriscore/nutriscore_details.dart
    • lib/src/model/nutriscore/nutriscore_detail_2021.dart
    • lib/src/model/nutriscore/nutriscore_detail_2023.dart
    • lib/src/model/nutriscore/nutriscore_data_2021.dart
    • lib/src/model/nutriscore/nutriscore_data_2023.dart
  2. all 5 classes are exported in lib/openfoodfacts.dart.

  3. NutriScoreDetails included in lib/src/model/product.dart via @JsonKey(name: 'nutriscore') to make it accessible.

  4. NUTRISCORE_DETAILS(offTag: 'nutriscore') included in lib/src/utils/product_fields.dart to be able to request this field from API.

  5. test coverage for Nutri-Score in test/api_get_product_test.dart

Does this finally look good for you?

Copy link
Contributor Author

@goerlitz goerlitz left a comment

Choose a reason for hiding this comment

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

@monsieurtanuki for some reason, 9 of my replies to your comments were in "pending" state and obviously not visible to you.
I believe that you should be able to see them now after I confirmed "submit review" with comments.


/// Nutri-Score categories defined for the 2023 specification.
enum NutriScoreCategory2023 implements _NutriScoreCategory {
general,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why remove general? This is an official Nutri-Score category that influences how the Nutri-Score is computed.
https://www.eurofins.de/food-analysis/other-services/nutri-score/


/// Nutri-Score categories defined for the 2021 specification.
enum NutriScoreCategory2021 implements _NutriScoreCategory {
general,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same here: 'general' is an official Nutri-Score category that influences how the Nutri-Score is computed. It does not make sense to remove it.
https://www.eurofins.de/food-analysis/other-services/nutri-score/

final String? notApplicableCategory;

/// Indicates whether the category required to compute the Nutri-Score is available.
final bool categoryAvailable;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the clarification, but I respectfully disagree in the context of the domain model.

The goal of the domain model is to remove ambiguity and provide a clean, semantic abstraction over the raw data. Introducing bool? (i.e., true/false/null) for categoryAvailable shifts the burden of interpretation onto the developer - despite the fact that there's no clear, documented distinction between false and null in the API itself.

In the domain model, categoryAvailable = false already implies "not available"—regardless of whether that came from an explicit false or an absent value in the raw data. That’s the very point: to normalize loosely defined input into reliable, meaningful output for app developers.

If this information truly needs to be disambiguated, it belongs in the raw model. The domain layer should prioritize clarity and eliminate unnecessary branching logic for consumers.

this.nutrientsAvailable = false,
}) : grade = OffTagged.fromOffTag(grade, NutriScoreGrade.values)
as NutriScoreGrade?,
category = categoryAvailable ? category : null;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the response!

To clarify: the current NutriScore class is that logical layer — it’s a domain model, intentionally built on top of the raw JSON. The raw data is already fully exposed via the clearly separated NutriScoreData202* and NutriScoreData202* classes. This gives us a clean architectural separation:

NutriScoreDetail202* and NutriScoreData202*: reflect the raw API structure

NutriScore: provides a normalized, safer-to-use abstraction on top

So we already have access to raw data and a clear place for domain logic. This isn't a "maybe later" scenario - the domain model already exists in parallel, not in place of the raw data.

That said, if the direction for the SDK is to only reflect raw API structures and defer all interpretation to the application layer - with all the inconsistency and poorly documented details, then I’d prefer to remove the NutriScore domain model entirely for now. That keeps things focused and avoids introducing a partially accepted abstraction.

Just let me know how you'd like to proceed — I’m happy to align either way. My main goal is ensuring that the SDK is both cleanly structured and helpful for developers integrating Nutri-Score functionality.

String? nutriscore;

@JsonKey(name: 'nutriscore')
NutriScoreDetails? nutriScoreDetails;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki , toJson works (and is tested in test/nutriscore_from_json_test.dart line 219) because NutriScoreDetails is a fully serializable class due to @JsonSerializable annotation and implementation of fromJson and toJson functions.

It was actually your suggestion to use a custom class with @JsonSerializable instead of Map<String, ...> - I think this is a very neat solution, indeed.

String? nutriscore;

@JsonKey(name: 'nutriscore')
NutriScoreDetails? nutriScoreDetails;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@monsieurtanuki as mentioned before, there is nothing to do. It is fully serializable. See the code of NutriScoreDetails.

String? nutriscore;

@JsonKey(name: 'nutriscore')
NutriScoreDetails? nutriScoreDetails;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @monsieurtanuki ,

Yes, this has already been tested and confirmed.

As I mentioned 2 weeks ago (see the messages above), the test you're asking about was part of the PR. Here is a link to the specific line of code:

final original = NutriScoreDetails.fromJson(json);
final copy = NutriScoreDetails.fromJson(original.toJson());

This, along with the following expect(...) assertions, verifies the behavior you're asking about.

You had asked me to remove that test file, but I can restore it if you’d like. Alternatively, feel free to check out the same commit and run the test locally.

Let me know what you prefer.

@teolemon teolemon moved this from Todo to In Progress in 🧃🛠️ Open Food Facts SDK tracking Aug 14, 2025
@monsieurtanuki
Copy link
Contributor

@goerlitz What shall we do now?

@goerlitz
Copy link
Contributor Author

@goerlitz What shall we do now?

@monsieurtanuki I had other priorities in the past weeks but I will finish it in the next days.

@goerlitz goerlitz force-pushed the extract-nutriscore-details-from-response branch from c69715f to 37c533f Compare October 11, 2025 13:07
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 @goerlitz!

I think I already mentioned that months ago: in Smoothie we use fromJson/toJson to retrieve data from our local database. We don't retrieve it directly from the server.
If a "json" field is incomplete, like NutriscoreData2021 (that doesn't have a "proteins_points" fields, cf. https://fr.openfoodfacts.org/api/v3/product/3229820129488?fields=nutriscore), all the extra data will be somehow lost until we publish a new version of the package then a new version of the app and then we download the data again. Not the perfect situation if we want to analyze the data and be reactive with data that we don't have crystal clear instructions about.

That's why I suggest to consider both data fields (in NutriscoreDetail2021 and NutriscoreDetail2023) as raw Map<String, dynamic> fields. With that we won't loose data. And we'll still be able to decode them as NutriscoreData202*, potentially with extra methods in order to get extra fields (e.g. "fiber_points").

The first step would be basically this PR with raw Map<String, dynamic> data fields.

Then we'll see how to implement elegantly a more functional layer, in a separate PR.

What do you think of that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants