import 'dart:convert'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import '../../domain/entities/food_product.dart'; import 'remote_food_data_source.dart'; /// Thin client for the USDA FoodData Central "foods/search" endpoint. /// /// Acts as the automatic fallback when the local cache and Open Food Facts /// cannot resolve a query. Nutrient values are mapped from the FDC nutrient /// IDs used by both Foundation and Branded food records: /// /// * 1008 — Energy (kcal) /// * 1003 — Protein /// * 1004 — Total lipid (fat) /// * 1005 — Carbohydrates, by difference /// * 1079 — Fiber, dietary /// * 1093 — Sodium /// /// The API key is read from `.env` (`USDA_FDC_API_KEY`) with a fallback to /// the public `DEMO_KEY` so development builds work out of the box. class UsdaFdcClient implements RemoteFoodDataSource { UsdaFdcClient({http.Client? client, String? apiKey}) : _client = client ?? http.Client(), _apiKey = apiKey; static const String endpoint = 'https://api.nal.usda.gov/fdc/v1/foods/search'; /// Fallback key for light development traffic. static const String demoApiKey = 'DEMO_KEY'; /// Nutrient IDs recognized by the mapper (see class docs). static const int nutrientIdEnergyKcal = 1008; static const int nutrientIdProtein = 1003; static const int nutrientIdFat = 1004; static const int nutrientIdCarbs = 1005; static const int nutrientIdFiber = 1079; static const int nutrientIdSodium = 1093; final http.Client _client; final String? _apiKey; /// Effective API key: constructor override > `.env` > [demoApiKey]. String get apiKey { final fromEnv = _apiKey ?? (dotenv.isInitialized ? dotenv.env['USDA_FDC_API_KEY'] : null); if (fromEnv == null || fromEnv.trim().isEmpty) return demoApiKey; return fromEnv.trim(); } @override Future> searchByText(String query) => search(query, isBarcode: false); @override Future> lookupBarcode(String barcode) => search(barcode, isBarcode: true, pageSize: 5); /// Searches FDC for [query] and maps up to [pageSize] results. /// /// [isBarcode] switches the query into a GTIN/UPC lookup for branded foods. Future> search( String query, { bool isBarcode = false, int pageSize = 25, }) async { final trimmed = query.trim(); if (trimmed.isEmpty) return const []; // The FDC API validates the key from the QUERY STRING only; an `apiKey` // field inside the JSON body is silently ignored and yields // HTTP 403 `API_KEY_MISSING`. final uri = Uri.parse(endpoint).replace( queryParameters: {'api_key': apiKey}, ); final body = { 'query': trimmed, 'pageSize': pageSize, if (isBarcode) 'dataType': ['Branded'], }; final response = await _client .post( uri, headers: const { 'Content-Type': 'application/json', 'User-Agent': 'GLP-1 Companion/0.1.0 (nutrition lookup)', }, body: jsonEncode(body), ) .timeout(const Duration(seconds: 8)); if (response.statusCode != 200) { throw UsdaFdcException( 'USDA FoodData Central responded with HTTP ${response.statusCode}.', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) return const []; final foods = decoded['foods']; if (foods is! List) return const []; final now = DateTime.now(); final results = []; for (final food in foods) { if (food is! Map) continue; final product = _mapFood( food, now: now, fallbackBarcode: isBarcode ? trimmed : null, ); if (product != null) results.add(product); } return results; } /// Maps a single FDC food payload to a [FoodProduct]; returns `null` when /// the payload has no usable identity or energy value. FoodProduct? _mapFood( Map food, { required DateTime now, String? fallbackBarcode, }) { final fdcId = food['fdcId']; final description = food['description'] as String? ?? ''; if (fdcId == null || description.isEmpty) return null; final nutrients = {}; final rawNutrients = food['foodNutrients']; if (rawNutrients is List) { for (final entry in rawNutrients) { if (entry is! Map) continue; final id = entry['nutrientId']; final value = entry['value']; if (id is int && value is num) nutrients[id] = value.toDouble(); } } // Energy is the gate: records without it cannot be logged meaningfully. final calories = nutrients[nutrientIdEnergyKcal]; if (calories == null) return null; final barcode = (food['gtinUpc'] as String?)?.trim(); final brand = (food['brandName'] as String?)?.trim(); final servingSize = (food['servingSize'] as num?)?.toDouble() ?? 100; final servingUnit = (food['servingSizeUnit'] as String?)?.trim().toLowerCase(); return FoodProduct( id: 'fdc:$fdcId', barcode: (barcode == null || barcode.isEmpty) ? fallbackBarcode : barcode, productName: description, brand: (brand == null || brand.isEmpty) ? null : brand, servingSizeGrams: servingSize, servingSizeUnit: _canonicalUnit(servingUnit), calories: calories, proteinGrams: nutrients[nutrientIdProtein] ?? 0, carbsGrams: nutrients[nutrientIdCarbs] ?? 0, fatGrams: nutrients[nutrientIdFat] ?? 0, fiberGrams: nutrients[nutrientIdFiber], sodiumMg: nutrients[nutrientIdSodium], imageUrl: null, lastUpdated: now, ); } /// Normalizes the FDC serving unit (`"g"`, `"ml"`, `"count"` …) into the /// pantry-canonical unit keys. A missing unit defaults to grams: FDC /// nutrition values sit on a per-100 g basis when no serving is declared. String _canonicalUnit(String? unit) { switch (unit) { case 'g': case 'gram': case 'grams': case null: case '': return 'g'; case 'ml': case 'milliliter': case 'milliliters': return 'ml'; case 'kg': return 'kg'; case 'oz': return 'oz'; case 'count': case 'each': return 'count'; default: return unit; } } /// Releases the underlying HTTP client. void close() => _client.close(); } /// Non-fatal failure surfaced by [UsdaFdcClient]; the search repository /// catches it and degrades to an empty result set. class UsdaFdcException implements Exception { const UsdaFdcException(this.message); final String message; @override String toString() => 'UsdaFdcException($message)'; }