213 lines
6.7 KiB
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)';
|
|
}
|