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:
parent
17b81f567d
commit
e43603be1b
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
|
@ -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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue