feat(nutrition,pantry): add Phase 3C nutritional search engine, barcode scanning, and dual-source food cache

Implements the architecture 4.8 resolution ladder: local SQLCipher cache ->
Open Food Facts (4s timeout) -> USDA FoodData Central fallback, with every
remote hit written through to the new FoodProductsTable (schema v5).

- domain: FoodProduct entity + IFoodSearchRepository contract (pure Dart)
- data: UsdaFdcClient (FDC nutrient-ID mapper 1008/1003/1004/1005/1079/1093,
  api_key sent as query parameter - FDC ignores body keys and 403s otherwise),
  OpenFoodFactsClient (userAgent with app identity, SearchTerms search,
  getProductV3 barcode lookup), shared RemoteFoodDataSource interface
- presentation: FoodSearchController (400ms debounce, request-token
  supersession), FoodSearchScreen (MobileScanner sheet on mobile, manual UPC
  dialog + notification on desktop/web, macro action sheet with serving
  multipliers, Log to Diary / Add to Pantry actions)
- integration: /food-search route with ?preset=pantry, Nutrition screen entry
  button, Pantry screen Scan Item action
- hardening: FoodSearchUnavailableException distinguishes total source outage
  from a healthy empty result; GoogleHealthConnector dotenv reads now guarded
  with isInitialized (fixes NotInitializedError crashing app_navigation_test)
- tests: +16 (fallback sequence cache->OFF->USDA for text and barcode,
  USDA request shape via MockClient); full suite 170/170, analyze clean
This commit is contained in:
Mike Kell 2026-10-17 19:37:06 -04:00
parent 17b81f567d
commit e43603be1b
20 changed files with 3487 additions and 6 deletions

View File

@ -3,13 +3,14 @@
## Active Target ## Active Target
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE - **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done) - **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
- **Latest Addendum:** Phase 3B retrofit — Platform Health Integrations & HealthGateway end-to-end (2026-10-17) - **Latest Addendum:** Phase 3C retrofit — Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache (2026-10-17)
## Phase Checklist ## Phase Checklist
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling - [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync - [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
- [x] Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements) - [x] Phase 3B: Platform Health Integrations & HealthGateway (architecture §4.7 contract, Settings control surface, native entitlements)
- [x] Phase 3C: Nutritional Search Engine, Barcode Scanning & Dual-Source Food Cache (architecture §4.8 resolution ladder)
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar - [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator - [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD - [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
@ -102,6 +103,30 @@
- [x] `flutter test` (148/148 passing) - [x] `flutter test` (148/148 passing)
- [x] `flutter build windows --debug` (`✓ Built build\windows\x64\runner\Debug\glpcompanion.exe`; the Settings screen's desktop-safe disabled states are asserted headlessly by `settings_screen_test.dart` — interactive `flutter run -d windows` compiles the same toolchain) - [x] `flutter build windows --debug` (`✓ Built build\windows\x64\runner\Debug\glpcompanion.exe`; the Settings screen's desktop-safe disabled states are asserted headlessly by `settings_screen_test.dart` — interactive `flutter run -d windows` compiles the same toolchain)
## Phase 3C Deliverables (2026-10-17, architecture §4.8 retrofit)
- [x] Dependencies: `openfoodfacts ^3.7.0` (resolves 3.31.0) + `mobile_scanner ^5.2.3` added to `pubspec.yaml` (`http ^1.2.1` already present); `.env` gains `USDA_FDC_API_KEY=DEMO_KEY` (read with an `isInitialized` guard + DEMO_KEY fallback in `UsdaFdcClient.apiKey`).
- [x] Domain: `FoodProduct` entity (id, barcode, productName, brand, servingSizeGrams/Unit, calories, protein/carbs/fat/fiber/sodium, imageUrl, lastUpdated) and `IFoodSearchRepository` contract (searchByText / lookupBarcode / cacheProduct) — pure Dart.
- [x] Data (persistence): `FoodProductsTable` added to `AppDatabase` (schema v5 + `from < 5` migration; text PK `id`, nullable barcode/brand/imageUrl, real macro columns, datetime `lastUpdated`) with barcode/substring search helpers (LIKE-wildcard sanitized) and `upsertFoodProduct` (`insertOnConflictUpdate`).
- [x] Data (sources): `UsdaFdcClient` (POST `/fdc/v1/foods/search`, nutrient-ID mapper 1008/1003/1004/1005/1079/1093, branded GTIN/UPC mode for barcode lookups, injectable `http.Client`) and `OpenFoodFactsClient` (`OpenFoodAPIConfiguration.userAgent` with app identity + contact, `SearchTerms` text search, `getProductV3` barcode lookup, per-serving nutrient reads with per-100 g scaling) — both implement the shared `RemoteFoodDataSource` interface for testability.
- [x] Data (repository): `FoodSearchRepository` implements the §4.8 ladder — L1 local drift cache (substring/barcode) → L2 Open Food Facts (4-second timeout) → L3 USDA fallback → write-through cache via `insertOnConflictUpdate`; source failures degrade non-fatally (`debugPrintStack`).
- [x] Presentation: `FoodSearchController` (StateNotifier; 400 ms debounced text queries, immediate barcode lookups, request-token supersession, loading/error/empty states) and `FoodSearchScreen` (debounced search bar with clear button; AppBar scan button — `MobileScanner` modal sheet on iOS/Android with UPC-A/EAN-8/13 filters and permission-failure messaging, manual UPC dialog + desktop/web notification otherwise; product tiles with thumbnail/name/brand/calories/protein badge; action bottom sheet with full macros, 0.5×/1×/1.5×/2× serving multiplier, "Log to Diary" → `FoodLogsTable` via `NutritionController.logFood`, "Add to Pantry" → `PantryItemsTable` via `PantryInventoryController.addItem`).
- [x] Integration: `/food-search` route (top-level, above the shell) with `?preset=pantry`; Nutrition screen gains a "Search Food / Scan Barcode" entry button; Pantry screen inventory tab gains an AppBar "Scan item barcode" action routing to the pantry preset (primary action flips to "Add to Pantry").
- [x] Tests: +10 in `test/features/nutrition/food_search_repository_test.dart` — cache hit short-circuit, OFF-first + write-through cache, USDA fallback on OFF failure/empty, dual-miss empty results, second-lookup cache hit, blank-query short-circuit, and the same hierarchy for barcode lookups (cached barcode, OFF barcode hit, USDA branded-GTIN fallback, total-failure degradation).
- [x] Regression fix (pre-existing, gate-unblocking): `GoogleHealthConnector` dotenv reads now go through an `isInitialized`-guarded `_env()` helper — `app_navigation_test.dart` previously crashed with `NotInitializedError` whenever `checkStatus` ran in a test/production shell that never called `dotenv.load`.
## Phase 3C Verification Gates (2026-10-17)
- [x] `flutter pub get` (openfoodfacts 3.31.0 + mobile_scanner 5.2.3 resolved)
- [x] `dart run build_runner build --delete-conflicting-outputs` (206 outputs; `FoodProductsTable` codegen verified in `app_database.g.dart`)
- [x] `flutter analyze` (No issues found!)
- [x] `flutter test` (164/164 passing, incl. 10 new fallback-sequence tests)
- [x] Field fix (2026-10-17, first live run): "food search returns nothing" — two server-side contract violations, both diagnosed with raw HTTP probes:
- **USDA 403 `API_KEY_MISSING`**: the FDC API validates the key from the **query string only** (`?api_key=…`); the JSON-body `apiKey` field is silently ignored. `UsdaFdcClient` now builds the URI with `queryParameters: {'api_key': apiKey}` and drops the body field (plus sends an identifying `User-Agent`); missing-unit records now default to `g` (FDC's per-100 g basis) instead of `count`.
- **Open Food Facts 503**: OFF blocks all anonymous **search** endpoints server-side (both `cgi/search.pl` and `api/v2/search` 503 even from raw HTTP with a proper User-Agent) while product/barcode lookups return 200 — the USDA fallback is the designed path for text search and now carries it. Barcode scans still prefer OFF.
- UX hardening: the repository now throws `FoodSearchUnavailableException` when the cache misses AND every remote source *errored* (vs. answered "no matches"), so the screen shows "Search failed — check your connection" instead of a false "No products found".
- Regression tests: `test/features/nutrition/usda_fdc_client_test.dart` (+5, MockClient) locks the query-param key shape, DEMO_KEY fallback, nutrient-ID mapping (mg sodium passthrough), Branded-only barcode mode, and non-200 → `UsdaFdcException`; the total-failure repository test now asserts the throw. Full suite: 170/170.
- [ ] `flutter build apk --debug` / `flutter build ios --no-codesign` — needs a machine with the mobile toolchains (also exercises the Android/iOS camera-permission additions required by `mobile_scanner`)
- [ ] Live dual-source round trip on a device: scan a known UPC → OFF hit populates `FoodProductsTable` → airplane-mode relaunch → same barcode resolves from the SQLCipher cache; delete the row + query a generic food (e.g. "rolled oats") → USDA fallback populates the cache.
## Active Verification Gates (Phase 7, 2026-10-16) ## Active Verification Gates (Phase 7, 2026-10-16)
- [x] `dart analyze lib test` (Zero issues) - [x] `dart analyze lib test` (Zero issues)
- [x] `flutter test` (all passing — 125/125) - [x] `flutter test` (all passing — 125/125)

View File

@ -10,6 +10,7 @@ import '../../features/auth/presentation/screens/login_screen.dart';
import '../../features/exercise/presentation/screens/exercise_screen.dart'; import '../../features/exercise/presentation/screens/exercise_screen.dart';
import '../../features/medication/presentation/screens/medication_screen.dart'; import '../../features/medication/presentation/screens/medication_screen.dart';
import '../../features/nutrition/presentation/screens/nutrition_screen.dart'; import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
import '../../features/nutrition/presentation/screens/food_search_screen.dart';
import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart'; import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart';
import '../../features/pantry/presentation/screens/pantry_screen.dart'; import '../../features/pantry/presentation/screens/pantry_screen.dart';
import '../../features/recipes/presentation/screens/recipes_screen.dart'; import '../../features/recipes/presentation/screens/recipes_screen.dart';
@ -71,6 +72,15 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/login', path: '/login',
builder: (context, state) => const LoginScreen(), builder: (context, state) => const LoginScreen(),
), ),
// Full-screen food search (pushed above the shell so it covers the
// navigation rail). `?preset=pantry` reorders the product action
// sheet to emphasize adding to pantry stock.
GoRoute(
path: '/food-search',
builder: (context, state) => FoodSearchScreen(
preset: foodSearchPresetFrom(state.uri.queryParameters['preset']),
),
),
ShellRoute( ShellRoute(
navigatorKey: _shellNavigatorKey, navigatorKey: _shellNavigatorKey,
builder: (context, state, child) { builder: (context, state, child) {

View File

@ -242,6 +242,51 @@ class ShoppingListItemsTable extends Table {
Set<Column>? get primaryKey => {id}; Set<Column>? get primaryKey => {id};
} }
/// Cached food product resolved by the nutritional search engine (Open
/// Food Facts / USDA FoodData Central dual-source lookup).
///
/// [id] is the stable source identifier (barcode or `fdc:<id>`); the row
/// doubles as the offline cache consulted before any remote lookup.
@DataClassName('FoodProductRecord')
class FoodProductsTable extends Table {
/// Stable primary key: the source barcode when known, otherwise a
/// source-prefixed identifier (e.g. `fdc:12345`).
TextColumn get id => text()();
/// UPC/EAN barcode; `null` when the source did not provide one.
TextColumn get barcode => text().nullable()();
TextColumn get productName => text().withLength(min: 1, max: 512)();
TextColumn get brand => text().nullable()();
/// Gram weight (or count) of one serving.
RealColumn get servingSizeGrams => real()();
/// Canonical unit key for [servingSizeGrams] (e.g. `g`, `ml`, `count`).
TextColumn get servingSizeUnit => text().withLength(min: 1, max: 16)();
/// Energy per serving, kilocalories.
RealColumn get calories => real()();
RealColumn get proteinGrams => real()();
RealColumn get carbsGrams => real()();
RealColumn get fatGrams => real()();
RealColumn get fiberGrams => real().nullable()();
RealColumn get sodiumMg => real().nullable()();
TextColumn get imageUrl => text().nullable()();
DateTimeColumn get lastUpdated => dateTime()();
@override
Set<Column>? get primaryKey => {id};
}
/// Root drift database for the GLP-1 Companion. /// Root drift database for the GLP-1 Companion.
/// ///
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES /// On native platforms the connection is backed by SQLCipher: the 256-bit AES
@ -259,6 +304,7 @@ class ShoppingListItemsTable extends Table {
MealPlanSlotsTable, MealPlanSlotsTable,
PantryItemsTable, PantryItemsTable,
ShoppingListItemsTable, ShoppingListItemsTable,
FoodProductsTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
@ -269,7 +315,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor); AppDatabase.forTesting(super.executor);
@override @override
int get schemaVersion => 4; int get schemaVersion => 5;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -293,6 +339,10 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(pantryItemsTable); await m.createTable(pantryItemsTable);
await m.createTable(shoppingListItemsTable); await m.createTable(shoppingListItemsTable);
} }
// v5 added the dual-source (Open Food Facts / USDA) food cache.
if (from < 5) {
await m.createTable(foodProductsTable);
}
}, },
); );
@ -727,6 +777,50 @@ class AppDatabase extends _$AppDatabase {
(t) => OrderingTerm.asc(t.name), (t) => OrderingTerm.asc(t.name),
(t) => OrderingTerm.asc(t.id), (t) => OrderingTerm.asc(t.id),
]); ]);
// ---------------------------------------------------------------------------
// Food product cache CRUD (nutritional search engine)
// ---------------------------------------------------------------------------
//
// The dual-source (Open Food Facts / USDA) search repository reads these
// rows before going to the network and writes every remote hit back through
// [upsertFoodProduct] so repeated lookups work offline.
/// The cached product row with [id], or `null` when absent.
Future<FoodProductRecord?> getFoodProductById(String id) =>
(select(foodProductsTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
/// The cached product row carrying [barcode], or `null` when absent.
Future<FoodProductRecord?> getFoodProductByBarcode(String barcode) =>
(select(foodProductsTable)..where((t) => t.barcode.equals(barcode)))
.getSingleOrNull();
/// Case-insensitive substring matches on product name or brand, most
/// recently updated first. Wildcard characters in [query] are stripped so
/// user input cannot inject `LIKE` patterns (SQLite's `LIKE` is already
/// case-insensitive for ASCII text).
Future<List<FoodProductRecord>> searchFoodProducts(
String query, {
int limit = 25,
}) {
final sanitized = query.replaceAll(RegExp(r'[%_\\]'), ' ').trim();
if (sanitized.isEmpty) return Future.value(const []);
final pattern = '%$sanitized%';
return (select(foodProductsTable)
..where(
(t) => t.productName.like(pattern) | t.brand.like(pattern),
)
..orderBy([
(t) => OrderingTerm.desc(t.lastUpdated),
])
..limit(limit))
.get();
}
/// Inserts the product row or refreshes the existing row with the same id.
Future<void> upsertFoodProduct(FoodProductsTableCompanion entry) =>
into(foodProductsTable).insertOnConflictUpdate(entry);
} }
/// Application-wide database instance; closed when the container is disposed. /// Application-wide database instance; closed when the container is disposed.

File diff suppressed because it is too large Load Diff

View File

@ -96,11 +96,17 @@ class GoogleHealthConnector implements ICloudHealthConnector {
final http.Client _client; final http.Client _client;
final FlutterSecureStorage _storage; final FlutterSecureStorage _storage;
String? get _clientId => dotenv.env['GOOGLE_HEALTH_CLIENT_ID']; /// Reads [.env] without throwing when the asset was never loaded (unit
String? get _clientSecret => dotenv.env['GOOGLE_HEALTH_CLIENT_SECRET']; /// tests and debug shells skip `dotenv.load`); the connector then reports
/// itself as unconfigured instead of crashing status probes.
static String? _env(String key) =>
dotenv.isInitialized ? dotenv.env[key] : null;
String? get _clientId => _env('GOOGLE_HEALTH_CLIENT_ID');
String? get _clientSecret => _env('GOOGLE_HEALTH_CLIENT_SECRET');
int get _redirectPort => int.tryParse( int get _redirectPort => int.tryParse(
dotenv.env['GOOGLE_HEALTH_REDIRECT_PORT'] ?? '', _env('GOOGLE_HEALTH_REDIRECT_PORT') ?? '',
) ?? ) ??
52789; 52789;

View File

@ -0,0 +1,228 @@
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;
}
}

View File

@ -0,0 +1,13 @@
import '../../domain/entities/food_product.dart';
/// A remote product source consulted by the food search repository after the
/// local cache misses. Both the Open Food Facts client and the USDA
/// FoodData Central client implement it; unit tests inject fakes to verify
/// the cache -> OFF -> USDA fallback sequence.
abstract class RemoteFoodDataSource {
/// Resolves a free-text query to zero or more products.
Future<List<FoodProduct>> searchByText(String query);
/// Resolves a UPC/EAN barcode to zero or one product.
Future<List<FoodProduct>> lookupBarcode(String barcode);
}

View File

@ -0,0 +1,212 @@
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)';
}

View File

@ -0,0 +1,216 @@
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../../../../core/database/app_database.dart';
import '../../domain/entities/food_product.dart';
import '../../domain/repositories/food_search_repository.dart';
import '../datasources/open_food_facts_client.dart';
import '../datasources/remote_food_data_source.dart';
import '../datasources/usda_fdc_client.dart';
export '../datasources/remote_food_data_source.dart';
/// Thrown when the cache missed AND every remote source errored (network
/// down, API outage, rate limiting). Distinct from an empty result set —
/// which means a healthy source answered "no matches" — so the UI can show
/// a connection error instead of a false "No products found".
class FoodSearchUnavailableException implements Exception {
const FoodSearchUnavailableException(this.query);
final String query;
@override
String toString() =>
'FoodSearchUnavailableException(no source could answer "$query")';
}
/// Dual-source food search with a local drift cache.
///
/// Resolution hierarchy (see [IFoodSearchRepository]):
/// 1. Local `FoodProductsTable` substring/barcode match (offline-fast).
/// 2. Open Food Facts API, bounded by a 4-second timeout.
/// 3. Automatic fallback to the USDA FoodData Central API.
///
/// Every remotely retrieved product is written back into the cache through
/// `insertOnConflictUpdate`, so subsequent searches and barcode scans serve
/// from disk without touching the network.
class FoodSearchRepository implements IFoodSearchRepository {
FoodSearchRepository(
this._database, {
required RemoteFoodDataSource openFoodFacts,
required RemoteFoodDataSource usdaFdc,
this.remoteTimeout = const Duration(seconds: 4),
}) : _openFoodFacts = openFoodFacts,
_usdaFdc = usdaFdc;
/// The default Open Food Facts → USDA chain used by the application.
factory FoodSearchRepository.defaults(AppDatabase database) =>
FoodSearchRepository(
database,
openFoodFacts: OpenFoodFactsClient(),
usdaFdc: UsdaFdcClient(),
);
final AppDatabase _database;
final RemoteFoodDataSource _openFoodFacts;
final RemoteFoodDataSource _usdaFdc;
/// Upper bound for a single Open Food Facts round trip before the USDA
/// fallback takes over (keeps the search UI responsive).
final Duration remoteTimeout;
@override
Future<List<FoodProduct>> searchByText(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
// 1. Local cache.
final cached = await _database.searchFoodProducts(trimmed);
if (cached.isNotEmpty) {
return cached.map(_mapRecord).toList(growable: false);
}
// 2. Open Food Facts (bounded).
var anySourceErrored = false;
List<FoodProduct>? remote;
try {
final hits =
await _openFoodFacts.searchByText(trimmed).timeout(remoteTimeout);
if (hits.isNotEmpty) remote = hits;
} on Exception catch (error, stackTrace) {
anySourceErrored = true;
_report('Open Food Facts text search failed', error, stackTrace);
}
// 3. USDA FoodData Central fallback.
if (remote == null) {
try {
final hits = await _usdaFdc.searchByText(trimmed);
if (hits.isNotEmpty) remote = hits;
} on Exception catch (error, stackTrace) {
anySourceErrored = true;
_report('USDA FoodData Central text search failed', error, stackTrace);
}
}
// Every source errored (vs. answered "no matches") -> surface a
// connection error instead of an empty list.
if (remote == null && anySourceErrored) {
throw FoodSearchUnavailableException(trimmed);
}
final results = remote ?? const <FoodProduct>[];
await _cacheAll(results);
return results;
}
@override
Future<List<FoodProduct>> lookupBarcode(String barcode) async {
final trimmed = barcode.trim();
if (trimmed.isEmpty) return const [];
// 1. Local cache.
final cached = await _database.getFoodProductByBarcode(trimmed);
if (cached != null) return [_mapRecord(cached)];
// 2. Open Food Facts (bounded) — the strongest barcode database.
var anySourceErrored = false;
List<FoodProduct>? remote;
try {
final hits = await _openFoodFacts
.lookupBarcode(trimmed)
.timeout(remoteTimeout);
if (hits.isNotEmpty) remote = hits;
} on Exception catch (error, stackTrace) {
anySourceErrored = true;
_report('Open Food Facts barcode lookup failed', error, stackTrace);
}
// 3. USDA FoodData Central fallback (branded GTIN/UPC search).
if (remote == null) {
try {
final hits = await _usdaFdc.lookupBarcode(trimmed);
if (hits.isNotEmpty) remote = hits;
} on Exception catch (error, stackTrace) {
anySourceErrored = true;
_report(
'USDA FoodData Central barcode lookup failed',
error,
stackTrace,
);
}
}
if (remote == null && anySourceErrored) {
throw FoodSearchUnavailableException(trimmed);
}
final results = remote ?? const <FoodProduct>[];
await _cacheAll(results);
return results;
}
@override
Future<void> cacheProduct(FoodProduct product) =>
_database.upsertFoodProduct(_toCompanion(product));
/// Writes every product into the offline cache (`insertOnConflictUpdate`),
/// refreshing previously stored rows with the latest remote values.
Future<void> _cacheAll(List<FoodProduct> products) async {
for (final product in products) {
await _database.upsertFoodProduct(_toCompanion(product));
}
}
/// Converts a persistence record into the domain entity.
FoodProduct _mapRecord(FoodProductRecord record) => FoodProduct(
id: record.id,
barcode: record.barcode,
productName: record.productName,
brand: record.brand,
servingSizeGrams: record.servingSizeGrams,
servingSizeUnit: record.servingSizeUnit,
calories: record.calories,
proteinGrams: record.proteinGrams,
carbsGrams: record.carbsGrams,
fatGrams: record.fatGrams,
fiberGrams: record.fiberGrams,
sodiumMg: record.sodiumMg,
imageUrl: record.imageUrl,
lastUpdated: record.lastUpdated,
);
/// Converts a domain entity into an upsert companion.
FoodProductsTableCompanion _toCompanion(FoodProduct product) =>
FoodProductsTableCompanion(
id: Value(product.id),
barcode: product.barcode == null
? const Value.absent()
: Value(product.barcode),
productName: Value(product.productName),
brand: product.brand == null
? const Value.absent()
: Value(product.brand),
servingSizeGrams: Value(product.servingSizeGrams),
servingSizeUnit: Value(product.servingSizeUnit),
calories: Value(product.calories),
proteinGrams: Value(product.proteinGrams),
carbsGrams: Value(product.carbsGrams),
fatGrams: Value(product.fatGrams),
fiberGrams: product.fiberGrams == null
? const Value.absent()
: Value(product.fiberGrams),
sodiumMg: product.sodiumMg == null
? const Value.absent()
: Value(product.sodiumMg),
imageUrl: product.imageUrl == null
? const Value.absent()
: Value(product.imageUrl),
lastUpdated: Value(product.lastUpdated),
);
/// Non-fatal: each source failure degrades the search to the next source.
void _report(String label, Object error, StackTrace stackTrace) {
debugPrintStack(stackTrace: stackTrace, label: '$label: $error');
}
}

View File

@ -0,0 +1,112 @@
/// Immutable nutritional product record resolved from the local cache,
/// Open Food Facts, or the USDA FoodData Central fallback.
///
/// Pure Dart (no framework imports) so the entity can be shared by the food
/// search repository, the drift-backed cache mapper, and the Riverpod
/// presentation layer without leaking infrastructure concerns into the
/// domain.
class FoodProduct {
const FoodProduct({
required this.id,
required this.productName,
required this.servingSizeGrams,
required this.servingSizeUnit,
required this.calories,
required this.proteinGrams,
required this.carbsGrams,
required this.fatGrams,
required this.lastUpdated,
this.barcode,
this.brand,
this.fiberGrams,
this.sodiumMg,
this.imageUrl,
});
/// Stable primary key: the source barcode when known, otherwise a
/// source-prefixed identifier (e.g. `fdc:12345` for USDA records).
final String id;
/// UPC/EAN barcode when the product carries one; `null` for USDA-only
/// foundation/branded records without a resolvable GTIN.
final String? barcode;
/// Display name of the product (e.g. "Greek Yogurt, Plain").
final String productName;
/// Manufacturer or distributor brand (e.g. "Fage"); `null` when unknown.
final String? brand;
/// Gram weight of one serving; anchors the serving-size multiplier math in
/// the action sheet.
final double servingSizeGrams;
/// Unit label persisted alongside [servingSizeGrams] (e.g. `g`, `ml`,
/// `count`) — kept for display and pantry stock mapping.
final String servingSizeUnit;
/// Energy content in kilocalories per serving.
final double calories;
/// Protein content in grams per serving; the primary lean-mass metric.
final double proteinGrams;
/// Carbohydrate content in grams per serving.
final double carbsGrams;
/// Fat content in grams per serving.
final double fatGrams;
/// Dietary fiber in grams per serving; `null` when the source omits it.
final double? fiberGrams;
/// Sodium in milligrams per serving; `null` when the source omits it.
final double? sodiumMg;
/// Thumbnail/front-image URL; `null` when no product photo is available.
final String? imageUrl;
/// When the record was resolved from its remote source (drives cache
/// freshness checks).
final DateTime lastUpdated;
/// Whether the product is identified by a scannable UPC/EAN barcode.
bool get hasBarcode => barcode != null && barcode!.isNotEmpty;
FoodProduct copyWith({
String? id,
String? barcode,
String? productName,
String? brand,
double? servingSizeGrams,
String? servingSizeUnit,
double? calories,
double? proteinGrams,
double? carbsGrams,
double? fatGrams,
double? fiberGrams,
double? sodiumMg,
String? imageUrl,
DateTime? lastUpdated,
}) =>
FoodProduct(
id: id ?? this.id,
barcode: barcode ?? this.barcode,
productName: productName ?? this.productName,
brand: brand ?? this.brand,
servingSizeGrams: servingSizeGrams ?? this.servingSizeGrams,
servingSizeUnit: servingSizeUnit ?? this.servingSizeUnit,
calories: calories ?? this.calories,
proteinGrams: proteinGrams ?? this.proteinGrams,
carbsGrams: carbsGrams ?? this.carbsGrams,
fatGrams: fatGrams ?? this.fatGrams,
fiberGrams: fiberGrams ?? this.fiberGrams,
sodiumMg: sodiumMg ?? this.sodiumMg,
imageUrl: imageUrl ?? this.imageUrl,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
@override
String toString() =>
'FoodProduct($id, "$productName" ${brand ?? ''}, $calories kcal)';
}

View File

@ -0,0 +1,24 @@
import '../../domain/entities/food_product.dart';
/// Persistence contract for the nutritional search engine: text and barcode
/// lookups resolved against the dual-source cache (Open Food Facts + USDA
/// FoodData Central).
///
/// The presentation layer depends only on this interface; the implementation
/// lives in `data/repositories/` so controllers and widgets remain testable
/// with in-memory fakes.
abstract class IFoodSearchRepository {
/// Resolves [query] as a free-text product search (e.g. "greek yogurt"),
/// consulting the local cache first, then Open Food Facts, and finally
/// falling back to the USDA FoodData Central API. All remote results are
/// written back into the local cache.
Future<List<FoodProduct>> searchByText(String query);
/// Resolves [barcode] (UPC/EAN) to a single product using the same
/// cache -> Open Food Facts -> USDA hierarchy. Returns an empty list when
/// no source knows the barcode.
Future<List<FoodProduct>> lookupBarcode(String barcode);
/// Re-caches [product] after external mutation (e.g. pantry ingestion).
Future<void> cacheProduct(FoodProduct product);
}

View File

@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/database/app_database.dart';
import '../../data/repositories/food_search_repository.dart';
import '../../domain/entities/food_product.dart';
import '../../domain/repositories/food_search_repository.dart';
/// Application-wide food search repository (dual-source + drift cache).
///
/// Overridable in widget/unit tests with an in-memory fake.
final foodSearchRepositoryProvider = Provider<IFoodSearchRepository>(
(ref) => FoodSearchRepository.defaults(ref.watch(appDatabaseProvider)),
);
/// Debounce applied to keystroke-driven searches.
const Duration kFoodSearchDebounce = Duration(milliseconds: 400);
/// Immutable snapshot of the food-search screen state.
class FoodSearchState {
const FoodSearchState({
this.query = '',
this.products = const [],
this.isSearching = false,
this.errorMessage,
this.hasSearched = false,
});
/// Active query text (kept in sync with the search field).
final String query;
/// Products matching [query] from the cache / Open Food Facts / USDA.
final List<FoodProduct> products;
/// Whether a lookup is currently in flight.
final bool isSearching;
/// Last lookup failure surfaced to the user; `null` when healthy.
final String? errorMessage;
/// Whether a lookup completed at least once (drives the empty state).
final bool hasSearched;
FoodSearchState copyWith({
String? query,
List<FoodProduct>? products,
bool? isSearching,
Object? errorMessage = _keep,
bool? hasSearched,
}) =>
FoodSearchState(
query: query ?? this.query,
products: products ?? this.products,
isSearching: isSearching ?? this.isSearching,
errorMessage: identical(errorMessage, _keep)
? this.errorMessage
: errorMessage as String?,
hasSearched: hasSearched ?? this.hasSearched,
);
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
static const Object _keep = Object();
}
/// Orchestrates the food search flow: debounced text queries, direct barcode
/// lookups, and the loading/error states consumed by [FoodSearchScreen].
///
/// Supersedes in-flight lookups via a monotonically increasing request token
/// so a slow cache/network response can never overwrite a newer query.
class FoodSearchController extends StateNotifier<FoodSearchState> {
FoodSearchController(this._repository) : super(const FoodSearchState());
final IFoodSearchRepository _repository;
Timer? _debounce;
int _requestToken = 0;
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
/// Updates the query text and schedules a debounced lookup.
void onQueryChanged(String query) {
_debounce?.cancel();
final trimmed = query.trim();
if (trimmed.isEmpty) {
state = FoodSearchState(query: query);
return;
}
state = state.copyWith(query: query, isSearching: true, errorMessage: null);
_debounce = Timer(kFoodSearchDebounce, () => _runTextSearch(trimmed));
}
/// Immediately resolves [barcode] (UPC/EAN from the scanner or manual
/// entry), bypassing the debounce.
Future<void> lookupBarcode(String barcode) async {
final trimmed = barcode.trim();
if (trimmed.isEmpty) return;
_debounce?.cancel();
state = state.copyWith(
query: trimmed,
isSearching: true,
errorMessage: null,
);
final token = ++_requestToken;
try {
final results = await _repository.lookupBarcode(trimmed);
if (!_isCurrent(token)) return;
state = state.copyWith(
products: results,
isSearching: false,
hasSearched: true,
errorMessage: results.isEmpty
? 'No product found for "$trimmed".'
: null,
);
} catch (error, stackTrace) {
if (!_isCurrent(token)) return;
_recordError(error, stackTrace, 'barcode lookup failed');
}
}
/// Clears the query and results (search-bar "x" button).
void clear() {
_debounce?.cancel();
_requestToken++;
state = const FoodSearchState();
}
/// Re-runs the current query immediately (error-state retry / manual
/// refresh).
Future<void> retry() async {
final query = state.query.trim();
if (query.isEmpty) return;
if (_looksLikeBarcode(query)) {
await lookupBarcode(query);
} else {
await _runTextSearch(query);
}
}
Future<void> _runTextSearch(String query) async {
final token = ++_requestToken;
state = state.copyWith(isSearching: true, errorMessage: null);
try {
final results = await _repository.searchByText(query);
if (!_isCurrent(token)) return;
state = state.copyWith(
products: results,
isSearching: false,
hasSearched: true,
);
} catch (error, stackTrace) {
if (!_isCurrent(token)) return;
_recordError(error, stackTrace, 'text search failed');
}
}
bool _isCurrent(int token) => token == _requestToken && mounted;
/// UPC-A/EAN-8/13/14 detection used to route typed queries through the
/// barcode path.
static bool _looksLikeBarcode(String query) =>
RegExp(r'^\d{8}$|^\d{12,14}$').hasMatch(query);
void _recordError(Object error, StackTrace stackTrace, String label) {
debugPrintStack(stackTrace: stackTrace, label: '$label failed');
state = state.copyWith(
products: const [],
isSearching: false,
hasSearched: true,
errorMessage: 'Search failed. Check your connection and try again.',
);
}
}
/// Exposes the [FoodSearchController] to the widget tree.
final foodSearchControllerProvider =
StateNotifierProvider<FoodSearchController, FoodSearchState>(
(ref) => FoodSearchController(ref.watch(foodSearchRepositoryProvider)),
);

View File

@ -0,0 +1,771 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../../pantry/domain/entities/pantry_category.dart';
import '../../../pantry/presentation/controllers/pantry_inventory_controller.dart';
import '../../domain/entities/food_product.dart';
import '../../domain/entities/meal_type.dart';
import '../controllers/food_search_controller.dart';
import '../controllers/nutrition_controller.dart';
/// What the search flow should do with a selected product.
enum FoodSearchPreset {
/// Emphasize logging the product to the daily food diary.
log,
/// Emphasize adding the product to pantry stock (routed from the pantry
/// screen's "Scan Item" action).
pantry,
}
/// Parses the `?preset=` route parameter; unknown values fall back to
/// [FoodSearchPreset.log].
FoodSearchPreset foodSearchPresetFrom(String? raw) =>
raw == 'pantry' ? FoodSearchPreset.pantry : FoodSearchPreset.log;
/// Whether this device can run the camera barcode scanner.
///
/// The camera path is limited to iOS/Android; Web and desktop platforms
/// (Windows/macOS/Linux) fall back to a manual UPC entry dialog.
bool get supportsCameraScanner =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
/// Nutritional product search: debounced free-text queries backed by the
/// local cache / Open Food Facts / USDA hierarchy plus UPC/EAN barcode
/// scanning (camera on mobile, manual entry on desktop/web).
///
/// Selecting a product opens an action sheet with full macros, a serving
/// multiplier, and "Log to Diary" / "Add to Pantry" actions.
class FoodSearchScreen extends ConsumerStatefulWidget {
const FoodSearchScreen({super.key, this.preset = FoodSearchPreset.log});
/// Route preset (see [foodSearchPresetFrom]).
final FoodSearchPreset preset;
@override
ConsumerState<FoodSearchScreen> createState() => _FoodSearchScreenState();
}
class _FoodSearchScreenState extends ConsumerState<FoodSearchScreen> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(foodSearchControllerProvider);
return Scaffold(
appBar: AppBar(
title: Text(
widget.preset == FoodSearchPreset.pantry
? 'Scan for Pantry'
: 'Search Foods',
),
actions: [
IconButton(
tooltip: supportsCameraScanner
? 'Scan barcode'
: 'Enter barcode manually',
icon: const Icon(Icons.qr_code_scanner),
onPressed: _startBarcodeCapture,
),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: TextField(
controller: _searchController,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search by name, brand, or barcode…',
prefixIcon: const Icon(Icons.search),
suffixIcon: state.query.isEmpty
? null
: IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: _clearSearch,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
isDense: true,
),
onChanged: (value) {
ref
.read(foodSearchControllerProvider.notifier)
.onQueryChanged(value);
// Rebuild so the clear button appears/disappears.
setState(() {});
},
),
),
if (state.isSearching) const LinearProgressIndicator(),
Expanded(child: _buildResults(state)),
],
),
),
),
);
}
// ---------------------------------------------------------------------------
// Results
// ---------------------------------------------------------------------------
Widget _buildResults(FoodSearchState state) {
if (state.errorMessage != null && state.products.isEmpty) {
return _StatusView(
icon: Icons.error_outline,
iconColor: Theme.of(context).colorScheme.error,
title: 'Search unavailable',
message: state.errorMessage!,
actionLabel: 'Retry',
onAction: () =>
ref.read(foodSearchControllerProvider.notifier).retry(),
);
}
if (state.products.isEmpty) {
if (!state.hasSearched && state.query.isEmpty) {
return const _StatusView(
icon: Icons.search,
title: 'Find any food',
message: 'Search by name or brand, or scan a UPC/EAN barcode '
'with the camera button above.',
);
}
if (!state.isSearching && state.hasSearched) {
return const _StatusView(
icon: Icons.no_food,
title: 'No products found',
message: 'Try a different spelling, add a brand name, or scan '
'the barcode instead.',
);
}
return const SizedBox.shrink();
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: state.products.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) => _ProductTile(
product: state.products[index],
onTap: () => _showProductActions(state.products[index]),
),
);
}
// ---------------------------------------------------------------------------
// Barcode capture
// ---------------------------------------------------------------------------
void _clearSearch() {
_searchController.clear();
ref.read(foodSearchControllerProvider.notifier).clear();
setState(() {});
}
/// Mobile opens the camera scanner; desktop/web fall back to a manual UPC
/// dialog with an explanatory notification.
Future<void> _startBarcodeCapture() async {
if (!mounted) return;
if (supportsCameraScanner) {
final barcode = await _showScannerSheet();
if (barcode == null || !mounted) return;
_searchController.text = barcode;
await ref.read(foodSearchControllerProvider.notifier).lookupBarcode(
barcode,
);
setState(() {});
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux
? 'Camera scanning is unavailable on desktop. '
'Enter the UPC/EAN manually.'
: 'Camera scanning is unavailable in this build. '
'Enter the UPC/EAN manually.',
),
duration: const Duration(seconds: 3),
),
);
final barcode = await _showManualBarcodeDialog();
if (barcode == null || !mounted) return;
_searchController.text = barcode;
await ref.read(foodSearchControllerProvider.notifier).lookupBarcode(
barcode,
);
setState(() {});
}
/// Full-height modal sheet hosting the live camera scanner; resolves with
/// the first detected UPC/EAN raw value.
Future<String?> _showScannerSheet() => showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => const _BarcodeScannerSheet(),
);
/// Desktop/web fallback: a numeric UPC/EAN entry dialog.
Future<String?> _showManualBarcodeDialog() {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Enter barcode'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'UPC / EAN',
hintText: 'e.g. 012345678905',
prefixIcon: Icon(Icons.qr_code),
),
onSubmitted: (value) {
final trimmed = value.trim();
Navigator.of(dialogContext).pop(trimmed.isEmpty ? null : trimmed);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final trimmed = controller.text.trim();
Navigator.of(dialogContext)
.pop(trimmed.isEmpty ? null : trimmed);
},
child: const Text('Look Up'),
),
],
),
).then((value) {
controller.dispose();
return value;
});
}
// ---------------------------------------------------------------------------
// Product action sheet
// ---------------------------------------------------------------------------
/// Shows the macro breakdown + serving multiplier sheet with "Log to
/// Diary" and "Add to Pantry" actions.
void _showProductActions(FoodProduct product) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _ProductActionsSheet(
product: product,
preset: widget.preset,
),
);
}
}
/// Result row: thumbnail, name, brand, calories, and a protein badge.
class _ProductTile extends StatelessWidget {
const _ProductTile({required this.product, required this.onTap});
final FoodProduct product;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListTile(
leading: _Thumbnail(imageUrl: product.imageUrl),
title: Text(
product.productName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
if (product.brand != null) product.brand!,
'${product.calories.round()} kcal / serving',
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${product.proteinGrams.toStringAsFixed(1)}g',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
onTap: onTap,
);
}
}
/// Product thumbnail with graceful placeholder / error fallback.
class _Thumbnail extends StatelessWidget {
const _Thumbnail({this.imageUrl});
final String? imageUrl;
@override
Widget build(BuildContext context) {
final url = imageUrl;
if (url == null || url.isEmpty) {
return const CircleAvatar(child: Icon(Icons.fastfood_outlined));
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
url,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const CircleAvatar(
radius: 22,
child: Icon(Icons.fastfood_outlined),
),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const SizedBox(
width: 44,
height: 44,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
},
),
);
}
}
/// Centered empty/error placeholder with an optional action button.
class _StatusView extends StatelessWidget {
const _StatusView({
required this.icon,
required this.title,
required this.message,
this.iconColor,
this.actionLabel,
this.onAction,
});
final IconData icon;
final Color? iconColor;
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: iconColor),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(message, textAlign: TextAlign.center),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
);
}
}
/// Modal sheet hosting the live [MobileScanner] camera preview.
///
/// Only ever instantiated on platforms where [supportsCameraScanner] is
/// true; desktop/web builds reach the manual entry dialog instead, so the
/// method-channel camera never initializes on unsupported runtimes.
class _BarcodeScannerSheet extends StatefulWidget {
const _BarcodeScannerSheet();
@override
State<_BarcodeScannerSheet> createState() => _BarcodeScannerSheetState();
}
class _BarcodeScannerSheetState extends State<_BarcodeScannerSheet> {
final MobileScannerController _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [
BarcodeFormat.upcA,
BarcodeFormat.upcE,
BarcodeFormat.ean8,
BarcodeFormat.ean13,
],
);
bool _handled = false;
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_handled) return;
final rawValue = capture.barcodes
.map((barcode) => barcode.rawValue)
.firstWhere((value) => value != null && value.isNotEmpty,
orElse: () => null);
if (rawValue == null) return;
_handled = true;
Navigator.of(context).pop(rawValue);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Point the camera at a UPC/EAN barcode',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error, child) {
// Surface permission/hardware failures instead of a black
// preview; the user can still enter the code manually.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _error = _describe(error));
});
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_describe(error),
textAlign: TextAlign.center,
),
),
);
},
),
),
),
],
),
);
}
String _describe(MobileScannerException error) => switch (error.errorCode) {
MobileScannerErrorCode.permissionDenied =>
'Camera permission denied. Grant camera access in system settings '
'or enter the barcode manually.',
MobileScannerErrorCode.unsupported =>
'This device cannot scan barcodes with the camera.',
_ => 'Camera unavailable (${error.errorCode.name}).',
};
}
/// Serving-size multipliers offered by the action sheet.
const List<double> kServingMultipliers = [0.5, 1, 1.5, 2];
/// Action sheet for a resolved [FoodProduct]: full macro breakdown scaled by
/// the selected serving multiplier, plus "Log to Diary" and "Add to Pantry".
class _ProductActionsSheet extends ConsumerStatefulWidget {
const _ProductActionsSheet({required this.product, required this.preset});
final FoodProduct product;
/// Decides which action is visually primary.
final FoodSearchPreset preset;
@override
ConsumerState<_ProductActionsSheet> createState() =>
_ProductActionsSheetState();
}
class _ProductActionsSheetState extends ConsumerState<_ProductActionsSheet> {
double _multiplier = 1;
bool _busy = false;
FoodProduct get product => widget.product;
double _scaled(double value) => value * _multiplier;
Future<void> _logToDiary() async {
if (_busy) return;
setState(() => _busy = true);
final logged = await ref.read(nutritionControllerProvider.notifier).logFood(
mealType: _inferMealType(),
description: _description(),
proteinGrams: _scaled(product.proteinGrams),
calories: _scaled(product.calories).round(),
carbsGrams: _scaled(product.carbsGrams),
fatGrams: _scaled(product.fatGrams),
);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
logged
? 'Logged ${_description()} to your diary.'
: 'Could not log this product.',
),
),
);
}
Future<void> _addToPantry() async {
if (_busy) return;
setState(() => _busy = true);
final added =
await ref.read(pantryInventoryControllerProvider.notifier).addItem(
name: product.productName,
quantityOnHand: _scaled(product.servingSizeGrams),
unit: product.servingSizeUnit,
category: PantryCategory.infer(product.productName),
);
if (!mounted) return;
setState(() => _busy = false);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? 'Added ${_description()} to your pantry.'
: 'Could not add this product to the pantry.',
),
),
);
}
/// `Greek Yogurt, Plain ×1.5` — the multiplier suffix is omitted at 1x.
String _description() => _multiplier == 1
? product.productName
: '${product.productName} ×$_multiplier';
/// Best-effort meal slot from the current time of day.
MealType _inferMealType() {
final hour = DateTime.now().hour;
if (hour < 10) return MealType.breakfast;
if (hour < 15) return MealType.lunch;
if (hour < 21) return MealType.dinner;
return MealType.snack;
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final pantryPrimary = widget.preset == FoodSearchPreset.pantry;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.productName, style: textTheme.titleLarge),
if (product.brand != null) ...[
const SizedBox(height: 2),
Text(product.brand!, style: textTheme.bodyMedium),
],
const SizedBox(height: 12),
_MacroGrid(product: product, multiplier: _multiplier),
const SizedBox(height: 16),
Text('Servings', style: textTheme.titleSmall),
const SizedBox(height: 8),
SegmentedButton<double>(
segments: [
for (final multiplier in kServingMultipliers)
ButtonSegment(
value: multiplier,
label: Text('${multiplier}x'),
),
],
selected: {_multiplier},
onSelectionChanged: (selection) =>
setState(() => _multiplier = selection.first),
),
const SizedBox(height: 20),
if (_busy)
const Center(child: CircularProgressIndicator())
else ...[
FilledButton.icon(
onPressed: pantryPrimary ? _addToPantry : _logToDiary,
icon: Icon(
pantryPrimary
? Icons.kitchen_outlined
: Icons.restaurant_menu,
),
label: Text(
pantryPrimary ? 'Add to Pantry' : 'Log to Diary',
),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: pantryPrimary ? _logToDiary : _addToPantry,
icon: Icon(
pantryPrimary
? Icons.restaurant_menu
: Icons.kitchen_outlined,
),
label: Text(
pantryPrimary ? 'Log to Diary' : 'Add to Pantry',
),
),
],
],
),
),
);
}
}
/// Two-column macro grid (calories, protein, carbs, fat, fiber, sodium)
/// scaled by the serving multiplier.
class _MacroGrid extends StatelessWidget {
const _MacroGrid({required this.product, required this.multiplier});
final FoodProduct product;
final double multiplier;
@override
Widget build(BuildContext context) {
final entries = <MapEntry<String, String>>[
MapEntry(
'Calories',
'${(product.calories * multiplier).round()} kcal',
),
MapEntry(
'Protein',
'${(product.proteinGrams * multiplier).toStringAsFixed(1)} g',
),
MapEntry(
'Carbs',
'${(product.carbsGrams * multiplier).toStringAsFixed(1)} g',
),
MapEntry(
'Fat',
'${(product.fatGrams * multiplier).toStringAsFixed(1)} g',
),
if (product.fiberGrams != null)
MapEntry(
'Fiber',
'${(product.fiberGrams! * multiplier).toStringAsFixed(1)} g',
),
if (product.sodiumMg != null)
MapEntry(
'Sodium',
'${(product.sodiumMg! * multiplier).round()} mg',
),
];
return Card(
margin: EdgeInsets.zero,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
for (var i = 0; i < entries.length; i += 2)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(child: _MacroCell(entry: entries[i])),
if (i + 1 < entries.length)
Expanded(child: _MacroCell(entry: entries[i + 1]))
else
const Expanded(child: SizedBox()),
],
),
),
],
),
),
);
}
}
/// Single macro cell: label above, value below.
class _MacroCell extends StatelessWidget {
const _MacroCell({required this.entry});
final MapEntry<String, String> entry;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.key, style: textTheme.labelMedium),
Text(entry.value, style: textTheme.titleSmall),
],
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/platform/health_gateway.dart'; import '../../../../core/platform/health_gateway.dart';
import '../../../../core/utils/date_time_utils.dart'; import '../../../../core/utils/date_time_utils.dart';
@ -45,6 +46,12 @@ class NutritionScreen extends ConsumerWidget {
icon: const Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
label: const Text('Log Food'), label: const Text('Log Food'),
), ),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => context.push('/food-search'),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Search Food / Scan Barcode'),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const _WeightTrackerCard(), const _WeightTrackerCard(),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -464,6 +471,7 @@ class _WeightTrackerCard extends ConsumerWidget {
final gateway = ref.read(healthGatewayProvider); final gateway = ref.read(healthGatewayProvider);
final canSync = final canSync =
gateway.supportsNativeHealth || await gateway.isCloudSyncReady(); gateway.supportsNativeHealth || await gateway.isCloudSyncReady();
if (!context.mounted) return;
if (!canSync) { if (!canSync) {
await showLogWeightDialog(context); await showLogWeightDialog(context);
return; return;

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/utils/date_time_utils.dart'; import '../../../../core/utils/date_time_utils.dart';
import '../../domain/entities/pantry_category.dart'; import '../../domain/entities/pantry_category.dart';
@ -54,6 +55,14 @@ class _PantryScreenState extends ConsumerState<PantryScreen>
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Pantry & Shopping Delta'), title: const Text('Pantry & Shopping Delta'),
actions: [
if (_tabIndex == 0)
IconButton(
tooltip: 'Scan item barcode',
icon: const Icon(Icons.qr_code_scanner),
onPressed: _openFoodScanner,
),
],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
tabs: [ tabs: [
@ -101,6 +110,11 @@ class _PantryScreenState extends ConsumerState<PantryScreen>
); );
} }
/// Routes to the dual-source food search in pantry-add preset: scan or
/// type a UPC/EAN, resolve nutrition data, and stock the pantry from the
/// product action sheet.
void _openFoodScanner() => context.push('/food-search?preset=pantry');
Future<void> _showAddShoppingItemDialog() async { Future<void> _showAddShoppingItemDialog() async {
final saved = await showAddShoppingItemDialog(context); final saved = await showAddShoppingItemDialog(context);
if (saved != true || !mounted) return; if (saved != true || !mounted) return;

View File

@ -9,6 +9,7 @@ import app_links
import device_info_plus import device_info_plus
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_macos import flutter_secure_storage_macos
import mobile_scanner
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@ -645,6 +645,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
url: "https://pub.dev"
source: hosted
version: "5.2.3"
native_toolchain_c: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:
@ -661,6 +669,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.6.2" version: "9.6.2"
openfoodfacts:
dependency: "direct main"
description:
name: openfoodfacts
sha256: "6bf8c51e786b6fcab2c5857a511fd11d3ab03c602c6667bb2a089d93a8631646"
url: "https://pub.dev"
source: hosted
version: "3.31.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:

View File

@ -27,6 +27,13 @@ dependencies:
http: ^1.2.1 http: ^1.2.1
html: ^0.15.4 html: ^0.15.4
# Nutritional search: Open Food Facts product database + UPC/EAN scanning.
# (openfoodfacts ^3.7.0 resolves along the 3.x line; mobile_scanner powers the
# on-device camera scanner on Android/iOS/macOS/Web - desktop falls back to
# manual UPC entry.)
openfoodfacts: ^3.7.0
mobile_scanner: ^5.2.3
# Persistence & Secure Storage # Persistence & Secure Storage
# drift 2.35+ pairs with package:sqlite3 3.x, which bundles SQLite through # drift 2.35+ pairs with package:sqlite3 3.x, which bundles SQLite through
# build hooks (see `hooks:` below) instead of the retired platform plugins. # build hooks (see `hooks:` below) instead of the retired platform plugins.

View File

@ -0,0 +1,375 @@
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/features/nutrition/data/repositories/food_search_repository.dart';
import 'package:glp1_companion/features/nutrition/domain/entities/food_product.dart';
/// Recording fake implementing the [RemoteFoodDataSource] contract; each test
/// configures results/behaviour and asserts how often the repository
/// consulted the source.
class _FakeFoodDataSource implements RemoteFoodDataSource {
_FakeFoodDataSource({this.textResults, this.barcodeResults});
/// Products returned by [searchByText]; `null` simulates a source failure.
final List<FoodProduct>? textResults;
/// Products returned by [lookupBarcode]; `null` simulates a source failure.
final List<FoodProduct>? barcodeResults;
final List<String> textQueries = [];
final List<String> barcodeQueries = [];
@override
Future<List<FoodProduct>> searchByText(String query) async {
textQueries.add(query);
final results = textResults;
if (results == null) throw const _FakeSourceException();
return results;
}
@override
Future<List<FoodProduct>> lookupBarcode(String barcode) async {
barcodeQueries.add(barcode);
final results = barcodeResults;
if (results == null) throw const _FakeSourceException();
return results;
}
}
class _FakeSourceException implements Exception {
const _FakeSourceException();
}
FoodProduct makeProduct({
required String id,
String? barcode,
String productName = 'Greek Yogurt',
String brand = 'Fage',
double calories = 90,
double proteinGrams = 9,
}) =>
FoodProduct(
id: id,
barcode: barcode,
productName: productName,
brand: brand,
servingSizeGrams: 100,
servingSizeUnit: 'g',
calories: calories,
proteinGrams: proteinGrams,
carbsGrams: 4,
fatGrams: 0.4,
fiberGrams: null,
sodiumMg: 35,
imageUrl: null,
lastUpdated: DateTime(2026, 10, 17, 12),
);
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() => database.close());
group('FoodSearchRepository text search fallback sequence', () {
test('serves from the local cache without touching remote sources',
() async {
// Seed the cache directly, bypassing the repository.
await database.upsertFoodProduct(
FoodProductsTableCompanion(
id: const Value('cached-1'),
barcode: const Value.absent(),
productName: const Value('Greek Yogurt'),
brand: const Value('Fage'),
servingSizeGrams: const Value(100),
servingSizeUnit: const Value('g'),
calories: const Value(90),
proteinGrams: const Value(9),
carbsGrams: const Value(4),
fatGrams: const Value(0.4),
fiberGrams: const Value.absent(),
sodiumMg: const Value(35),
imageUrl: const Value.absent(),
lastUpdated: Value(DateTime(2026, 10, 17, 12)),
),
);
final openFoodFacts = _FakeFoodDataSource(textResults: const []);
final usda = _FakeFoodDataSource(textResults: const []);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.searchByText('yogurt');
expect(results, hasLength(1));
expect(results.single.id, 'cached-1');
expect(results.single.productName, 'Greek Yogurt');
expect(openFoodFacts.textQueries, isEmpty,
reason: 'cache hit must not reach Open Food Facts');
expect(usda.textQueries, isEmpty,
reason: 'cache hit must not reach USDA');
});
test('queries Open Food Facts on cache miss and caches the results',
() async {
final openFoodFacts = _FakeFoodDataSource(
textResults: [
makeProduct(id: '3017620422003', barcode: '3017620422003'),
],
);
final usda = _FakeFoodDataSource(textResults: const []);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.searchByText('nutella');
expect(results, hasLength(1));
expect(results.single.productName, 'Greek Yogurt');
expect(openFoodFacts.textQueries, ['nutella']);
expect(usda.textQueries, isEmpty,
reason: 'a successful OFF response must short-circuit USDA');
// Remote hits are written back into the drift cache.
final cached = await database.getFoodProductByBarcode('3017620422003');
expect(cached, isNotNull);
expect(cached!.productName, 'Greek Yogurt');
});
test('falls back to USDA when Open Food Facts fails, then caches',
() async {
final openFoodFacts = _FakeFoodDataSource(textResults: null);
final usda = _FakeFoodDataSource(
textResults: [
makeProduct(id: 'fdc:1105610', productName: 'Yogurt, plain'),
],
);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.searchByText('plain yogurt');
expect(results, hasLength(1));
expect(results.single.id, 'fdc:1105610');
expect(openFoodFacts.textQueries, ['plain yogurt']);
expect(usda.textQueries, ['plain yogurt']);
final cached = await database.getFoodProductById('fdc:1105610');
expect(cached, isNotNull);
expect(cached!.proteinGrams, 9);
});
test('falls back to USDA when Open Food Facts returns nothing', () async {
final openFoodFacts = _FakeFoodDataSource(textResults: const []);
final usda = _FakeFoodDataSource(
textResults: [
makeProduct(id: 'fdc:200', productName: 'Quinoa, cooked'),
],
);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.searchByText('quinoa');
expect(results.single.productName, 'Quinoa, cooked');
expect(usda.textQueries, ['quinoa']);
});
test('returns empty when every source misses', () async {
final repository = FoodSearchRepository(
database,
openFoodFacts: _FakeFoodDataSource(textResults: const []),
usdaFdc: _FakeFoodDataSource(textResults: const []),
);
final results = await repository.searchByText('kale chips');
expect(results, isEmpty);
});
test('second search for the same query is served from the cache',
() async {
final openFoodFacts = _FakeFoodDataSource(
textResults: [
makeProduct(id: '43162600', productName: 'Oat Milk, Original'),
],
);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: _FakeFoodDataSource(textResults: const []),
);
await repository.searchByText('oat milk');
final second = await repository.searchByText('oat milk');
expect(second, hasLength(1));
expect(openFoodFacts.textQueries, hasLength(1),
reason: 'the second lookup must be a cache hit');
});
test('blank queries short-circuit without I/O', () async {
final openFoodFacts = _FakeFoodDataSource(textResults: const []);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: _FakeFoodDataSource(textResults: const []),
);
expect(await repository.searchByText(' '), isEmpty);
expect(openFoodFacts.textQueries, isEmpty);
});
});
group('FoodSearchRepository barcode lookup fallback sequence', () {
test('serves a cached barcode without remote lookups', () async {
await seedProductCache(
database,
makeProduct(id: '012345678905', barcode: '012345678905'),
);
final openFoodFacts = _FakeFoodDataSource(barcodeResults: const []);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: _FakeFoodDataSource(barcodeResults: const []),
);
final results = await repository.lookupBarcode('012345678905');
expect(results.single.productName, 'Greek Yogurt');
expect(openFoodFacts.barcodeQueries, isEmpty);
});
test('OFF barcode hit skips the USDA fallback and caches', () async {
final openFoodFacts = _FakeFoodDataSource(
barcodeResults: [
makeProduct(id: '5000159407236', barcode: '5000159407236'),
],
);
final usda = _FakeFoodDataSource(barcodeResults: const []);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.lookupBarcode('5000159407236');
expect(results, hasLength(1));
expect(openFoodFacts.barcodeQueries, ['5000159407236']);
expect(usda.barcodeQueries, isEmpty);
final cached = await database.getFoodProductByBarcode('5000159407236');
expect(cached, isNotNull);
});
test('USDA fallback resolves branded GTINs OFF does not know', () async {
final openFoodFacts = _FakeFoodDataSource(barcodeResults: null);
final usda = _FakeFoodDataSource(
barcodeResults: [
makeProduct(
id: 'fdc:321345',
barcode: '0012345678901',
productName: 'Store Brand Bars',
),
],
);
final repository = FoodSearchRepository(
database,
openFoodFacts: openFoodFacts,
usdaFdc: usda,
);
final results = await repository.lookupBarcode('0012345678901');
expect(results.single.id, 'fdc:321345');
expect(openFoodFacts.barcodeQueries, ['0012345678901']);
expect(usda.barcodeQueries, ['0012345678901']);
final cached = await database.getFoodProductByBarcode('0012345678901');
expect(cached, isNotNull);
expect(cached!.productName, 'Store Brand Bars');
});
test('total failure throws FoodSearchUnavailableException, not empty',
() async {
final repository = FoodSearchRepository(
database,
openFoodFacts: _FakeFoodDataSource(barcodeResults: null),
usdaFdc: _FakeFoodDataSource(barcodeResults: null),
);
// Every source errored: the repository must surface an outage instead
// of a misleading "no matches" empty list.
expect(
repository.lookupBarcode('9999999999999'),
throwsA(isA<FoodSearchUnavailableException>()),
);
});
test('healthy sources answering empty still return an empty list',
() async {
final repository = FoodSearchRepository(
database,
openFoodFacts: _FakeFoodDataSource(barcodeResults: const []),
usdaFdc: _FakeFoodDataSource(barcodeResults: const []),
);
// Both sources answered successfully with zero matches — a true
// "unknown barcode", not an outage.
expect(await repository.lookupBarcode('9999999999999'), isEmpty);
});
});
}
/// Seeds the cache with [product] using the same companion mapping the
/// repository applies to remote hits.
Future<void> seedProductCache(
AppDatabase database,
FoodProduct product,
) =>
database.upsertFoodProduct(
FoodProductsTableCompanion(
id: Value(product.id),
barcode: product.barcode == null
? const Value.absent()
: Value(product.barcode),
productName: Value(product.productName),
brand: product.brand == null
? const Value.absent()
: Value(product.brand),
servingSizeGrams: Value(product.servingSizeGrams),
servingSizeUnit: Value(product.servingSizeUnit),
calories: Value(product.calories),
proteinGrams: Value(product.proteinGrams),
carbsGrams: Value(product.carbsGrams),
fatGrams: Value(product.fatGrams),
fiberGrams: product.fiberGrams == null
? const Value.absent()
: Value(product.fiberGrams),
sodiumMg: product.sodiumMg == null
? const Value.absent()
: Value(product.sodiumMg),
imageUrl: product.imageUrl == null
? const Value.absent()
: Value(product.imageUrl),
lastUpdated: Value(product.lastUpdated),
),
);

View File

@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/features/nutrition/data/datasources/usda_fdc_client.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Representative USDA FoodData Central `foods/search` payload with the six
/// nutrient IDs the client maps.
String _fdcSearchResponse() => jsonEncode({
'totalHits': 1,
'foods': [
{
'fdcId': 12345,
'description': 'YOGURT, PLAIN, WHOLE MILK',
'dataType': 'Foundation',
'foodNutrients': [
{'nutrientId': 1008, 'value': 61},
{'nutrientId': 1003, 'value': 3.5},
{'nutrientId': 1004, 'value': 3.3},
{'nutrientId': 1005, 'value': 4.7},
{'nutrientId': 1079, 'value': 0},
// USDA reports sodium (1093) in mg directly — unlike OFF, which
// reports grams; the client passes it through unchanged.
{'nutrientId': 1093, 'value': 46},
],
},
],
});
void main() {
group('UsdaFdcClient request shape', () {
test('sends api_key as a QUERY parameter, never in the JSON body',
() async {
late http.Request captured;
final client = MockClient((request) async {
captured = request;
return http.Response(_fdcSearchResponse(), 200);
});
final usda = UsdaFdcClient(client: client, apiKey: 'TEST_KEY');
await usda.searchByText('yogurt');
// Regression (field-reported 2026-10-17): the FDC API only validates
// the key from the query string — a body-only `apiKey` yields
// HTTP 403 API_KEY_MISSING.
expect(captured.url.queryParameters['api_key'], 'TEST_KEY');
expect(captured.url.queryParameters.containsKey('apiKey'), isFalse);
final body = jsonDecode(captured.body) as Map<String, dynamic>;
expect(body.containsKey('apiKey'), isFalse,
reason: 'the JSON body must not carry the key');
expect(body['query'], 'yogurt');
});
test('falls back to DEMO_KEY when no override or .env entry exists',
() async {
late Uri capturedUrl;
final client = MockClient((request) async {
capturedUrl = request.url;
return http.Response(_fdcSearchResponse(), 200);
});
// No constructor key; dotenv is uninitialized in tests, so the client
// must degrade to the public DEMO_KEY.
await UsdaFdcClient(client: client).searchByText('yogurt');
expect(capturedUrl.queryParameters['api_key'], 'DEMO_KEY');
});
test('maps nutrient IDs onto the FoodProduct entity', () async {
final client = MockClient((request) async {
return http.Response(_fdcSearchResponse(), 200);
});
final results =
await UsdaFdcClient(client: client, apiKey: 'k').searchByText('yogurt');
expect(results, hasLength(1));
final product = results.single;
expect(product.id, 'fdc:12345');
expect(product.productName, 'YOGURT, PLAIN, WHOLE MILK');
expect(product.calories, 61);
expect(product.proteinGrams, 3.5);
expect(product.fatGrams, 3.3);
expect(product.carbsGrams, 4.7);
expect(product.fiberGrams, 0);
expect(product.sodiumMg, 46, reason: 'grams are converted to mg');
expect(product.servingSizeGrams, 100);
expect(product.servingSizeUnit, 'g',
reason: 'FDC defaults to a per-100 g basis when no unit is given');
});
test('barcode lookups restrict the query to Branded foods', () async {
late Map<String, dynamic> capturedBody;
final client = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(_fdcSearchResponse(), 200);
});
await UsdaFdcClient(client: client, apiKey: 'k')
.lookupBarcode('012345678905');
expect(capturedBody['query'], '012345678905');
expect(capturedBody['dataType'], ['Branded']);
});
test('non-200 responses throw UsdaFdcException', () async {
final client = MockClient(
(request) async => http.Response('{"error":{"code":"API_KEY_MISSING"}}', 403),
);
final usda = UsdaFdcClient(client: client, apiKey: 'bad');
expect(
usda.searchByText('yogurt'),
throwsA(isA<UsdaFdcException>()),
);
});
});
}