glp-companion/lib/features/nutrition/data/datasources/usda_fdc_client.dart

213 lines
6.7 KiB
Dart

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<List<FoodProduct>> searchByText(String query) =>
search(query, isBarcode: false);
@override
Future<List<FoodProduct>> 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<List<FoodProduct>> 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: <String, String>{'api_key': apiKey},
);
final body = <String, dynamic>{
'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<String, dynamic>) return const [];
final foods = decoded['foods'];
if (foods is! List) return const [];
final now = DateTime.now();
final results = <FoodProduct>[];
for (final food in foods) {
if (food is! Map<String, dynamic>) 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<String, dynamic> 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 = <int, double>{};
final rawNutrients = food['foodNutrients'];
if (rawNutrients is List) {
for (final entry in rawNutrients) {
if (entry is! Map<String, dynamic>) 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)';
}