feat: 1072 - Add Nutri-Score details#1079
feat: 1072 - Add Nutri-Score details#1079goerlitz wants to merge 16 commits intoopenfoodfacts:masterfrom
Conversation
Codecov ReportAttention: Patch coverage is
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. 🚀 New features to boost your workflow:
|
|
@stephanegigandet perhaps some inspiration for refactoring |
lib/src/model/nutriscore.dart
Outdated
|
|
||
| @override | ||
| String toString() { | ||
| return 'NutriScore{status: ${status.name}, grade: ${grade?.name}, version: $version, category: $category, score: $score, notApplicableCategory: $notApplicableCategory, errorMessage: $errorMessage, missingData: $missingData}'; |
There was a problem hiding this comment.
I'm sure we can afford some line jumps like that:
| 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' |
There was a problem hiding this comment.
You're right, we don't send back this data to the server.
But, as I explained in another comment
We need a
toJsontoo, 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.
lib/src/model/nutriscore.dart
Outdated
| /// but partial information such as [version] and [category] may still be present. | ||
| /// | ||
| /// The [missingData] parameter indicates which data is missing. | ||
| factory NutriScore.unknown([ |
There was a problem hiding this comment.
Just by curiosity: what's the added value of factory instead of named constructor in this file?
There was a problem hiding this comment.
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.
| @JsonKey( | ||
| name: 'nutriscore', | ||
| fromJson: NutriScore.fromJson, | ||
| includeToJson: false, | ||
| ) |
There was a problem hiding this 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.
There was a problem hiding this comment.
@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?
lib/src/model/product.dart
Outdated
| fromJson: NutriScore.fromJson, | ||
| includeToJson: false, | ||
| ) | ||
| Map<NutriScoreVersion, NutriScore>? nutriscores; |
There was a problem hiding this comment.
I would name it nutriscoreDetails too, as you did as a ProductField. That way it would be easier to keep the track.
lib/src/model/nutriscore_raw.dart
Outdated
| } | ||
| } | ||
|
|
||
| bool parseBool(Map<String, dynamic> json, String key) { |
There was a problem hiding this comment.
We already have JsonObject.parseBool.
There was a problem hiding this comment.
Ah, I only found JsonHelper.boolFromJSON which is not appropriate.
I will replace with JsonObject.parseBool.
|
@monsieurtanuki, I made the requested changes:
|
monsieurtanuki
left a comment
There was a problem hiding this comment.
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.
lib/src/model/nutriscore.dart
Outdated
| /// ```dart | ||
| /// final nutriScore = product.nutriscoreDetails?[NutriScoreVersion.v2023]; | ||
| /// | ||
| /// final category = switch (nutriScore?.category?.as2023) { |
There was a problem hiding this comment.
nutriscore or nutriscoreDetails?
There was a problem hiding this comment.
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)).
lib/src/model/nutriscore_enums.dart
Outdated
|
|
||
| enum NutriScoreInput { category, nutrients, ingredients } | ||
|
|
||
| enum NutriScoreVersion { |
There was a problem hiding this comment.
Please extend OffTagged for that.
There was a problem hiding this comment.
enum NutriScoreInput { category, nutrients, ingredients } are not tags in json.
But let me check if version could be used via OffTagged.
lib/src/model/nutriscore_enums.dart
Outdated
|
|
||
| /// 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) { |
There was a problem hiding this comment.
To be replaced with fromOffTag
lib/src/model/nutriscore_raw.dart
Outdated
| final bool isBeverage; | ||
| final bool isCheese; | ||
| final bool isFat; // 2021 | ||
| final bool isFatOilNutsSeeds; // 2023 | ||
| final bool isRedMeatProduct; // 2023 | ||
| final bool isWater; |
There was a problem hiding this comment.
The underlying JSON is not "very elegant", but let me check if I can handle this with OffTagged instead.
There was a problem hiding this comment.
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.
lib/src/model/nutriscore_raw.dart
Outdated
| isWater = JsonObject.parseBool(json['is_water']); | ||
|
|
||
| @override | ||
| String toString() { |
There was a problem hiding this comment.
I'm not sure we need all those toString methods.
Makes the code harder to maintain, for very limited added value.
There was a problem hiding this comment.
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.
test/nutriscore_from_json_test.dart
Outdated
| }); | ||
| } | ||
|
|
||
| final computed = { |
There was a problem hiding this comment.
Please put those finals on top of the file and inside the main.
test/api_get_product_test.dart
Outdated
| expect(result.product, isNotNull); | ||
| expect(result.product!.barcode, barcode); | ||
|
|
||
| final nutriScore = |
There was a problem hiding this comment.
Please make a specific test method for that, specifically requesting only ProductField.NUTRISCORE_DETAILS.
test/api_get_product_test.dart
Outdated
|
|
||
| expect(result.product!.nutriscore, 'e'); | ||
|
|
||
| final nutriScore = |
There was a problem hiding this comment.
Please make a specific test method for that, specifically requesting only ProductField.NUTRISCORE_DETAILS.
lib/src/model/nutriscore.dart
Outdated
| @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, |
There was a problem hiding this comment.
Do we need to order nutriscores?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
To be honest it's a lot of noise.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ;)
Actually, my app needs a simple way to access all of this Nutri-Score information - that why I implemented it.
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 Let me try to leverage |
|
@goerlitz I'm not saying that your code doesn't work or isn't appropriate for your 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
- 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
79fb11e to
7723586
Compare
monsieurtanuki
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
"isUnknown"? What about hasMissingData?
There was a problem hiding this comment.
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. :-/
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I reintroduced the NutriScoreStatus enum (computed, notApplicable, unknown) for these reasons:
-
Matches the raw API: The
gradefield 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, andhasMissingDataflags 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.
|
@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:
Final verdict:
|
monsieurtanuki
left a comment
There was a problem hiding this comment.
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 {} |
There was a problem hiding this comment.
As time goes by we saw in the project that we needed to be more flexible.
I'd prefer a public class.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
- 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.
- 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; | ||
| } |
There was a problem hiding this comment.
| } | |
| /// 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, |
There was a problem hiding this comment.
| String? grade, | |
| NutriScoreGrade? grade, |
| categoryAvailable: rawNutriScore2021?.categoryAvailable ?? false, | ||
| nutrientsAvailable: rawNutriScore2021?.nutrientsAvailable ?? false, |
There was a problem hiding this comment.
If it's null, it's null.
| 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; |
There was a problem hiding this comment.
| final bool categoryAvailable; | |
| final bool? categoryAvailable; |
There was a problem hiding this comment.
What would be the difference between null and false?
There was a problem hiding this comment.
What would be the difference between
nullandfalse?
Same difference as between "I don't know" and "no".
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
| final bool nutrientsAvailable; | |
| final bool? nutrientsAvailable; |
There was a problem hiding this comment.
same here, what would be the difference between null and false?
There was a problem hiding this comment.
same here, what would be the difference between
nullandfalse?
Same difference as between "I don't know" and "no".
|
|
||
| return NutriScore2021( | ||
| category: rawNutriScore2021?.data?.category, | ||
| grade: rawNutriScore2021?.grade, |
There was a problem hiding this comment.
| grade: rawNutriScore2021?.grade, | |
| grade: NutriScoreGrade.fromOffTag(rawNutriScore2021?.grade), |
|
|
||
| return NutriScore2023( | ||
| category: rawNutriScore2023?.data?.category, | ||
| grade: rawNutriScore2023?.grade, |
There was a problem hiding this comment.
| grade: rawNutriScore2023?.grade, | |
| grade: NutriScoreGrade.fromOffTag(rawNutriScore2023?.grade), |
|
@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 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 Let me know how you’d like to proceed! |
|
@goerlitz If still needed, please give me the links of the 4 comments where you expected my reaction. |
@monsieurtanuki Let me split it into two PRs - this will keep a clear separation between
|
|
@goerlitz Perfect! Please ping me when you're done with the boring code PR. |
|
Hi @monsieurtanuki, 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. |
monsieurtanuki
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
@goerlitz I don't think you addressed this comment, did you?
There was a problem hiding this comment.
@monsieurtanuki as mentioned before, there is nothing to do. It is fully serializable. See the code of NutriScoreDetails.
There was a problem hiding this comment.
@goerlitz So let me ask again, have you checked that
- if you create a
Productnamedproduct - and set its
nutriscoreDetailsto something not null - then store
product.toJson()in variableproductJson - then store
Product.fromJson(productJson)in variableproductRestored - and check what you have in
productRestored.nutriscoreDetails, you have the same thing as inproduct.nutriscoreDetails?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 aboutNutriScoreDetailswithin a Product.
So, please add a test:
- with a product downloaded from the server, including your new nutriscore field
- then store product.toJson() in variable productJson
- then store Product.fromJson(productJson) in variable productRestored
- and check what you have in productRestored.nutriscoreDetails, you have the same thing as in product.nutriscoreDetails
There was a problem hiding this comment.
okay, I can do it this way if it is the preferred approach.
There was a problem hiding this comment.
@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.
|
@monsieurtanuki did you have the time to check if everything is good now? |
|
@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:
Does this finally look good for you? |
There was a problem hiding this comment.
@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, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
@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; |
There was a problem hiding this comment.
@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; |
There was a problem hiding this comment.
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.
|
@goerlitz What shall we do now? |
@monsieurtanuki I had other priorities in the past weeks but I will finish it in the next days. |
…e-details-from-response
c69715f to
37c533f
Compare
monsieurtanuki
left a comment
There was a problem hiding this comment.
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?
What
Introduces a developer-friendly Nutri-Score domain model to access and interpret structured data from thenutriscorefield.JSON data returned by API:
Why
nutriscorefield contains rich metadata, including Version-specific data (2021, 2023), grade, score, info, and data availability, multiple overlapping boolean flags (is_*) to indicate food categoryChallenges
a–e,not-applicable,unknown)is_*) and no flag or general category1.0,1vs"1")Solution
Create 2 layers:
Benefits of the high-level domain model:
Structure
model/nutriscore/nutriscore.dartHigh level, strongly typed Nutri-Score domain model