-
-
Notifications
You must be signed in to change notification settings - Fork 336
Expand file tree
/
Copy pathmeal_nutriments_entity.dart
More file actions
427 lines (394 loc) · 17 KB
/
Copy pathmeal_nutriments_entity.dart
File metadata and controls
427 lines (394 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:opennutritracker/core/data/dbo/meal_nutriments_dbo.dart';
import 'package:opennutritracker/core/utils/extensions.dart';
import 'package:opennutritracker/features/add_meal/data/dto/fdc/fdc_const.dart';
import 'package:opennutritracker/features/add_meal/data/dto/fdc/fdc_food_nutriment_dto.dart';
import 'package:opennutritracker/features/add_meal/data/dto/off/off_product_nutriments_dto.dart';
import 'package:opennutritracker/features/add_meal/data/dto/sp/sp_food_dto.dart';
class MealNutrimentsEntity extends Equatable {
final double? energyKcal100;
final double? carbohydrates100;
final double? fat100;
final double? proteins100;
final double? sugars100;
final double? saturatedFat100;
final double? fiber100;
// #237: Extended lipid profile
final double? monounsaturatedFat100;
final double? polyunsaturatedFat100;
final double? transFat100;
final double? cholesterol100;
// #237: Minerals (mg per 100g)
final double? sodium100;
final double? potassium100;
final double? magnesium100;
final double? calcium100;
final double? iron100;
final double? zinc100;
final double? phosphorus100;
// #237: Vitamins
final double? vitaminA100; // µg RAE
final double? vitaminC100; // mg
final double? vitaminD100; // µg
final double? vitaminB6100; // mg
final double? vitaminB12100; // µg
final double? niacin100; // mg (B3)
double? get energyPerUnit => _getValuePerUnit(energyKcal100);
double? get carbohydratesPerUnit => _getValuePerUnit(carbohydrates100);
double? get fatPerUnit => _getValuePerUnit(fat100);
double? get proteinsPerUnit => _getValuePerUnit(proteins100);
/// Returns true when at least one of the extended micronutrient fields
/// (lipid profile / minerals / vitamins from #237) has a value.
bool get hasMicronutrientData =>
monounsaturatedFat100 != null ||
polyunsaturatedFat100 != null ||
transFat100 != null ||
cholesterol100 != null ||
sodium100 != null ||
potassium100 != null ||
magnesium100 != null ||
calcium100 != null ||
iron100 != null ||
zinc100 != null ||
phosphorus100 != null ||
vitaminA100 != null ||
vitaminC100 != null ||
vitaminD100 != null ||
vitaminB6100 != null ||
vitaminB12100 != null ||
niacin100 != null;
const MealNutrimentsEntity({
required this.energyKcal100,
required this.carbohydrates100,
required this.fat100,
required this.proteins100,
required this.sugars100,
required this.saturatedFat100,
required this.fiber100,
this.monounsaturatedFat100,
this.polyunsaturatedFat100,
this.transFat100,
this.cholesterol100,
this.sodium100,
this.potassium100,
this.magnesium100,
this.calcium100,
this.iron100,
this.zinc100,
this.phosphorus100,
this.vitaminA100,
this.vitaminC100,
this.vitaminD100,
this.vitaminB6100,
this.vitaminB12100,
this.niacin100,
});
factory MealNutrimentsEntity.empty() => const MealNutrimentsEntity(
energyKcal100: null,
carbohydrates100: null,
fat100: null,
proteins100: null,
sugars100: null,
saturatedFat100: null,
fiber100: null,
);
factory MealNutrimentsEntity.fromMealNutrimentsDBO(
MealNutrimentsDBO nutriments,
) {
return MealNutrimentsEntity(
energyKcal100: nutriments.energyKcal100,
carbohydrates100: nutriments.carbohydrates100,
fat100: nutriments.fat100,
proteins100: nutriments.proteins100,
sugars100: nutriments.sugars100,
saturatedFat100: nutriments.saturatedFat100,
fiber100: nutriments.fiber100,
monounsaturatedFat100: nutriments.monounsaturatedFat100,
polyunsaturatedFat100: nutriments.polyunsaturatedFat100,
transFat100: nutriments.transFat100,
cholesterol100: nutriments.cholesterol100,
sodium100: nutriments.sodium100,
potassium100: nutriments.potassium100,
magnesium100: nutriments.magnesium100,
calcium100: nutriments.calcium100,
iron100: nutriments.iron100,
zinc100: nutriments.zinc100,
phosphorus100: nutriments.phosphorus100,
vitaminA100: nutriments.vitaminA100,
vitaminC100: nutriments.vitaminC100,
vitaminD100: nutriments.vitaminD100,
vitaminB6100: nutriments.vitaminB6100,
vitaminB12100: nutriments.vitaminB12100,
niacin100: nutriments.niacin100,
);
}
factory MealNutrimentsEntity.fromOffNutriments(
OFFProductNutrimentsDTO? offNutriments,
) {
if (offNutriments == null) return MealNutrimentsEntity.empty();
// 1. OFF product nutriments can either be String, int, double or null
// 2. Extension function asDoubleOrNull does not work on a dynamic data
// type, so cast to it Object?
return MealNutrimentsEntity(
energyKcal100:
(offNutriments.energy_kcal_100g as Object?).asDoubleOrNull(),
carbohydrates100:
(offNutriments.carbohydrates_100g as Object?).asDoubleOrNull(),
fat100: (offNutriments.fat_100g as Object?).asDoubleOrNull(),
proteins100: (offNutriments.proteins_100g as Object?).asDoubleOrNull(),
sugars100: (offNutriments.sugars_100g as Object?).asDoubleOrNull(),
saturatedFat100:
(offNutriments.saturated_fat_100g as Object?).asDoubleOrNull(),
fiber100: (offNutriments.fiber_100g as Object?).asDoubleOrNull(),
// #237: Extended lipid profile. These three are grams either way, so
// they pass through like the macros above.
monounsaturatedFat100:
(offNutriments.monounsaturated_fat_100g as Object?).asDoubleOrNull(),
polyunsaturatedFat100:
(offNutriments.polyunsaturated_fat_100g as Object?).asDoubleOrNull(),
transFat100: (offNutriments.trans_fat_100g as Object?).asDoubleOrNull(),
cholesterol100: _gToMg(
(offNutriments.cholesterol_100g as Object?).asDoubleOrNull(),
),
// #237: Minerals
sodium100: _gToMg(
(offNutriments.sodium_100g as Object?).asDoubleOrNull(),
),
potassium100: _gToMg(
(offNutriments.potassium_100g as Object?).asDoubleOrNull(),
),
magnesium100: _gToMg(
(offNutriments.magnesium_100g as Object?).asDoubleOrNull(),
),
calcium100: _gToMg(
(offNutriments.calcium_100g as Object?).asDoubleOrNull(),
),
iron100: _gToMg((offNutriments.iron_100g as Object?).asDoubleOrNull()),
zinc100: _gToMg((offNutriments.zinc_100g as Object?).asDoubleOrNull()),
phosphorus100: _gToMg(
(offNutriments.phosphorus_100g as Object?).asDoubleOrNull(),
),
// #237: Vitamins
vitaminA100: _gToUg(
(offNutriments.vitamin_a_100g as Object?).asDoubleOrNull(),
),
vitaminC100: _gToMg(
(offNutriments.vitamin_c_100g as Object?).asDoubleOrNull(),
),
vitaminD100: _gToUg(
(offNutriments.vitamin_d_100g as Object?).asDoubleOrNull(),
),
vitaminB6100: _gToMg(
(offNutriments.vitamin_b6_100g as Object?).asDoubleOrNull(),
),
vitaminB12100: _gToUg(
(offNutriments.vitamin_b12_100g as Object?).asDoubleOrNull(),
),
niacin100: _gToMg(
(offNutriments.niacin_100g as Object?).asDoubleOrNull(),
),
);
}
/// Open Food Facts normalises every `<nutrient>_100g` field to grams, while
/// this entity carries each micronutrient in the unit the app displays it
/// in: milligrams for the minerals, micrograms for vitamins A, D and B12.
/// The field comments above are the reference for which is which.
///
/// Without these two conversions a product's 2.1 mg of iron was stored as
/// 0.0021 and its 5 µg of vitamin D as 0.000005, which is why vitamin D
/// read `0.00µg` everywhere and why an OFF food contributed essentially
/// nothing to the daily micronutrient panel (#716).
///
/// The other two sources need no conversion: the Supabase view already
/// pivots into the app's units, and FDC publishes these nutrients in mg and
/// µg natively.
static double? _gToMg(double? grams) => grams == null ? null : grams * 1000;
static double? _gToUg(double? grams) =>
grams == null ? null : grams * 1000000;
/// Nutrients from the Supabase `food_summary` view. The view already
/// pivots the canonical per-100g nutrient rows into flat columns in the
/// app's units, so this is a straight field-for-field copy.
factory MealNutrimentsEntity.fromSpFoodSummary(SpFoodDTO food) {
return MealNutrimentsEntity(
energyKcal100: food.energyKcal100,
carbohydrates100: food.carbohydrates100,
fat100: food.fat100,
proteins100: food.proteins100,
sugars100: food.sugars100,
saturatedFat100: food.saturatedFat100,
fiber100: food.fiber100,
monounsaturatedFat100: food.monounsaturatedFat100,
polyunsaturatedFat100: food.polyunsaturatedFat100,
transFat100: food.transFat100,
cholesterol100: food.cholesterol100,
sodium100: food.sodium100,
potassium100: food.potassium100,
magnesium100: food.magnesium100,
calcium100: food.calcium100,
iron100: food.iron100,
zinc100: food.zinc100,
phosphorus100: food.phosphorus100,
vitaminA100: food.vitaminA100,
vitaminC100: food.vitaminC100,
vitaminD100: food.vitaminD100,
vitaminB6100: food.vitaminB6100,
vitaminB12100: food.vitaminB12100,
niacin100: food.niacin100,
);
}
factory MealNutrimentsEntity.fromFDCNutriments(
List<FDCFoodNutrimentDTO> fdcNutriment,
) {
double? fdcAmount(int nutrientId) => fdcNutriment
.firstWhereOrNull((nutriment) => nutriment.nutrientId == nutrientId)
?.amount;
// FDC Food nutriments can have different values for Energy [Energy,
// Energy (Atwater General Factors), Energy (Atwater Specific Factors)].
// Prefer the most-precise Atwater value; fall back to raw total last.
final energyTotal = fdcAmount(FDCConst.fdcKcalAtwaterSpecificId) ??
fdcAmount(FDCConst.fdcKcalAtwaterGeneralId) ??
fdcAmount(FDCConst.fdcTotalKcalId);
return MealNutrimentsEntity(
energyKcal100: energyTotal,
carbohydrates100: fdcAmount(FDCConst.fdcTotalCarbsId),
fat100: fdcAmount(FDCConst.fdcTotalFatId),
proteins100: fdcAmount(FDCConst.fdcTotalProteinsId),
sugars100: fdcAmount(FDCConst.fdcTotalSugarId),
saturatedFat100: fdcAmount(FDCConst.fdcTotalSaturatedFatId),
fiber100: fdcAmount(FDCConst.fdcTotalDietaryFiberId),
// #237: Extended lipid profile
monounsaturatedFat100: fdcAmount(FDCConst.fdcMonounsaturatedFatId),
polyunsaturatedFat100: fdcAmount(FDCConst.fdcPolyunsaturatedFatId),
transFat100: fdcAmount(FDCConst.fdcTransFatId),
cholesterol100: fdcAmount(FDCConst.fdcCholesterolId),
// #237: Minerals
sodium100: fdcAmount(FDCConst.fdcSodiumId),
potassium100: fdcAmount(FDCConst.fdcPotassiumId),
magnesium100: fdcAmount(FDCConst.fdcMagnesiumId),
calcium100: fdcAmount(FDCConst.fdcCalciumId),
iron100: fdcAmount(FDCConst.fdcIronId),
zinc100: fdcAmount(FDCConst.fdcZincId),
phosphorus100: fdcAmount(FDCConst.fdcPhosphorusId),
// #237: Vitamins
vitaminA100: fdcAmount(FDCConst.fdcVitaminAId),
vitaminC100: fdcAmount(FDCConst.fdcVitaminCId),
vitaminD100: fdcAmount(FDCConst.fdcVitaminDId),
vitaminB6100: fdcAmount(FDCConst.fdcVitaminB6Id),
vitaminB12100: fdcAmount(FDCConst.fdcVitaminB12Id),
niacin100: fdcAmount(FDCConst.fdcNiacinId),
);
}
static double? _getValuePerUnit(double? valuePer100) {
if (valuePer100 != null) {
return valuePer100 / 100;
} else {
return null;
}
}
@override
List<Object?> get props => [
energyKcal100,
carbohydrates100,
fat100,
proteins100,
];
}
/// Outcome of running the three physical-plausibility checks against a
/// [MealNutrimentsEntity] parsed from a remote source (FDC via Supabase, OFF,
/// or the direct FDC API). When [isConsistent] is false, [failureReason]
/// names the first rule that tripped — that is the value to attach to a
/// Sentry breadcrumb so we can spot systematic upstream problems.
///
/// The rules come from issue #222 and are deliberately conservative: they
/// only fire on data that is physically impossible on a 100g basis, so a
/// borderline-noisy-but-plausible item is left alone.
class NutrimentsValidationResult {
final bool isConsistent;
final String? failureReason;
const NutrimentsValidationResult.ok()
: isConsistent = true,
failureReason = null;
const NutrimentsValidationResult.failed(this.failureReason)
: isConsistent = false;
}
/// Tolerance applied to weight-summation and subset-of comparisons. Source
/// data is typically rounded to 0.1g or 1g, and the per-nutrient values do
/// not always sum exactly; a small slack keeps us from dropping items that
/// are merely noisy rather than corrupt.
const double _nutrimentsValidationToleranceG = 1.0;
/// Returns true when [nutriments] passes the three physical-plausibility
/// rules from issue #222:
///
/// 1. `sugars100 <= carbohydrates100` (sugars are a subset of carbs)
/// 2. `saturatedFat100 <= fat100` (sat fat is a subset of total fat)
/// 3. `carbohydrates100 + fat100 + proteins100 <= 100g` (per-100g basis)
///
/// A null on either side of a comparison skips the rule (we cannot prove a
/// violation without both values). A small tolerance absorbs rounding from
/// the source.
///
/// Convenience wrapper around [validateNutriments] for callers that only
/// need the pass/fail bit.
bool isNutrimentsConsistent(MealNutrimentsEntity nutriments) =>
validateNutriments(nutriments).isConsistent;
/// Full validation result. Use this when you need the failing rule name —
/// for example, to log a Sentry breadcrumb at the call site that dropped the
/// item from search results.
NutrimentsValidationResult validateNutriments(MealNutrimentsEntity nutriments) {
final sugars = nutriments.sugars100;
final carbs = nutriments.carbohydrates100;
if (sugars != null && carbs != null && sugars > carbs + _nutrimentsValidationToleranceG) {
return const NutrimentsValidationResult.failed('sugars_exceed_carbs');
}
final satFat = nutriments.saturatedFat100;
final fat = nutriments.fat100;
if (satFat != null && fat != null && satFat > fat + _nutrimentsValidationToleranceG) {
return const NutrimentsValidationResult.failed('saturated_fat_exceeds_total_fat');
}
// Per-100g basis: the weight-bearing macros (carbs + fat + protein) cannot
// sum to more than 100g. Fibre is intentionally excluded because it is
// typically already inside the carbohydrate total in both FDC and OFF
// accounting, so adding it would double-count. Micronutrients are in
// mg/µg so they do not enter the sum.
final protein = nutriments.proteins100;
final macroSum = (carbs ?? 0) + (fat ?? 0) + (protein ?? 0);
if (macroSum > 100 + _nutrimentsValidationToleranceG) {
return const NutrimentsValidationResult.failed('macros_exceed_100g');
}
return const NutrimentsValidationResult.ok();
}
/// Maximum relative gap between declared and Atwater-computed energy before a
/// product is treated as data-incoherent for ranking. Deliberately generous:
/// across a sample of ~700 Open Food Facts products the legitimate noise floor
/// (energy/macro rounding, food-specific Atwater factors, the EU/US fibre-in-
/// carbs convention) reaches ~10-15%, while genuine data errors are gross
/// (77% to >20000%). 25% sits in the empty gap between the two, so it demotes
/// real errors without penalising correctly-entered data.
const double atwaterEnergyTolerance = 0.25;
/// Relative difference between the declared energy and the energy implied by
/// the macros via the general Atwater factors (carbs 4, protein 4, fat 9
/// kcal/g). Fibre is not added separately for the same reason the macro-sum
/// check omits it — it is already inside the carbohydrate total here.
///
/// Returns null when there is nothing to judge (no declared kcal, or none of
/// the three macros present); callers should treat null as "no opinion" rather
/// than as inconsistent, so products with sparse data are ranked on the other
/// signals instead of being penalised.
double? atwaterEnergyRelativeError(MealNutrimentsEntity nutriments) {
final energy = nutriments.energyKcal100;
if (energy == null || energy <= 0) return null;
final carbs = nutriments.carbohydrates100;
final fat = nutriments.fat100;
final protein = nutriments.proteins100;
if (carbs == null && fat == null && protein == null) return null;
final computed = 4 * (carbs ?? 0) + 4 * (protein ?? 0) + 9 * (fat ?? 0);
return (energy - computed).abs() / energy;
}
/// True when a product's declared energy is coherent with its macros (within
/// [atwaterEnergyTolerance]) or cannot be judged. False only for products that
/// are computable AND diverge beyond the tolerance — the gross-error outliers.
bool isAtwaterConsistent(MealNutrimentsEntity nutriments) {
final error = atwaterEnergyRelativeError(nutriments);
return error == null || error <= atwaterEnergyTolerance;
}