229 lines
8.6 KiB
Dart
229 lines
8.6 KiB
Dart
import 'package:openfoodfacts/openfoodfacts.dart';
|
|
|
|
import '../../domain/entities/food_product.dart';
|
|
import 'remote_food_data_source.dart';
|
|
|
|
/// Client for the Open Food Facts crowdsourced product database.
|
|
///
|
|
/// Configures the shared [OpenFoodAPIConfiguration] with the app's user
|
|
/// agent (contact info + app identity, per the OFF API guidelines) and maps
|
|
/// raw [Product] records onto the nutrition feature's [FoodProduct] entity.
|
|
///
|
|
/// All lookups are wrapped by the search repository in a 4-second timeout so
|
|
/// a slow OFF backend degrades into the USDA FoodData Central fallback
|
|
/// instead of blocking the UI.
|
|
class OpenFoodFactsClient implements RemoteFoodDataSource {
|
|
OpenFoodFactsClient() {
|
|
configureUserAgent();
|
|
}
|
|
|
|
/// Identifies the app to the Open Food Facts API (politeness policy:
|
|
/// include app name, version, and contact info so operators can reach the
|
|
/// maintainer if traffic misbehaves).
|
|
static void configureUserAgent() {
|
|
OpenFoodAPIConfiguration.userAgent = UserAgent(
|
|
name: 'GLP-1 Companion',
|
|
version: '0.1.0',
|
|
url: 'https://git.kellsupport.com/mtkell/glp-companion',
|
|
comment: 'Nutrition lookup for GLP-1 therapy tracking; '
|
|
'maintainer contact: mt.kell (at) kellsupport.com',
|
|
);
|
|
}
|
|
|
|
/// Fields requested for every remote product so the mapper has everything
|
|
/// it needs without pulling the entire OFF record.
|
|
static const List<ProductField> _fields = [
|
|
ProductField.BARCODE,
|
|
ProductField.NAME,
|
|
ProductField.GENERIC_NAME,
|
|
ProductField.BRANDS,
|
|
ProductField.SERVING_SIZE,
|
|
ProductField.SERVING_QUANTITY,
|
|
// ignore: deprecated_member_use
|
|
ProductField.NUTRIMENTS,
|
|
ProductField.IMAGE_FRONT_SMALL_URL,
|
|
ProductField.IMAGE_FRONT_URL,
|
|
];
|
|
|
|
/// Resolves a single UPC/EAN [barcode]; empty when OFF does not know the
|
|
/// code.
|
|
@override
|
|
Future<List<FoodProduct>> lookupBarcode(String barcode) async {
|
|
final trimmed = barcode.trim();
|
|
if (trimmed.isEmpty) return const [];
|
|
|
|
final configuration = ProductQueryConfiguration(
|
|
trimmed,
|
|
fields: _fields,
|
|
version: ProductQueryVersion.latestVersion,
|
|
);
|
|
|
|
try {
|
|
final result = await OpenFoodAPIClient.getProductV3(configuration);
|
|
if (result.status != ProductResultV3.statusSuccess) return const [];
|
|
final product = result.product;
|
|
if (product == null) return const [];
|
|
final mapped = _mapProduct(product);
|
|
return mapped == null ? const [] : [mapped];
|
|
} on TooManyRequestsException {
|
|
rethrow;
|
|
} catch (_) {
|
|
// OFF throws HttpStatusException (and friends) for unknown barcodes;
|
|
// treat every non-rate-limit failure as "not found" so the repository
|
|
// can fall through to the USDA source.
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Free-text product search (e.g. "greek yogurt").
|
|
@override
|
|
Future<List<FoodProduct>> searchByText(String query) async {
|
|
final trimmed = query.trim();
|
|
if (trimmed.isEmpty) return const [];
|
|
|
|
final configuration = ProductSearchQueryConfiguration(
|
|
parametersList: [
|
|
SearchTerms(terms: trimmed.split(RegExp(r'\s+'))),
|
|
],
|
|
fields: _fields,
|
|
version: ProductQueryVersion.latestVersion,
|
|
);
|
|
|
|
final result = await OpenFoodAPIClient.searchProducts(null, configuration);
|
|
final products = result.products ?? const <Product>[];
|
|
return products
|
|
.map(_mapProduct)
|
|
.whereType<FoodProduct>()
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// Maps an OFF [Product] onto the domain entity; returns `null` when the
|
|
/// record has no name or no usable energy value.
|
|
FoodProduct? _mapProduct(Product product) {
|
|
final name = _firstNonEmpty(product.productName, product.genericName);
|
|
if (name == null) return null;
|
|
|
|
final barcode = product.barcode?.trim();
|
|
final brand = product.brands?.split(',').first.trim();
|
|
|
|
final servingGrams = _resolveServingGrams(product);
|
|
final perServing = servingGrams != null && servingGrams > 0;
|
|
|
|
// Values are read per-serving when OFF carries them, otherwise the
|
|
// per-100 g figures are scaled onto the resolved serving size.
|
|
final calories = _nutrient(product, Nutrient.energyKCal, perServing);
|
|
if (calories == null) return null;
|
|
|
|
final imageFront = product.imageFrontSmallUrl?.trim();
|
|
final imageFull = product.imageFrontUrl?.trim();
|
|
|
|
return FoodProduct(
|
|
id: (barcode == null || barcode.isEmpty)
|
|
// Offline-only OFF records still get a stable id.
|
|
? 'off:${product.barcode ?? product.productName}'
|
|
: barcode,
|
|
barcode: (barcode == null || barcode.isEmpty) ? null : barcode,
|
|
productName: name,
|
|
brand: (brand == null || brand.isEmpty) ? null : brand,
|
|
servingSizeGrams: servingGrams ?? 100,
|
|
servingSizeUnit: _unitFromServingSize(product.servingSize) ?? 'g',
|
|
calories: calories,
|
|
proteinGrams: _nutrient(product, Nutrient.proteins, perServing) ?? 0,
|
|
carbsGrams: _nutrient(product, Nutrient.carbohydrates, perServing) ?? 0,
|
|
fatGrams: _nutrient(product, Nutrient.fat, perServing) ?? 0,
|
|
fiberGrams: _nutrient(product, Nutrient.fiber, perServing),
|
|
sodiumMg: _sodiumMg(product, perServing),
|
|
imageUrl: (imageFront?.isNotEmpty ?? false)
|
|
? imageFront
|
|
: (imageFull?.isNotEmpty ?? false)
|
|
? imageFull
|
|
: null,
|
|
lastUpdated: DateTime.now(),
|
|
);
|
|
}
|
|
|
|
/// Reads [nutrient] for the given size, preferring per-serving values and
|
|
/// scaling per-100 g fallbacks onto the resolved serving size.
|
|
// ignore: deprecated_member_use
|
|
double? _nutrient(Product product, Nutrient nutrient, bool perServing) {
|
|
// `Product.nutriments` and the `Nutriments` class are deprecated in
|
|
// favor of the API v3.5 `nutrition` map, but the v3 map is not populated
|
|
// by the search endpoint yet; the legacy accessor remains the reliable
|
|
// read path.
|
|
// ignore: deprecated_member_use
|
|
final nutriments = product.nutriments;
|
|
if (nutriments == null) return null;
|
|
if (perServing) {
|
|
// ignore: deprecated_member_use
|
|
final serving = nutriments.getValue(nutrient, PerSize.serving);
|
|
if (serving != null) return serving;
|
|
}
|
|
// ignore: deprecated_member_use
|
|
final perHundred = nutriments.getValue(nutrient, PerSize.oneHundredGrams);
|
|
if (perHundred == null) return null;
|
|
return perServing ? _scaled(perHundred, product) : perHundred;
|
|
}
|
|
|
|
/// Scales a per-100 g value onto the product's serving size.
|
|
double _scaled(double perHundred, Product product) {
|
|
final grams = _resolveServingGrams(product);
|
|
if (grams == null || grams <= 0) return perHundred;
|
|
return perHundred * grams / 100;
|
|
}
|
|
|
|
/// Sodium is stored in mg; OFF reports grams.
|
|
double? _sodiumMg(Product product, bool perServing) {
|
|
final grams = _nutrient(product, Nutrient.sodium, perServing);
|
|
return grams == null ? null : grams * 1000;
|
|
}
|
|
|
|
/// Resolves the serving weight in grams from `servingQuantity` or by
|
|
/// parsing the human-readable `servingSize` string (e.g. "30 g").
|
|
double? _resolveServingGrams(Product product) {
|
|
final quantity = product.servingQuantity;
|
|
if (quantity != null && quantity > 0) return quantity;
|
|
return _parseGrams(product.servingSize);
|
|
}
|
|
|
|
/// Parses a leading number + mass unit from strings like "30 g" / "0.5 kg".
|
|
static double? _parseGrams(String? servingSize) {
|
|
if (servingSize == null) return null;
|
|
final match = RegExp(
|
|
r'(\d+(?:[.,]\d+)?)\s*(g|gram|grams|kg|kilogram|kilograms|oz|ounces?)',
|
|
caseSensitive: false,
|
|
).firstMatch(servingSize);
|
|
if (match == null) return null;
|
|
final value = double.tryParse(match.group(1)!.replaceAll(',', '.'));
|
|
if (value == null) return null;
|
|
switch (match.group(2)!.toLowerCase()) {
|
|
case 'kg':
|
|
case 'kilogram':
|
|
case 'kilograms':
|
|
return value * 1000;
|
|
case 'oz':
|
|
case 'ounce':
|
|
case 'ounces':
|
|
return value * 28.3495;
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/// Extracts the canonical pantry unit from a serving-size string.
|
|
static String? _unitFromServingSize(String? servingSize) {
|
|
if (servingSize == null) return null;
|
|
final lower = servingSize.toLowerCase();
|
|
if (RegExp(r'\bml\b|milliliter|millilitre').hasMatch(lower)) return 'ml';
|
|
if (RegExp(r'\bl\b|liter|litre').hasMatch(lower)) return 'l';
|
|
if (RegExp(r'\bg\b|gram|kg').hasMatch(lower)) return 'g';
|
|
if (RegExp(r'\boz\b|ounce').hasMatch(lower)) return 'oz';
|
|
return null;
|
|
}
|
|
|
|
static String? _firstNonEmpty(String? a, String? b) {
|
|
if (a != null && a.trim().isNotEmpty) return a.trim();
|
|
if (b != null && b.trim().isNotEmpty) return b.trim();
|
|
return null;
|
|
}
|
|
}
|