Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/openfoodfacts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export 'src/model/nutrient_modifier.dart';
export 'src/model/nutrient.dart';
export 'src/model/nutrient_levels.dart';
export 'src/model/nutriments.dart';
export 'src/model/nutriscore/nutriscore.dart';
export 'src/model/nutriscore/nutriscore_data_2021.dart';
export 'src/model/nutriscore/nutriscore_data_2023.dart';
export 'src/model/nutriscore/nutriscore_detail_2021.dart';
export 'src/model/nutriscore/nutriscore_detail_2023.dart';
export 'src/model/nutriscore/nutriscore_details.dart';

// export 'src/model/product_list.dart'; // not needed
export 'src/model/ocr_ingredients_result.dart';
Expand Down
135 changes: 135 additions & 0 deletions lib/src/model/nutriscore/nutriscore.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import 'package:openfoodfacts/openfoodfacts.dart';

enum NutriScoreCategory2021 {
general,
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
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/

cheese,
fat,
beverage,
water,
}

enum NutriScoreCategory2023 {
general,
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
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/

cheese,
redMeatProduct,
fatOilNutsSeeds,
beverage,
water,
}

enum NutriScoreGrade { A, B, C, D, E }

enum NutriScoreInput { category, nutrients, ingredients }

typedef NutriScore2021 = _NutriScore<NutriScoreCategory2021>;
typedef NutriScore2023 = _NutriScore<NutriScoreCategory2023>;

/// Private base class for NutriScore.
/// It is used to define the common properties and methods for both
/// NutriScore 2021 and NutriScore 2023.
class _NutriScore<T extends Enum> {
final NutriScoreGrade? grade;
final int? score;
final String? notApplicableCategory;
final List<NutriScoreInput> missingData;
final T? category;

_NutriScore({
this.grade,
this.category,
this.score,
this.notApplicableCategory,
this.missingData = const [],
}) : assert(
(grade != null && score != null && category != null) ||
(grade == null && (notApplicableCategory?.isNotEmpty ?? false)) ||
(grade == null && missingData.isNotEmpty),
'Either NutriScore is computed or not applicable or unknown',
);

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.

}

NutriScoreGrade? _parseGrade(String? grade) {
switch (grade?.toLowerCase()) {
case 'a':
return NutriScoreGrade.A;
case 'b':
return NutriScoreGrade.B;
case 'c':
return NutriScoreGrade.C;
case 'd':
return NutriScoreGrade.D;
case 'e':
return NutriScoreGrade.E;
default:
return null;
}
}

List<NutriScoreInput> _missingData(bool? hasCategory, bool? hasNutrients) => [
if (hasCategory != true) NutriScoreInput.category,
if (hasNutrients != true) NutriScoreInput.nutrients,
];

extension NutriScoreDetails2021Ext on NutriScoreDetails {
/// Returns the NutriScore 2021 as domain model.
NutriScore2021? get2021() {
if (nutriScore2021 == null) return null;

return NutriScore2021(
category: nutriScore2021?.data?.category,
grade: _parseGrade(nutriScore2021?.grade),
score: nutriScore2021?.score,
notApplicableCategory: nutriScore2021?.notApplicableCategory,
missingData: _missingData(
nutriScore2021?.categoryAvailable,
nutriScore2021?.nutrientsAvailable,
),
);
}

/// Returns the NutriScore 2023 as domain model.
NutriScore2023? get2023() {
if (nutriScore2023 == null) return null;

return NutriScore2023(
category: nutriScore2023?.data?.category,
grade: _parseGrade(nutriScore2023?.grade),
score: nutriScore2023?.score,
notApplicableCategory: nutriScore2023?.notApplicableCategory,
missingData: _missingData(
nutriScore2023?.categoryAvailable,
nutriScore2023?.nutrientsAvailable,
),
);
}
}

/// Extension to infer [NutriScoreCategory2021] from boolean flags.
extension NutriScoreData2021Ext on NutriScoreData2021 {
NutriScoreCategory2021 get category {
// water must be checked first to avoid beverage+water conflict
if (isWater == true) return NutriScoreCategory2021.water;
if (isBeverage == true) return NutriScoreCategory2021.beverage;
if (isFat == true) return NutriScoreCategory2021.fat;
if (isCheese == true) return NutriScoreCategory2021.cheese;
return NutriScoreCategory2021.general;
}
}

/// Extension to infer [NutriScoreCategory2023] from boolean flags.
extension NutriScoreData2023Ext on NutriScoreData2023 {
NutriScoreCategory2023 get category {
// water must be checked first to avoid beverage+water conflict
if (isWater == true) return NutriScoreCategory2023.water;
if (isBeverage == true) return NutriScoreCategory2023.beverage;
if (isFatOilNutsSeeds == true)
return NutriScoreCategory2023.fatOilNutsSeeds;
if (isCheese == true) return NutriScoreCategory2023.cheese;
if (isRedMeatProduct == true) return NutriScoreCategory2023.redMeatProduct;
return NutriScoreCategory2023.general;
}
}
51 changes: 51 additions & 0 deletions lib/src/model/nutriscore/nutriscore_data_2021.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:json_annotation/json_annotation.dart';
import '../../interface/json_object.dart';
import '../../utils/json_helper.dart';

part 'nutriscore_data_2021.g.dart';

/// Detailed data of NutriScore version 2021.
@JsonSerializable()
class NutriScoreData2021 extends JsonObject {
@JsonKey(
name: 'is_beverage',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isBeverage;

@JsonKey(
name: 'is_cheese',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isCheese;

@JsonKey(
name: 'is_fat',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isFat;

@JsonKey(
name: 'is_water',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isWater;

@JsonKey(name: 'negative_points')
int? negativePoints;

@JsonKey(name: 'positive_points')
int? positivePoints;

NutriScoreData2021();

factory NutriScoreData2021.fromJson(Map<String, dynamic> json) =>
_$NutriScoreData2021FromJson(json);

@override
Map<String, dynamic> toJson() => _$NutriScoreData2021ToJson(this);
}
26 changes: 26 additions & 0 deletions lib/src/model/nutriscore/nutriscore_data_2021.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions lib/src/model/nutriscore/nutriscore_data_2023.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'package:json_annotation/json_annotation.dart';
import '../../interface/json_object.dart';
import '../../utils/json_helper.dart';

part 'nutriscore_data_2023.g.dart';

/// Detailed data of NutriScore version 2023.
@JsonSerializable()
class NutriScoreData2023 extends JsonObject {
@JsonKey(
name: 'count_proteins',
toJson: JsonHelper.boolToJSON,
fromJson: JsonHelper.boolFromJSON,
)
bool? countProteins;

@JsonKey(name: 'count_proteins_reason')
String? countProteinsReason;

@JsonKey(
name: 'is_beverage',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isBeverage;

@JsonKey(
name: 'is_cheese',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isCheese;

@JsonKey(
name: 'is_fat_oil_nuts_seeds',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isFatOilNutsSeeds;

@JsonKey(
name: 'is_red_meat_product',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isRedMeatProduct;

@JsonKey(
name: 'is_water',
toJson: JsonHelper.boolToJSON,
fromJson: JsonObject.parseBool,
)
bool? isWater;

@JsonKey(name: 'negative_points')
int? negativePoints;

@JsonKey(name: 'negative_points_max')
int? negativePointsMax;

@JsonKey(name: 'positive_points')
int? positivePoints;

@JsonKey(name: 'positive_points_max')
int? positivePointsMax;

NutriScoreData2023();

factory NutriScoreData2023.fromJson(Map<String, dynamic> json) =>
_$NutriScoreData2023FromJson(json);

@override
Map<String, dynamic> toJson() => _$NutriScoreData2023ToJson(this);
}
37 changes: 37 additions & 0 deletions lib/src/model/nutriscore/nutriscore_data_2023.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions lib/src/model/nutriscore/nutriscore_detail_2021.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'package:json_annotation/json_annotation.dart';
import '../../interface/json_object.dart';
import '../../utils/json_helper.dart';
import 'nutriscore_data_2021.dart';

part 'nutriscore_detail_2021.g.dart';

/// Data of NutriScore version 2021.
@JsonSerializable(explicitToJson: true)
class NutriScoreDetail2021 extends JsonObject {
@JsonKey()
String? grade;

@JsonKey()
int? score;

@JsonKey()
NutriScoreData2021? data;

@JsonKey(name: 'not_applicable_category')
String? notApplicableCategory;

@JsonKey(
name: 'category_available',
toJson: JsonHelper.boolToJSON,
fromJson: JsonHelper.boolFromJSON,
)
bool? categoryAvailable;

@JsonKey(
name: 'nutrients_available',
toJson: JsonHelper.boolToJSON,
fromJson: JsonHelper.boolFromJSON,
)
bool? nutrientsAvailable;

@JsonKey(
name: 'nutriscore_applicable',
toJson: JsonHelper.boolToJSON,
fromJson: JsonHelper.boolFromJSON,
)
bool? nutriScoreApplicable;

@JsonKey(
name: 'nutriscore_computed',
toJson: JsonHelper.boolToJSON,
fromJson: JsonHelper.boolFromJSON,
)
bool? nutriScoreComputed;

NutriScoreDetail2021();

factory NutriScoreDetail2021.fromJson(Map<String, dynamic> json) =>
_$NutriScoreDetail2021FromJson(json);

@override
Map<String, dynamic> toJson() => _$NutriScoreDetail2021ToJson(this);
}
Loading
Loading