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 _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> 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> 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 []; return products .map(_mapProduct) .whereType() .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; } }