glp-companion/lib/features/nutrition/data/datasources/open_food_facts_client.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;
}
}