From e43603be1bbc59616de81c0214c93ef6ddf5bf39 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 17 Oct 2026 19:37:06 -0400 Subject: [PATCH] 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 --- BUILD_STATE.md | 27 +- lib/app/router/app_router.dart | 10 + lib/core/database/app_database.dart | 96 +- lib/core/database/app_database.g.dart | 1041 ++++++++++++++++- .../google_health_connector_io.dart | 12 +- .../datasources/open_food_facts_client.dart | 228 ++++ .../datasources/remote_food_data_source.dart | 13 + .../data/datasources/usda_fdc_client.dart | 212 ++++ .../repositories/food_search_repository.dart | 216 ++++ .../domain/entities/food_product.dart | 112 ++ .../repositories/food_search_repository.dart | 24 + .../controllers/food_search_controller.dart | 189 +++ .../screens/food_search_screen.dart | 771 ++++++++++++ .../screens/nutrition_screen.dart | 8 + .../presentation/screens/pantry_screen.dart | 14 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 16 + pubspec.yaml | 7 + .../food_search_repository_test.dart | 375 ++++++ .../nutrition/usda_fdc_client_test.dart | 120 ++ 20 files changed, 3487 insertions(+), 6 deletions(-) create mode 100644 lib/features/nutrition/data/datasources/open_food_facts_client.dart create mode 100644 lib/features/nutrition/data/datasources/remote_food_data_source.dart create mode 100644 lib/features/nutrition/data/datasources/usda_fdc_client.dart create mode 100644 lib/features/nutrition/data/repositories/food_search_repository.dart create mode 100644 lib/features/nutrition/domain/entities/food_product.dart create mode 100644 lib/features/nutrition/domain/repositories/food_search_repository.dart create mode 100644 lib/features/nutrition/presentation/controllers/food_search_controller.dart create mode 100644 lib/features/nutrition/presentation/screens/food_search_screen.dart create mode 100644 test/features/nutrition/food_search_repository_test.dart create mode 100644 test/features/nutrition/usda_fdc_client_test.dart diff --git a/BUILD_STATE.md b/BUILD_STATE.md index 8acb5c0..36d645d 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -3,13 +3,14 @@ ## Active Target - **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE - **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 - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) - [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling - [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 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 5: Pantry Delta Engine & Auto-Shopping Generator - [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD @@ -102,6 +103,30 @@ - [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) +## 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) - [x] `dart analyze lib test` (Zero issues) - [x] `flutter test` (all passing — 125/125) diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 3155c16..c87128f 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -10,6 +10,7 @@ import '../../features/auth/presentation/screens/login_screen.dart'; import '../../features/exercise/presentation/screens/exercise_screen.dart'; import '../../features/medication/presentation/screens/medication_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/pantry/presentation/screens/pantry_screen.dart'; import '../../features/recipes/presentation/screens/recipes_screen.dart'; @@ -71,6 +72,15 @@ final routerProvider = Provider((ref) { path: '/login', 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( navigatorKey: _shellNavigatorKey, builder: (context, state, child) { diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index e1cc5ea..bfbf86e 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -242,6 +242,51 @@ class ShoppingListItemsTable extends Table { Set? 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:`); 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? get primaryKey => {id}; +} + /// Root drift database for the GLP-1 Companion. /// /// On native platforms the connection is backed by SQLCipher: the 256-bit AES @@ -259,6 +304,7 @@ class ShoppingListItemsTable extends Table { MealPlanSlotsTable, PantryItemsTable, ShoppingListItemsTable, + FoodProductsTable, ], ) class AppDatabase extends _$AppDatabase { @@ -269,7 +315,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 4; + int get schemaVersion => 5; @override MigrationStrategy get migration => MigrationStrategy( @@ -293,6 +339,10 @@ class AppDatabase extends _$AppDatabase { await m.createTable(pantryItemsTable); 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.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 getFoodProductById(String id) => + (select(foodProductsTable)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + + /// The cached product row carrying [barcode], or `null` when absent. + Future 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> 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 upsertFoodProduct(FoodProductsTableCompanion entry) => + into(foodProductsTable).insertOnConflictUpdate(entry); } /// Application-wide database instance; closed when the container is disposed. diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 028bc64..72a72e5 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -3392,6 +3392,720 @@ class ShoppingListItemsTableCompanion } } +class $FoodProductsTableTable extends FoodProductsTable + with TableInfo<$FoodProductsTableTable, FoodProductRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FoodProductsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _barcodeMeta = + const VerificationMeta('barcode'); + @override + late final GeneratedColumn barcode = GeneratedColumn( + 'barcode', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _productNameMeta = + const VerificationMeta('productName'); + @override + late final GeneratedColumn productName = GeneratedColumn( + 'product_name', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 512), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _brandMeta = const VerificationMeta('brand'); + @override + late final GeneratedColumn brand = GeneratedColumn( + 'brand', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _servingSizeGramsMeta = + const VerificationMeta('servingSizeGrams'); + @override + late final GeneratedColumn servingSizeGrams = GeneratedColumn( + 'serving_size_grams', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _servingSizeUnitMeta = + const VerificationMeta('servingSizeUnit'); + @override + late final GeneratedColumn servingSizeUnit = GeneratedColumn( + 'serving_size_unit', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 16), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _caloriesMeta = + const VerificationMeta('calories'); + @override + late final GeneratedColumn calories = GeneratedColumn( + 'calories', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _proteinGramsMeta = + const VerificationMeta('proteinGrams'); + @override + late final GeneratedColumn proteinGrams = GeneratedColumn( + 'protein_grams', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _carbsGramsMeta = + const VerificationMeta('carbsGrams'); + @override + late final GeneratedColumn carbsGrams = GeneratedColumn( + 'carbs_grams', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _fatGramsMeta = + const VerificationMeta('fatGrams'); + @override + late final GeneratedColumn fatGrams = GeneratedColumn( + 'fat_grams', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _fiberGramsMeta = + const VerificationMeta('fiberGrams'); + @override + late final GeneratedColumn fiberGrams = GeneratedColumn( + 'fiber_grams', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + static const VerificationMeta _sodiumMgMeta = + const VerificationMeta('sodiumMg'); + @override + late final GeneratedColumn sodiumMg = GeneratedColumn( + 'sodium_mg', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + static const VerificationMeta _imageUrlMeta = + const VerificationMeta('imageUrl'); + @override + late final GeneratedColumn imageUrl = GeneratedColumn( + 'image_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _lastUpdatedMeta = + const VerificationMeta('lastUpdated'); + @override + late final GeneratedColumn lastUpdated = GeneratedColumn( + 'last_updated', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [ + id, + barcode, + productName, + brand, + servingSizeGrams, + servingSizeUnit, + calories, + proteinGrams, + carbsGrams, + fatGrams, + fiberGrams, + sodiumMg, + imageUrl, + lastUpdated + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'food_products_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('barcode')) { + context.handle(_barcodeMeta, + barcode.isAcceptableOrUnknown(data['barcode']!, _barcodeMeta)); + } + if (data.containsKey('product_name')) { + context.handle( + _productNameMeta, + productName.isAcceptableOrUnknown( + data['product_name']!, _productNameMeta)); + } else if (isInserting) { + context.missing(_productNameMeta); + } + if (data.containsKey('brand')) { + context.handle( + _brandMeta, brand.isAcceptableOrUnknown(data['brand']!, _brandMeta)); + } + if (data.containsKey('serving_size_grams')) { + context.handle( + _servingSizeGramsMeta, + servingSizeGrams.isAcceptableOrUnknown( + data['serving_size_grams']!, _servingSizeGramsMeta)); + } else if (isInserting) { + context.missing(_servingSizeGramsMeta); + } + if (data.containsKey('serving_size_unit')) { + context.handle( + _servingSizeUnitMeta, + servingSizeUnit.isAcceptableOrUnknown( + data['serving_size_unit']!, _servingSizeUnitMeta)); + } else if (isInserting) { + context.missing(_servingSizeUnitMeta); + } + if (data.containsKey('calories')) { + context.handle(_caloriesMeta, + calories.isAcceptableOrUnknown(data['calories']!, _caloriesMeta)); + } else if (isInserting) { + context.missing(_caloriesMeta); + } + if (data.containsKey('protein_grams')) { + context.handle( + _proteinGramsMeta, + proteinGrams.isAcceptableOrUnknown( + data['protein_grams']!, _proteinGramsMeta)); + } else if (isInserting) { + context.missing(_proteinGramsMeta); + } + if (data.containsKey('carbs_grams')) { + context.handle( + _carbsGramsMeta, + carbsGrams.isAcceptableOrUnknown( + data['carbs_grams']!, _carbsGramsMeta)); + } else if (isInserting) { + context.missing(_carbsGramsMeta); + } + if (data.containsKey('fat_grams')) { + context.handle(_fatGramsMeta, + fatGrams.isAcceptableOrUnknown(data['fat_grams']!, _fatGramsMeta)); + } else if (isInserting) { + context.missing(_fatGramsMeta); + } + if (data.containsKey('fiber_grams')) { + context.handle( + _fiberGramsMeta, + fiberGrams.isAcceptableOrUnknown( + data['fiber_grams']!, _fiberGramsMeta)); + } + if (data.containsKey('sodium_mg')) { + context.handle(_sodiumMgMeta, + sodiumMg.isAcceptableOrUnknown(data['sodium_mg']!, _sodiumMgMeta)); + } + if (data.containsKey('image_url')) { + context.handle(_imageUrlMeta, + imageUrl.isAcceptableOrUnknown(data['image_url']!, _imageUrlMeta)); + } + if (data.containsKey('last_updated')) { + context.handle( + _lastUpdatedMeta, + lastUpdated.isAcceptableOrUnknown( + data['last_updated']!, _lastUpdatedMeta)); + } else if (isInserting) { + context.missing(_lastUpdatedMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + FoodProductRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FoodProductRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + barcode: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}barcode']), + productName: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}product_name'])!, + brand: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}brand']), + servingSizeGrams: attachedDatabase.typeMapping.read( + DriftSqlType.double, data['${effectivePrefix}serving_size_grams'])!, + servingSizeUnit: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}serving_size_unit'])!, + calories: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}calories'])!, + proteinGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}protein_grams'])!, + carbsGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}carbs_grams'])!, + fatGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}fat_grams'])!, + fiberGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}fiber_grams']), + sodiumMg: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}sodium_mg']), + imageUrl: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}image_url']), + lastUpdated: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}last_updated'])!, + ); + } + + @override + $FoodProductsTableTable createAlias(String alias) { + return $FoodProductsTableTable(attachedDatabase, alias); + } +} + +class FoodProductRecord extends DataClass + implements Insertable { + /// Stable primary key: the source barcode when known, otherwise a + /// source-prefixed identifier (e.g. `fdc:12345`). + final String id; + + /// UPC/EAN barcode; `null` when the source did not provide one. + final String? barcode; + final String productName; + final String? brand; + + /// Gram weight (or count) of one serving. + final double servingSizeGrams; + + /// Canonical unit key for [servingSizeGrams] (e.g. `g`, `ml`, `count`). + final String servingSizeUnit; + + /// Energy per serving, kilocalories. + final double calories; + final double proteinGrams; + final double carbsGrams; + final double fatGrams; + final double? fiberGrams; + final double? sodiumMg; + final String? imageUrl; + final DateTime lastUpdated; + const FoodProductRecord( + {required this.id, + this.barcode, + required this.productName, + this.brand, + required this.servingSizeGrams, + required this.servingSizeUnit, + required this.calories, + required this.proteinGrams, + required this.carbsGrams, + required this.fatGrams, + this.fiberGrams, + this.sodiumMg, + this.imageUrl, + required this.lastUpdated}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || barcode != null) { + map['barcode'] = Variable(barcode); + } + map['product_name'] = Variable(productName); + if (!nullToAbsent || brand != null) { + map['brand'] = Variable(brand); + } + map['serving_size_grams'] = Variable(servingSizeGrams); + map['serving_size_unit'] = Variable(servingSizeUnit); + map['calories'] = Variable(calories); + map['protein_grams'] = Variable(proteinGrams); + map['carbs_grams'] = Variable(carbsGrams); + map['fat_grams'] = Variable(fatGrams); + if (!nullToAbsent || fiberGrams != null) { + map['fiber_grams'] = Variable(fiberGrams); + } + if (!nullToAbsent || sodiumMg != null) { + map['sodium_mg'] = Variable(sodiumMg); + } + if (!nullToAbsent || imageUrl != null) { + map['image_url'] = Variable(imageUrl); + } + map['last_updated'] = Variable(lastUpdated); + return map; + } + + FoodProductsTableCompanion toCompanion(bool nullToAbsent) { + return FoodProductsTableCompanion( + id: Value(id), + barcode: barcode == null && nullToAbsent + ? const Value.absent() + : Value(barcode), + productName: Value(productName), + brand: + brand == null && nullToAbsent ? const Value.absent() : Value(brand), + servingSizeGrams: Value(servingSizeGrams), + servingSizeUnit: Value(servingSizeUnit), + calories: Value(calories), + proteinGrams: Value(proteinGrams), + carbsGrams: Value(carbsGrams), + fatGrams: Value(fatGrams), + fiberGrams: fiberGrams == null && nullToAbsent + ? const Value.absent() + : Value(fiberGrams), + sodiumMg: sodiumMg == null && nullToAbsent + ? const Value.absent() + : Value(sodiumMg), + imageUrl: imageUrl == null && nullToAbsent + ? const Value.absent() + : Value(imageUrl), + lastUpdated: Value(lastUpdated), + ); + } + + factory FoodProductRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FoodProductRecord( + id: serializer.fromJson(json['id']), + barcode: serializer.fromJson(json['barcode']), + productName: serializer.fromJson(json['productName']), + brand: serializer.fromJson(json['brand']), + servingSizeGrams: serializer.fromJson(json['servingSizeGrams']), + servingSizeUnit: serializer.fromJson(json['servingSizeUnit']), + calories: serializer.fromJson(json['calories']), + proteinGrams: serializer.fromJson(json['proteinGrams']), + carbsGrams: serializer.fromJson(json['carbsGrams']), + fatGrams: serializer.fromJson(json['fatGrams']), + fiberGrams: serializer.fromJson(json['fiberGrams']), + sodiumMg: serializer.fromJson(json['sodiumMg']), + imageUrl: serializer.fromJson(json['imageUrl']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'barcode': serializer.toJson(barcode), + 'productName': serializer.toJson(productName), + 'brand': serializer.toJson(brand), + 'servingSizeGrams': serializer.toJson(servingSizeGrams), + 'servingSizeUnit': serializer.toJson(servingSizeUnit), + 'calories': serializer.toJson(calories), + 'proteinGrams': serializer.toJson(proteinGrams), + 'carbsGrams': serializer.toJson(carbsGrams), + 'fatGrams': serializer.toJson(fatGrams), + 'fiberGrams': serializer.toJson(fiberGrams), + 'sodiumMg': serializer.toJson(sodiumMg), + 'imageUrl': serializer.toJson(imageUrl), + 'lastUpdated': serializer.toJson(lastUpdated), + }; + } + + FoodProductRecord copyWith( + {String? id, + Value barcode = const Value.absent(), + String? productName, + Value brand = const Value.absent(), + double? servingSizeGrams, + String? servingSizeUnit, + double? calories, + double? proteinGrams, + double? carbsGrams, + double? fatGrams, + Value fiberGrams = const Value.absent(), + Value sodiumMg = const Value.absent(), + Value imageUrl = const Value.absent(), + DateTime? lastUpdated}) => + FoodProductRecord( + id: id ?? this.id, + barcode: barcode.present ? barcode.value : this.barcode, + productName: productName ?? this.productName, + brand: brand.present ? brand.value : 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.present ? fiberGrams.value : this.fiberGrams, + sodiumMg: sodiumMg.present ? sodiumMg.value : this.sodiumMg, + imageUrl: imageUrl.present ? imageUrl.value : this.imageUrl, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + FoodProductRecord copyWithCompanion(FoodProductsTableCompanion data) { + return FoodProductRecord( + id: data.id.present ? data.id.value : this.id, + barcode: data.barcode.present ? data.barcode.value : this.barcode, + productName: + data.productName.present ? data.productName.value : this.productName, + brand: data.brand.present ? data.brand.value : this.brand, + servingSizeGrams: data.servingSizeGrams.present + ? data.servingSizeGrams.value + : this.servingSizeGrams, + servingSizeUnit: data.servingSizeUnit.present + ? data.servingSizeUnit.value + : this.servingSizeUnit, + calories: data.calories.present ? data.calories.value : this.calories, + proteinGrams: data.proteinGrams.present + ? data.proteinGrams.value + : this.proteinGrams, + carbsGrams: + data.carbsGrams.present ? data.carbsGrams.value : this.carbsGrams, + fatGrams: data.fatGrams.present ? data.fatGrams.value : this.fatGrams, + fiberGrams: + data.fiberGrams.present ? data.fiberGrams.value : this.fiberGrams, + sodiumMg: data.sodiumMg.present ? data.sodiumMg.value : this.sodiumMg, + imageUrl: data.imageUrl.present ? data.imageUrl.value : this.imageUrl, + lastUpdated: + data.lastUpdated.present ? data.lastUpdated.value : this.lastUpdated, + ); + } + + @override + String toString() { + return (StringBuffer('FoodProductRecord(') + ..write('id: $id, ') + ..write('barcode: $barcode, ') + ..write('productName: $productName, ') + ..write('brand: $brand, ') + ..write('servingSizeGrams: $servingSizeGrams, ') + ..write('servingSizeUnit: $servingSizeUnit, ') + ..write('calories: $calories, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams, ') + ..write('fiberGrams: $fiberGrams, ') + ..write('sodiumMg: $sodiumMg, ') + ..write('imageUrl: $imageUrl, ') + ..write('lastUpdated: $lastUpdated') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + barcode, + productName, + brand, + servingSizeGrams, + servingSizeUnit, + calories, + proteinGrams, + carbsGrams, + fatGrams, + fiberGrams, + sodiumMg, + imageUrl, + lastUpdated); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FoodProductRecord && + other.id == this.id && + other.barcode == this.barcode && + other.productName == this.productName && + other.brand == this.brand && + other.servingSizeGrams == this.servingSizeGrams && + other.servingSizeUnit == this.servingSizeUnit && + other.calories == this.calories && + other.proteinGrams == this.proteinGrams && + other.carbsGrams == this.carbsGrams && + other.fatGrams == this.fatGrams && + other.fiberGrams == this.fiberGrams && + other.sodiumMg == this.sodiumMg && + other.imageUrl == this.imageUrl && + other.lastUpdated == this.lastUpdated); +} + +class FoodProductsTableCompanion extends UpdateCompanion { + final Value id; + final Value barcode; + final Value productName; + final Value brand; + final Value servingSizeGrams; + final Value servingSizeUnit; + final Value calories; + final Value proteinGrams; + final Value carbsGrams; + final Value fatGrams; + final Value fiberGrams; + final Value sodiumMg; + final Value imageUrl; + final Value lastUpdated; + final Value rowid; + const FoodProductsTableCompanion({ + this.id = const Value.absent(), + this.barcode = const Value.absent(), + this.productName = const Value.absent(), + this.brand = const Value.absent(), + this.servingSizeGrams = const Value.absent(), + this.servingSizeUnit = const Value.absent(), + this.calories = const Value.absent(), + this.proteinGrams = const Value.absent(), + this.carbsGrams = const Value.absent(), + this.fatGrams = const Value.absent(), + this.fiberGrams = const Value.absent(), + this.sodiumMg = const Value.absent(), + this.imageUrl = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.rowid = const Value.absent(), + }); + FoodProductsTableCompanion.insert({ + required String id, + this.barcode = const Value.absent(), + required String productName, + this.brand = const Value.absent(), + required double servingSizeGrams, + required String servingSizeUnit, + required double calories, + required double proteinGrams, + required double carbsGrams, + required double fatGrams, + this.fiberGrams = const Value.absent(), + this.sodiumMg = const Value.absent(), + this.imageUrl = const Value.absent(), + required DateTime lastUpdated, + this.rowid = const Value.absent(), + }) : id = Value(id), + productName = Value(productName), + servingSizeGrams = Value(servingSizeGrams), + servingSizeUnit = Value(servingSizeUnit), + calories = Value(calories), + proteinGrams = Value(proteinGrams), + carbsGrams = Value(carbsGrams), + fatGrams = Value(fatGrams), + lastUpdated = Value(lastUpdated); + static Insertable custom({ + Expression? id, + Expression? barcode, + Expression? productName, + Expression? brand, + Expression? servingSizeGrams, + Expression? servingSizeUnit, + Expression? calories, + Expression? proteinGrams, + Expression? carbsGrams, + Expression? fatGrams, + Expression? fiberGrams, + Expression? sodiumMg, + Expression? imageUrl, + Expression? lastUpdated, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (barcode != null) 'barcode': barcode, + if (productName != null) 'product_name': productName, + if (brand != null) 'brand': brand, + if (servingSizeGrams != null) 'serving_size_grams': servingSizeGrams, + if (servingSizeUnit != null) 'serving_size_unit': servingSizeUnit, + if (calories != null) 'calories': calories, + if (proteinGrams != null) 'protein_grams': proteinGrams, + if (carbsGrams != null) 'carbs_grams': carbsGrams, + if (fatGrams != null) 'fat_grams': fatGrams, + if (fiberGrams != null) 'fiber_grams': fiberGrams, + if (sodiumMg != null) 'sodium_mg': sodiumMg, + if (imageUrl != null) 'image_url': imageUrl, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (rowid != null) 'rowid': rowid, + }); + } + + FoodProductsTableCompanion copyWith( + {Value? id, + Value? barcode, + Value? productName, + Value? brand, + Value? servingSizeGrams, + Value? servingSizeUnit, + Value? calories, + Value? proteinGrams, + Value? carbsGrams, + Value? fatGrams, + Value? fiberGrams, + Value? sodiumMg, + Value? imageUrl, + Value? lastUpdated, + Value? rowid}) { + return FoodProductsTableCompanion( + 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, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (barcode.present) { + map['barcode'] = Variable(barcode.value); + } + if (productName.present) { + map['product_name'] = Variable(productName.value); + } + if (brand.present) { + map['brand'] = Variable(brand.value); + } + if (servingSizeGrams.present) { + map['serving_size_grams'] = Variable(servingSizeGrams.value); + } + if (servingSizeUnit.present) { + map['serving_size_unit'] = Variable(servingSizeUnit.value); + } + if (calories.present) { + map['calories'] = Variable(calories.value); + } + if (proteinGrams.present) { + map['protein_grams'] = Variable(proteinGrams.value); + } + if (carbsGrams.present) { + map['carbs_grams'] = Variable(carbsGrams.value); + } + if (fatGrams.present) { + map['fat_grams'] = Variable(fatGrams.value); + } + if (fiberGrams.present) { + map['fiber_grams'] = Variable(fiberGrams.value); + } + if (sodiumMg.present) { + map['sodium_mg'] = Variable(sodiumMg.value); + } + if (imageUrl.present) { + map['image_url'] = Variable(imageUrl.value); + } + if (lastUpdated.present) { + map['last_updated'] = Variable(lastUpdated.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FoodProductsTableCompanion(') + ..write('id: $id, ') + ..write('barcode: $barcode, ') + ..write('productName: $productName, ') + ..write('brand: $brand, ') + ..write('servingSizeGrams: $servingSizeGrams, ') + ..write('servingSizeUnit: $servingSizeUnit, ') + ..write('calories: $calories, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams, ') + ..write('fiberGrams: $fiberGrams, ') + ..write('sodiumMg: $sodiumMg, ') + ..write('imageUrl: $imageUrl, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -3409,6 +4123,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { $PantryItemsTableTable(this); late final $ShoppingListItemsTableTable shoppingListItemsTable = $ShoppingListItemsTableTable(this); + late final $FoodProductsTableTable foodProductsTable = + $FoodProductsTableTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3421,7 +4137,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { recipesTable, mealPlanSlotsTable, pantryItemsTable, - shoppingListItemsTable + shoppingListItemsTable, + foodProductsTable ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( @@ -5335,6 +6052,326 @@ typedef $$ShoppingListItemsTableTableProcessedTableManager ), ShoppingListItemRecord, PrefetchHooks Function()>; +typedef $$FoodProductsTableTableCreateCompanionBuilder + = FoodProductsTableCompanion Function({ + required String id, + Value barcode, + required String productName, + Value brand, + required double servingSizeGrams, + required String servingSizeUnit, + required double calories, + required double proteinGrams, + required double carbsGrams, + required double fatGrams, + Value fiberGrams, + Value sodiumMg, + Value imageUrl, + required DateTime lastUpdated, + Value rowid, +}); +typedef $$FoodProductsTableTableUpdateCompanionBuilder + = FoodProductsTableCompanion Function({ + Value id, + Value barcode, + Value productName, + Value brand, + Value servingSizeGrams, + Value servingSizeUnit, + Value calories, + Value proteinGrams, + Value carbsGrams, + Value fatGrams, + Value fiberGrams, + Value sodiumMg, + Value imageUrl, + Value lastUpdated, + Value rowid, +}); + +class $$FoodProductsTableTableFilterComposer + extends Composer<_$AppDatabase, $FoodProductsTableTable> { + $$FoodProductsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get barcode => $composableBuilder( + column: $table.barcode, builder: (column) => ColumnFilters(column)); + + ColumnFilters get productName => $composableBuilder( + column: $table.productName, builder: (column) => ColumnFilters(column)); + + ColumnFilters get brand => $composableBuilder( + column: $table.brand, builder: (column) => ColumnFilters(column)); + + ColumnFilters get servingSizeGrams => $composableBuilder( + column: $table.servingSizeGrams, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get servingSizeUnit => $composableBuilder( + column: $table.servingSizeUnit, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnFilters(column)); + + ColumnFilters get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fiberGrams => $composableBuilder( + column: $table.fiberGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get sodiumMg => $composableBuilder( + column: $table.sodiumMg, builder: (column) => ColumnFilters(column)); + + ColumnFilters get imageUrl => $composableBuilder( + column: $table.imageUrl, builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => ColumnFilters(column)); +} + +class $$FoodProductsTableTableOrderingComposer + extends Composer<_$AppDatabase, $FoodProductsTableTable> { + $$FoodProductsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get barcode => $composableBuilder( + column: $table.barcode, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get productName => $composableBuilder( + column: $table.productName, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get brand => $composableBuilder( + column: $table.brand, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get servingSizeGrams => $composableBuilder( + column: $table.servingSizeGrams, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get servingSizeUnit => $composableBuilder( + column: $table.servingSizeUnit, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get proteinGrams => $composableBuilder( + column: $table.proteinGrams, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fiberGrams => $composableBuilder( + column: $table.fiberGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sodiumMg => $composableBuilder( + column: $table.sodiumMg, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get imageUrl => $composableBuilder( + column: $table.imageUrl, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => ColumnOrderings(column)); +} + +class $$FoodProductsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $FoodProductsTableTable> { + $$FoodProductsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get barcode => + $composableBuilder(column: $table.barcode, builder: (column) => column); + + GeneratedColumn get productName => $composableBuilder( + column: $table.productName, builder: (column) => column); + + GeneratedColumn get brand => + $composableBuilder(column: $table.brand, builder: (column) => column); + + GeneratedColumn get servingSizeGrams => $composableBuilder( + column: $table.servingSizeGrams, builder: (column) => column); + + GeneratedColumn get servingSizeUnit => $composableBuilder( + column: $table.servingSizeUnit, builder: (column) => column); + + GeneratedColumn get calories => + $composableBuilder(column: $table.calories, builder: (column) => column); + + GeneratedColumn get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => column); + + GeneratedColumn get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => column); + + GeneratedColumn get fatGrams => + $composableBuilder(column: $table.fatGrams, builder: (column) => column); + + GeneratedColumn get fiberGrams => $composableBuilder( + column: $table.fiberGrams, builder: (column) => column); + + GeneratedColumn get sodiumMg => + $composableBuilder(column: $table.sodiumMg, builder: (column) => column); + + GeneratedColumn get imageUrl => + $composableBuilder(column: $table.imageUrl, builder: (column) => column); + + GeneratedColumn get lastUpdated => $composableBuilder( + column: $table.lastUpdated, builder: (column) => column); +} + +class $$FoodProductsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $FoodProductsTableTable, + FoodProductRecord, + $$FoodProductsTableTableFilterComposer, + $$FoodProductsTableTableOrderingComposer, + $$FoodProductsTableTableAnnotationComposer, + $$FoodProductsTableTableCreateCompanionBuilder, + $$FoodProductsTableTableUpdateCompanionBuilder, + ( + FoodProductRecord, + BaseReferences<_$AppDatabase, $FoodProductsTableTable, FoodProductRecord> + ), + FoodProductRecord, + PrefetchHooks Function()> { + $$FoodProductsTableTableTableManager( + _$AppDatabase db, $FoodProductsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$FoodProductsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$FoodProductsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$FoodProductsTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value barcode = const Value.absent(), + Value productName = const Value.absent(), + Value brand = const Value.absent(), + Value servingSizeGrams = const Value.absent(), + Value servingSizeUnit = const Value.absent(), + Value calories = const Value.absent(), + Value proteinGrams = const Value.absent(), + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent(), + Value fiberGrams = const Value.absent(), + Value sodiumMg = const Value.absent(), + Value imageUrl = const Value.absent(), + Value lastUpdated = const Value.absent(), + Value rowid = const Value.absent(), + }) => + FoodProductsTableCompanion( + id: id, + barcode: barcode, + productName: productName, + brand: brand, + servingSizeGrams: servingSizeGrams, + servingSizeUnit: servingSizeUnit, + calories: calories, + proteinGrams: proteinGrams, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + fiberGrams: fiberGrams, + sodiumMg: sodiumMg, + imageUrl: imageUrl, + lastUpdated: lastUpdated, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + Value barcode = const Value.absent(), + required String productName, + Value brand = const Value.absent(), + required double servingSizeGrams, + required String servingSizeUnit, + required double calories, + required double proteinGrams, + required double carbsGrams, + required double fatGrams, + Value fiberGrams = const Value.absent(), + Value sodiumMg = const Value.absent(), + Value imageUrl = const Value.absent(), + required DateTime lastUpdated, + Value rowid = const Value.absent(), + }) => + FoodProductsTableCompanion.insert( + id: id, + barcode: barcode, + productName: productName, + brand: brand, + servingSizeGrams: servingSizeGrams, + servingSizeUnit: servingSizeUnit, + calories: calories, + proteinGrams: proteinGrams, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + fiberGrams: fiberGrams, + sodiumMg: sodiumMg, + imageUrl: imageUrl, + lastUpdated: lastUpdated, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$FoodProductsTableTable, FoodProductRecord>( + table), + BaseReferences<_$AppDatabase, $FoodProductsTableTable, + FoodProductRecord>(db, table, e) + )) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$FoodProductsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $FoodProductsTableTable, + FoodProductRecord, + $$FoodProductsTableTableFilterComposer, + $$FoodProductsTableTableOrderingComposer, + $$FoodProductsTableTableAnnotationComposer, + $$FoodProductsTableTableCreateCompanionBuilder, + $$FoodProductsTableTableUpdateCompanionBuilder, + ( + FoodProductRecord, + BaseReferences<_$AppDatabase, $FoodProductsTableTable, FoodProductRecord> + ), + FoodProductRecord, + PrefetchHooks Function()>; class $AppDatabaseManager { final _$AppDatabase _db; @@ -5356,4 +6393,6 @@ class $AppDatabaseManager { $$ShoppingListItemsTableTableTableManager get shoppingListItemsTable => $$ShoppingListItemsTableTableTableManager( _db, _db.shoppingListItemsTable); + $$FoodProductsTableTableTableManager get foodProductsTable => + $$FoodProductsTableTableTableManager(_db, _db.foodProductsTable); } diff --git a/lib/core/platform/cloud_health/google_health_connector_io.dart b/lib/core/platform/cloud_health/google_health_connector_io.dart index 3d54b28..43bdc79 100644 --- a/lib/core/platform/cloud_health/google_health_connector_io.dart +++ b/lib/core/platform/cloud_health/google_health_connector_io.dart @@ -96,11 +96,17 @@ class GoogleHealthConnector implements ICloudHealthConnector { final http.Client _client; final FlutterSecureStorage _storage; - String? get _clientId => dotenv.env['GOOGLE_HEALTH_CLIENT_ID']; - String? get _clientSecret => dotenv.env['GOOGLE_HEALTH_CLIENT_SECRET']; + /// Reads [.env] without throwing when the asset was never loaded (unit + /// 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( - dotenv.env['GOOGLE_HEALTH_REDIRECT_PORT'] ?? '', + _env('GOOGLE_HEALTH_REDIRECT_PORT') ?? '', ) ?? 52789; diff --git a/lib/features/nutrition/data/datasources/open_food_facts_client.dart b/lib/features/nutrition/data/datasources/open_food_facts_client.dart new file mode 100644 index 0000000..aabebed --- /dev/null +++ b/lib/features/nutrition/data/datasources/open_food_facts_client.dart @@ -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 _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> 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> 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 []; + return products + .map(_mapProduct) + .whereType() + .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; + } +} diff --git a/lib/features/nutrition/data/datasources/remote_food_data_source.dart b/lib/features/nutrition/data/datasources/remote_food_data_source.dart new file mode 100644 index 0000000..010a0ab --- /dev/null +++ b/lib/features/nutrition/data/datasources/remote_food_data_source.dart @@ -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> searchByText(String query); + + /// Resolves a UPC/EAN barcode to zero or one product. + Future> lookupBarcode(String barcode); +} \ No newline at end of file diff --git a/lib/features/nutrition/data/datasources/usda_fdc_client.dart b/lib/features/nutrition/data/datasources/usda_fdc_client.dart new file mode 100644 index 0000000..0c67750 --- /dev/null +++ b/lib/features/nutrition/data/datasources/usda_fdc_client.dart @@ -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> searchByText(String query) => + search(query, isBarcode: false); + + @override + Future> lookupBarcode(String barcode) => + search(barcode, isBarcode: true, pageSize: 5); + + /// Searches FDC for [query] and maps up to [pageSize] results. + /// + /// [isBarcode] switches the query into a GTIN/UPC lookup for branded foods. + Future> search( + String query, { + bool isBarcode = false, + int pageSize = 25, + }) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + + // The FDC API validates the key from the QUERY STRING only; an `apiKey` + // field inside the JSON body is silently ignored and yields + // HTTP 403 `API_KEY_MISSING`. + final uri = Uri.parse(endpoint).replace( + queryParameters: {'api_key': apiKey}, + ); + + final body = { + 'query': trimmed, + 'pageSize': pageSize, + if (isBarcode) 'dataType': ['Branded'], + }; + + final response = await _client + .post( + uri, + headers: const { + 'Content-Type': 'application/json', + 'User-Agent': 'GLP-1 Companion/0.1.0 (nutrition lookup)', + }, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode != 200) { + throw UsdaFdcException( + 'USDA FoodData Central responded with HTTP ${response.statusCode}.', + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) return const []; + final foods = decoded['foods']; + if (foods is! List) return const []; + + final now = DateTime.now(); + final results = []; + for (final food in foods) { + if (food is! Map) continue; + final product = _mapFood( + food, + now: now, + fallbackBarcode: isBarcode ? trimmed : null, + ); + if (product != null) results.add(product); + } + return results; + } + + /// Maps a single FDC food payload to a [FoodProduct]; returns `null` when + /// the payload has no usable identity or energy value. + FoodProduct? _mapFood( + Map food, { + required DateTime now, + String? fallbackBarcode, + }) { + final fdcId = food['fdcId']; + final description = food['description'] as String? ?? ''; + if (fdcId == null || description.isEmpty) return null; + + final nutrients = {}; + final rawNutrients = food['foodNutrients']; + if (rawNutrients is List) { + for (final entry in rawNutrients) { + if (entry is! Map) continue; + final id = entry['nutrientId']; + final value = entry['value']; + if (id is int && value is num) nutrients[id] = value.toDouble(); + } + } + + // Energy is the gate: records without it cannot be logged meaningfully. + final calories = nutrients[nutrientIdEnergyKcal]; + if (calories == null) return null; + + final barcode = (food['gtinUpc'] as String?)?.trim(); + final brand = (food['brandName'] as String?)?.trim(); + final servingSize = (food['servingSize'] as num?)?.toDouble() ?? 100; + final servingUnit = + (food['servingSizeUnit'] as String?)?.trim().toLowerCase(); + + return FoodProduct( + id: 'fdc:$fdcId', + barcode: (barcode == null || barcode.isEmpty) ? fallbackBarcode : barcode, + productName: description, + brand: (brand == null || brand.isEmpty) ? null : brand, + servingSizeGrams: servingSize, + servingSizeUnit: _canonicalUnit(servingUnit), + calories: calories, + proteinGrams: nutrients[nutrientIdProtein] ?? 0, + carbsGrams: nutrients[nutrientIdCarbs] ?? 0, + fatGrams: nutrients[nutrientIdFat] ?? 0, + fiberGrams: nutrients[nutrientIdFiber], + sodiumMg: nutrients[nutrientIdSodium], + imageUrl: null, + lastUpdated: now, + ); + } + + /// Normalizes the FDC serving unit (`"g"`, `"ml"`, `"count"` …) into the + /// pantry-canonical unit keys. A missing unit defaults to grams: FDC + /// nutrition values sit on a per-100 g basis when no serving is declared. + String _canonicalUnit(String? unit) { + switch (unit) { + case 'g': + case 'gram': + case 'grams': + case null: + case '': + return 'g'; + case 'ml': + case 'milliliter': + case 'milliliters': + return 'ml'; + case 'kg': + return 'kg'; + case 'oz': + return 'oz'; + case 'count': + case 'each': + return 'count'; + default: + return unit; + } + } + + /// Releases the underlying HTTP client. + void close() => _client.close(); +} + +/// Non-fatal failure surfaced by [UsdaFdcClient]; the search repository +/// catches it and degrades to an empty result set. +class UsdaFdcException implements Exception { + const UsdaFdcException(this.message); + + final String message; + + @override + String toString() => 'UsdaFdcException($message)'; +} diff --git a/lib/features/nutrition/data/repositories/food_search_repository.dart b/lib/features/nutrition/data/repositories/food_search_repository.dart new file mode 100644 index 0000000..edf719d --- /dev/null +++ b/lib/features/nutrition/data/repositories/food_search_repository.dart @@ -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> 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? 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 []; + await _cacheAll(results); + return results; + } + + @override + Future> 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? 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 []; + await _cacheAll(results); + return results; + } + + @override + Future 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 _cacheAll(List 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'); + } +} diff --git a/lib/features/nutrition/domain/entities/food_product.dart b/lib/features/nutrition/domain/entities/food_product.dart new file mode 100644 index 0000000..06c08e0 --- /dev/null +++ b/lib/features/nutrition/domain/entities/food_product.dart @@ -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)'; +} \ No newline at end of file diff --git a/lib/features/nutrition/domain/repositories/food_search_repository.dart b/lib/features/nutrition/domain/repositories/food_search_repository.dart new file mode 100644 index 0000000..ad2a51b --- /dev/null +++ b/lib/features/nutrition/domain/repositories/food_search_repository.dart @@ -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> 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> lookupBarcode(String barcode); + + /// Re-caches [product] after external mutation (e.g. pantry ingestion). + Future cacheProduct(FoodProduct product); +} \ No newline at end of file diff --git a/lib/features/nutrition/presentation/controllers/food_search_controller.dart b/lib/features/nutrition/presentation/controllers/food_search_controller.dart new file mode 100644 index 0000000..8cc821a --- /dev/null +++ b/lib/features/nutrition/presentation/controllers/food_search_controller.dart @@ -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( + (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 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? 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 { + 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 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 retry() async { + final query = state.query.trim(); + if (query.isEmpty) return; + if (_looksLikeBarcode(query)) { + await lookupBarcode(query); + } else { + await _runTextSearch(query); + } + } + + Future _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( + (ref) => FoodSearchController(ref.watch(foodSearchRepositoryProvider)), +); diff --git a/lib/features/nutrition/presentation/screens/food_search_screen.dart b/lib/features/nutrition/presentation/screens/food_search_screen.dart new file mode 100644 index 0000000..447be74 --- /dev/null +++ b/lib/features/nutrition/presentation/screens/food_search_screen.dart @@ -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 createState() => _FoodSearchScreenState(); +} + +class _FoodSearchScreenState extends ConsumerState { + 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 _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 _showScannerSheet() => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => const _BarcodeScannerSheet(), + ); + + /// Desktop/web fallback: a numeric UPC/EAN entry dialog. + Future _showManualBarcodeDialog() { + final controller = TextEditingController(); + return showDialog( + 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( + 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 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 _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 _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( + 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( + '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 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), + ], + ); + } +} diff --git a/lib/features/nutrition/presentation/screens/nutrition_screen.dart b/lib/features/nutrition/presentation/screens/nutrition_screen.dart index 53e0c72..e1854b1 100644 --- a/lib/features/nutrition/presentation/screens/nutrition_screen.dart +++ b/lib/features/nutrition/presentation/screens/nutrition_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/platform/health_gateway.dart'; import '../../../../core/utils/date_time_utils.dart'; @@ -45,6 +46,12 @@ class NutritionScreen extends ConsumerWidget { icon: const Icon(Icons.add_circle_outline), 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 _WeightTrackerCard(), const SizedBox(height: 24), @@ -464,6 +471,7 @@ class _WeightTrackerCard extends ConsumerWidget { final gateway = ref.read(healthGatewayProvider); final canSync = gateway.supportsNativeHealth || await gateway.isCloudSyncReady(); + if (!context.mounted) return; if (!canSync) { await showLogWeightDialog(context); return; diff --git a/lib/features/pantry/presentation/screens/pantry_screen.dart b/lib/features/pantry/presentation/screens/pantry_screen.dart index 704e188..3692be3 100644 --- a/lib/features/pantry/presentation/screens/pantry_screen.dart +++ b/lib/features/pantry/presentation/screens/pantry_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/utils/date_time_utils.dart'; import '../../domain/entities/pantry_category.dart'; @@ -54,6 +55,14 @@ class _PantryScreenState extends ConsumerState return Scaffold( appBar: AppBar( 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( controller: _tabController, tabs: [ @@ -101,6 +110,11 @@ class _PantryScreenState extends ConsumerState ); } + /// 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 _showAddShoppingItemDialog() async { final saved = await showAddShoppingItemDialog(context); if (saved != true || !mounted) return; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b1fac11..1a891dd 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import device_info_plus import flutter_local_notifications import flutter_secure_storage_macos +import mobile_scanner import shared_preferences_foundation import url_launcher_macos @@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 61158ff..eb99c8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -645,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -661,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 02d50ac..a43c35e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,13 @@ dependencies: http: ^1.2.1 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 # drift 2.35+ pairs with package:sqlite3 3.x, which bundles SQLite through # build hooks (see `hooks:` below) instead of the retired platform plugins. diff --git a/test/features/nutrition/food_search_repository_test.dart b/test/features/nutrition/food_search_repository_test.dart new file mode 100644 index 0000000..911003e --- /dev/null +++ b/test/features/nutrition/food_search_repository_test.dart @@ -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? textResults; + + /// Products returned by [lookupBarcode]; `null` simulates a source failure. + final List? barcodeResults; + + final List textQueries = []; + final List barcodeQueries = []; + + @override + Future> searchByText(String query) async { + textQueries.add(query); + final results = textResults; + if (results == null) throw const _FakeSourceException(); + return results; + } + + @override + Future> 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()), + ); + }); + + 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 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), + ), + ); + diff --git a/test/features/nutrition/usda_fdc_client_test.dart b/test/features/nutrition/usda_fdc_client_test.dart new file mode 100644 index 0000000..45ee45a --- /dev/null +++ b/test/features/nutrition/usda_fdc_client_test.dart @@ -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; + 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 capturedBody; + final client = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + 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()), + ); + }); + }); +} \ No newline at end of file