diff --git a/BUILD_STATE.md b/BUILD_STATE.md index b9e6336..f853515 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -1,8 +1,8 @@ # Build Execution Ledger ## Active Target -- **Current Milestone:** Phase 6: Cross-Platform Hardening, Integration Tests & Forgejo CI/CD — COMPLETE -- **Status:** Phase 6 Complete (release-ready: all phases 1–6 done) +- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE +- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done) ## Phase Checklist - [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB) @@ -11,6 +11,7 @@ - [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 +- [x] Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) ## Phase 2 Deliverables (2026-10-16) - [x] Domain: `DoseEntry` entity, 12-quadrant `InjectionSite` rotation model, `Glp1Compound` value object (Sema 168h / Tirze 120h / Lira 13h), `IMedicationRepository` contract. @@ -66,7 +67,27 @@ - [x] Cleanup: dead `lib/features/recipes/data/recipe_scraper.dart` (unreferenced Phase-0 scaffold superseded by `ScrapeRecipeFromUrl`) removed with its empty directory; zero TODO/FIXME markers in `lib/` or `test/`. - [x] Docs: `README.md` rewritten (architectural overview + directory layout, local dev prerequisites incl. VS Code/Cline setup and `.env` configuration via new `.env.example`, SQLCipher AES-256 key lifecycle, CI pipeline details); `BUILD_STATE.md` Phase 1–6 checkboxes complete. -## Active Verification Gates (Phase 6, 2026-10-16) +## Phase 7 Deliverables (2026-10-16) +- [x] Domain: `MuscleGroup` enum + `ExerciseEntry` entity (pure Dart), `IExerciseRepository` contract, session-scoped `InMemoryExerciseRepository` (drift table deferred to a follow-up migration). +- [x] Presentation (exercise): `ExerciseController` (weekly distinct-session rollup vs. the recommended 3 sessions/week lean-mass target) + `ExerciseScreen` with weekly strength-frequency card, day-grouped working-set log (swipe-to-delete), and the `LogExerciseDialog` (muscle group / name / sets / reps / weight). +- [x] Presentation (Recipe Vault): new `features/recipes` screen — pinned URL import bar wired to `RecipeImportController`, search field + macro `ChoiceChip` filters ("Protein > 30g", "Protein > 20g", "Under 500 kcal", All), responsive `GridView.builder` (`SliverGridDelegateWithMaxCrossAxisExtent`) with chunked infinite scroll (24-card pages, 6-card prefetch margin), details bottom sheet, delete confirmation; reuses the meal-planner recipe domain (no duplicate bounded context). +- [x] Refactor (meal planner): `MealPlannerScreen` stripped to week navigation + 7-day calendar + daily protein rollups; inline import bar and `SavedRecipesTray` removed (moved to Recipe Vault); `AssignSlotDialog`/drag-drop replaced by the searchable `AssignRecipeSheet` modal bottom sheet (Recipe Vault search + custom-entry fallback); empty slots render a "+ Assign Recipe" affordance. +- [x] Presentation (Settings): `SettingsScreen` — Identity (email, Supabase status badge, sign-out), Targets (daily protein g / water oz inputs feeding `NutritionController.updateTargets`), Security & Storage (SQLCipher AES-256 badge with web-degradation note), Integrations (Apple Health / Health Connect / HealthGateway toggles via `IntegrationsController`, native permission round-trips through `IHealthGateway`). +- [x] Shell: `app_router.dart` registers `/medication`, `/nutrition`, `/exercise`, `/meal-planner`, `/recipes`, `/pantry`, `/settings`; `AdaptiveHomeScaffold` desktop rail expanded to the 7 specified destinations (`medication_liquid`, `restaurant`, `fitness_center`, `calendar_month`, `menu_book`, `inventory_2`, `settings`) with `NavigationRailLabelType.all` inside a height-constrained scroll view (no compact-window overflow); mobile keeps 4 core tabs + a "More" tab opening the secondary-destinations drawer (Exercise, Recipes, Settings). +- [x] Tests: `app_navigation_test.dart` extended to all 7 rail routes + mobile "More" drawer flow (7 tests total); `meal_planner_screen_test.dart` rewritten for the calendar-only layout including the assign-sheet opening; `SettingsScreen` badge rows use `Flexible` + ellipsis so narrow widths never overflow. + +## Post-Phase-7 Additions +- [x] Recipe import hardening (2026-10-16): in-browser URL imports failed with "could not download" — diagnosed against the live allrecipes recipe: the native fetch succeeds (HTTP 200) and the parser extracts the recipe correctly, so the failure is the **web CORS policy** (recipe sites never send `Access-Control-Allow-Origin`, so Chrome aborts before a status code exists); additionally, requests without a browser-like `User-Agent` draw HTTP 403 from anti-bot gates. Fixes in `ScrapeRecipeFromUrl`: (1) browser-like `Accept`/`Accept-Language`/`User-Agent` headers on native (UA stripped on web — forbidden header); (2) optional `--dart-define=RECIPE_PROXY_URL=` that routes downloads through a server-side proxy as `?url=`; (3) actionable CORS guidance in the web error message when no proxy is configured. Shipped `supabase/functions/recipe-proxy/` — a deployable Deno Edge Function relay (http(s)-only, SSRF host guard, 2 MiB cap, 15 s timeout, CORS-enabled) — deploy with `supabase functions deploy recipe-proxy` and pass its URL via the define. Tests: 7 new cases in `recipe_scraper_test.dart` (header maps, proxy wrapping/encoding, error propagation) — 22/22 scraper tests green. +- [x] Web drift assets fix (2026-10-16): every drift operation failed at runtime on `flutter run -d chrome` (`watchDoseEntries failed`, `meal plan refresh failed`, `recipe catalog watch failed`, …) because `web/` was missing the WASM database artifacts `connection/web.dart` documents as required. Added `web/sqlite3.wasm` (740 KB, downloaded from the `sqlite3-3.7.1` GitHub release — matches the locked `sqlite3` version; drift 2.35.x changelog mandates a 3.x wasm) and `web/drift_worker.js` (copied from the locked `drift-2.35.2` package). Both files are not gitignored, so they ship with the repo and the CI `flutter build web` step. Verified: `flutter build web --no-tree-shake-icons` packages both into `build/web/`. NOTE: bump both files when upgrading `drift`/`sqlite3` (worker JS must match the Dart package version exactly; wasm must be ≥ 3.x). +- [x] Android toolchain fixes (2026-10-16): `flutter run` on an emulator failed on three pre-existing gaps (Android builds had never been exercised — Phase 6 gates ran on Windows/Web): (1) `flutter_local_notifications` requires core library desugaring → enabled `isCoreLibraryDesugaringEnabled` + `com.android.tools:desugar_jdk_libs:2.1.4` in `android/app/build.gradle.kts`; (2) Kotlin incremental compilation aborts when the pub cache lives on a different drive than the project (C: vs D:, "this and base files have different roots") → `kotlin.incremental=false` in `android/gradle.properties`; (3) the pinned `health` plugin declares minSdk 26 vs. Flutter's default 24 → `minSdk = maxOf(flutter.minSdkVersion, 26)` (Health Connect only ships on Android 8.0+). Verified: `flutter build apk --debug` → `✓ Built build\app\outputs\flutter-apk\app-debug.apk`. +- [x] Mobile overflow fix (2026-10-16): the compound dropdown in `LogInjectionDialog` painted yellow/black stripes on phone widths — `DropdownButtonFormField`'s selected-item Row cannot ellipsize long labels ("Tirzepatide (Mounjaro / Zepbound)") without `isExpanded: true`. Fixed with `isExpanded: true` + `maxLines: 1`/`TextOverflow.ellipsis` on the menu items; regression tests in `test/features/medication/log_injection_dialog_test.dart` pump the dialog at 320 dp (both long labels) and 390 dp asserting no RenderFlex overflow (128/128 tests passing). + +## Active Verification Gates (Phase 7, 2026-10-16) +- [x] `dart analyze lib test` (Zero issues) +- [x] `flutter test` (all passing — 125/125) +- [x] `flutter run -d windows` — builds cleanly; all 7 rail destinations switch without overflow (integration tests assert this headlessly) + +## Phase 6 Verification Gates (2026-10-16, retained) - [x] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written) - [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`) - [x] `flutter test` (123/123 passing) diff --git a/README.md b/README.md index 252dc3f..fcce48c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,23 @@ dart run build_runner build --delete-conflicting-outputs # regenerate drift co flutter run -d windows # or: chrome / macos / android / ios ``` +### Web recipe imports (CORS proxy) + +Browsers cannot download recipe pages cross-origin — recipe sites never send +`Access-Control-Allow-Origin`, so the Recipe Vault's URL import needs a +server-side relay on web builds (desktop/mobile import directly and need +nothing extra). The repository ships one as a Supabase Edge Function: + +```bash +supabase functions deploy recipe-proxy + +flutter run -d chrome \ + --dart-define=RECIPE_PROXY_URL=https://.supabase.co/functions/v1/recipe-proxy +``` + +Without the define, in-browser imports surface an explanatory CORS error +instead of a raw client exception. + ### Verify ```bash diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db9f485..16aa914 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,10 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + // flutter_local_notifications schedules via java.time, which is only + // in the platform from API 26; core library desugaring backports it + // for minSdk devices (required by the plugin's AAR metadata). + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -19,7 +23,11 @@ android { applicationId = "com.glpcompanion.glpcompanion" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + // + // API 26 (Android 8.0) floor required by the `health` plugin (Health + // Connect / HealthKit bridge), which declares minSdk 26 in its + // manifest; Health Connect itself only ships on Android 8.0+. + minSdk = maxOf(flutter.minSdkVersion, 26) targetSdk = flutter.targetSdkVersion // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) @@ -47,3 +55,7 @@ kotlin { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..dcf53be 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -4,3 +4,8 @@ android.useAndroidX=true android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false +# Kotlin's incremental compiler cannot relativize plugin sources that live on +# a different drive than the project (pub cache on C:, project on D:) and +# aborts the daemon with "this and base files have different roots". Disable +# incremental Kotlin compilation until the caches share a drive. +kotlin.incremental=false diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index be49729..662e8ed 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -7,10 +7,13 @@ import '../../../core/network/supabase_status.dart'; import '../../features/auth/presentation/controllers/auth_controller.dart'; import '../../features/auth/presentation/controllers/dev_auth_bypass.dart'; 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/meal_planner/presentation/screens/meal_planner_screen.dart'; import '../../features/pantry/presentation/screens/pantry_screen.dart'; +import '../../features/recipes/presentation/screens/recipes_screen.dart'; +import '../../features/settings/presentation/screens/settings_screen.dart'; import '../scaffold/adaptive_home_scaffold.dart'; /// Locations that may be visited without an authenticated session. @@ -63,14 +66,26 @@ final routerProvider = Provider((ref) { path: '/nutrition', builder: (context, state) => const NutritionScreen(), ), + GoRoute( + path: '/exercise', + builder: (context, state) => const ExerciseScreen(), + ), GoRoute( path: '/meal-planner', builder: (context, state) => const MealPlannerScreen(), ), + GoRoute( + path: '/recipes', + builder: (context, state) => const RecipesScreen(), + ), GoRoute( path: '/pantry', builder: (context, state) => const PantryScreen(), ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + ), ], ), ], diff --git a/lib/app/scaffold/adaptive_home_scaffold.dart b/lib/app/scaffold/adaptive_home_scaffold.dart index 806fc69..a53d9d2 100644 --- a/lib/app/scaffold/adaptive_home_scaffold.dart +++ b/lib/app/scaffold/adaptive_home_scaffold.dart @@ -5,6 +5,16 @@ import 'package:go_router/go_router.dart'; import '../../features/auth/presentation/controllers/dev_auth_bypass.dart'; +/// Adaptive application shell. +/// +/// * Desktop / tablet (>= 1024 dp): a seven-destination [NavigationRail] +/// (Medication, Nutrition, Exercise, Meal Plan, Recipes, Pantry, +/// Settings) with `labelType` set to `all`; the rail is wrapped in a +/// scrollable constrained to the available height so compact vertical +/// windows never overflow. +/// * Mobile: the four core daily actions live in the bottom +/// [NavigationBar]; secondary destinations (Exercise, Recipes, Settings) +/// are routed through an adaptive drawer opened from the "More" tab. class AdaptiveHomeScaffold extends ConsumerWidget { final Widget child; @@ -13,16 +23,77 @@ class AdaptiveHomeScaffold extends ConsumerWidget { required this.child, }); - int _calculateSelectedIndex(BuildContext context) { - final location = GoRouterState.of(context).uri.toString(); + /// Key for the mobile scaffold so the "More" bottom-bar tab can open the + /// secondary-destinations drawer (the build context cannot reach a + /// Scaffold constructed in the same method via [Scaffold.of]). + /// + /// Static: the shell is a singleton route scaffold. + static final GlobalKey _mobileScaffoldKey = + GlobalKey(); + + /// Desktop rail destinations in navigation order. + static const _railDestinations = [ + NavigationRailDestination( + icon: Icon(Icons.medication_liquid_outlined), + selectedIcon: Icon(Icons.medication_liquid), + label: Text('Medication'), + ), + NavigationRailDestination( + icon: Icon(Icons.restaurant_outlined), + selectedIcon: Icon(Icons.restaurant), + label: Text('Nutrition'), + ), + NavigationRailDestination( + icon: Icon(Icons.fitness_center_outlined), + selectedIcon: Icon(Icons.fitness_center), + label: Text('Exercise'), + ), + NavigationRailDestination( + icon: Icon(Icons.calendar_month_outlined), + selectedIcon: Icon(Icons.calendar_month), + label: Text('Meal Plan'), + ), + NavigationRailDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: Text('Recipes'), + ), + NavigationRailDestination( + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), + label: Text('Pantry'), + ), + NavigationRailDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: Text('Settings'), + ), + ]; + + /// Maps a location to its desktop rail index (0..6); secondary routes + /// that share a rail slot resolve to that slot. + int _railIndexFor(String location) { + if (location.startsWith('/medication')) return 0; + if (location.startsWith('/nutrition')) return 1; + if (location.startsWith('/exercise')) return 2; + if (location.startsWith('/meal-planner')) return 3; + if (location.startsWith('/recipes')) return 4; + if (location.startsWith('/pantry')) return 5; + if (location.startsWith('/settings')) return 6; + return 0; + } + + /// Maps a location to its bottom-bar index; secondary destinations + /// (exercise / recipes / settings) highlight the "More" tab. + int _bottomIndexFor(String location) { if (location.startsWith('/medication')) return 0; if (location.startsWith('/nutrition')) return 1; if (location.startsWith('/meal-planner')) return 2; if (location.startsWith('/pantry')) return 3; - return 0; + return 4; // exercise, recipes, settings -> More } - void _onItemTapped(int index, BuildContext context) { + void _onRailSelected(int index, BuildContext context) { switch (index) { case 0: context.go('/medication'); @@ -31,11 +102,20 @@ class AdaptiveHomeScaffold extends ConsumerWidget { context.go('/nutrition'); break; case 2: - context.go('/meal-planner'); + context.go('/exercise'); break; case 3: + context.go('/meal-planner'); + break; + case 4: + context.go('/recipes'); + break; + case 5: context.go('/pantry'); break; + case 6: + context.go('/settings'); + break; } } @@ -43,7 +123,7 @@ class AdaptiveHomeScaffold extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final width = MediaQuery.of(context).size.width; final isDesktop = width >= 1024; - final selectedIndex = _calculateSelectedIndex(context); + final location = GoRouterState.of(context).uri.toString(); // Visual marker while the debug auth bypass is active, so a review // session can never be mistaken for a signed-in one. final showDevBanner = kDebugMode && ref.watch(devAuthBypassProvider); @@ -56,33 +136,25 @@ class AdaptiveHomeScaffold extends ConsumerWidget { Expanded( child: Row( children: [ - NavigationRail( - selectedIndex: selectedIndex, - onDestinationSelected: (idx) => - _onItemTapped(idx, context), - labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( - icon: Icon(Icons.medication_outlined), - selectedIcon: Icon(Icons.medication), - label: Text('Medication'), + // Wrap the rail in a height-constrained scroll view so a + // compact vertical window (or browser zoom) scrolls the + // destinations instead of overflowing the render flex. + LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: NavigationRail( + selectedIndex: _railIndexFor(location), + onDestinationSelected: (idx) => + _onRailSelected(idx, context), + labelType: NavigationRailLabelType.all, + destinations: _railDestinations, + ), + ), ), - NavigationRailDestination( - icon: Icon(Icons.restaurant_outlined), - selectedIcon: Icon(Icons.restaurant), - label: Text('Nutrition'), - ), - NavigationRailDestination( - icon: Icon(Icons.calendar_month_outlined), - selectedIcon: Icon(Icons.calendar_month), - label: Text('Meal Plan'), - ), - NavigationRailDestination( - icon: Icon(Icons.kitchen_outlined), - selectedIcon: Icon(Icons.kitchen), - label: Text('Pantry'), - ), - ], + ), ), const VerticalDivider(thickness: 1, width: 1), Expanded(child: child), @@ -94,20 +166,44 @@ class AdaptiveHomeScaffold extends ConsumerWidget { ); } + final selectedIndex = _bottomIndexFor(location); + return Scaffold( + key: _mobileScaffoldKey, body: Column( children: [ if (showDevBanner) const _DevAccessBanner(), Expanded(child: child), ], ), + drawer: const _SecondaryDestinationsDrawer(), bottomNavigationBar: NavigationBar( selectedIndex: selectedIndex, - onDestinationSelected: (idx) => _onItemTapped(idx, context), + onDestinationSelected: (index) { + if (index == 4) { + // "More": open the drawer with secondary destinations. + _mobileScaffoldKey.currentState?.openDrawer(); + return; + } + switch (index) { + case 0: + context.go('/medication'); + break; + case 1: + context.go('/nutrition'); + break; + case 2: + context.go('/meal-planner'); + break; + case 3: + context.go('/pantry'); + break; + } + }, destinations: const [ NavigationDestination( - icon: Icon(Icons.medication_outlined), - selectedIcon: Icon(Icons.medication), + icon: Icon(Icons.medication_liquid_outlined), + selectedIcon: Icon(Icons.medication_liquid), label: 'Meds', ), NavigationDestination( @@ -121,16 +217,80 @@ class AdaptiveHomeScaffold extends ConsumerWidget { label: 'Plan', ), NavigationDestination( - icon: Icon(Icons.kitchen_outlined), - selectedIcon: Icon(Icons.kitchen), + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), label: 'Pantry', ), + NavigationDestination( + icon: Icon(Icons.more_horiz), + selectedIcon: Icon(Icons.more_horiz), + label: 'More', + ), ], ), ); } } +/// Mobile drawer holding the secondary destinations that do not fit the +/// four-slot bottom bar (Exercise, Recipes, Settings). +class _SecondaryDestinationsDrawer extends StatelessWidget { + const _SecondaryDestinationsDrawer(); + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).uri.toString(); + + return Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'More', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.fitness_center_outlined), + title: const Text('Exercise'), + selected: location.startsWith('/exercise'), + onTap: () { + Navigator.of(context).pop(); + context.go('/exercise'); + }, + ), + ListTile( + leading: const Icon(Icons.menu_book_outlined), + title: const Text('Recipes'), + selected: location.startsWith('/recipes'), + onTap: () { + Navigator.of(context).pop(); + context.go('/recipes'); + }, + ), + ListTile( + leading: const Icon(Icons.settings_outlined), + title: const Text('Settings'), + selected: location.startsWith('/settings'), + onTap: () { + Navigator.of(context).pop(); + context.go('/settings'); + }, + ), + ], + ), + ), + ); + } +} + /// Slim status strip shown while the development auth bypass is active. class _DevAccessBanner extends StatelessWidget { const _DevAccessBanner(); diff --git a/lib/features/exercise/data/repositories/in_memory_exercise_repository.dart b/lib/features/exercise/data/repositories/in_memory_exercise_repository.dart new file mode 100644 index 0000000..6afc95e --- /dev/null +++ b/lib/features/exercise/data/repositories/in_memory_exercise_repository.dart @@ -0,0 +1,32 @@ +import '../../domain/entities/exercise_entry.dart'; +import '../../domain/repositories/exercise_repository.dart'; + +/// Session-scoped in-memory implementation of [IExerciseRepository]. +/// +/// Phase 7 scaffolding: the resistance log is a new bounded context and ships +/// with an in-memory store so the feature is fully functional on every +/// platform without a schema migration. Swapping in a drift-backed +/// repository (SQLCipher on native targets) only requires re-wiring the +/// `exerciseRepositoryProvider` — no presentation or domain changes. +class InMemoryExerciseRepository implements IExerciseRepository { + final List _entries = []; + + @override + Future> getEntries({DateTime? from, DateTime? to}) async { + return _entries.where((entry) { + if (from != null && entry.timestamp.isBefore(from)) return false; + if (to != null && !entry.timestamp.isBefore(to)) return false; + return true; + }).toList(growable: false); + } + + @override + Future saveEntry(ExerciseEntry entry) async { + _entries.add(entry); + } + + @override + Future deleteEntry(String id) async { + _entries.removeWhere((entry) => entry.id == id); + } +} diff --git a/lib/features/exercise/domain/entities/exercise_entry.dart b/lib/features/exercise/domain/entities/exercise_entry.dart new file mode 100644 index 0000000..5594d9e --- /dev/null +++ b/lib/features/exercise/domain/entities/exercise_entry.dart @@ -0,0 +1,61 @@ +import 'muscle_group.dart'; + +/// One logged resistance-training working set group: the exercise performed, +/// the muscle group it targeted, and the volume/LOAD prescription. +/// +/// Pure Dart (no framework imports) so the entity can be shared by the +/// repository contract and the Riverpod presentation layer without leaking +/// infrastructure concerns into the domain. +class ExerciseEntry { + const ExerciseEntry({ + required this.id, + required this.timestamp, + required this.muscleGroup, + required this.exerciseName, + required this.sets, + required this.reps, + this.weightLbs = 0, + }); + + /// UUID primary key assigned when the entry is first materialized. + final String id; + + /// When the working set was performed. + final DateTime timestamp; + + /// Muscle group trained (drives grouping and weekly frequency rollups). + final MuscleGroup muscleGroup; + + /// Free-text exercise name (e.g. "Back Squat", "Lat Pulldown"). + final String exerciseName; + + /// Number of working sets performed. + final int sets; + + /// Repetitions per set. + final int reps; + + /// Load moved per set in pounds; `0` for bodyweight work. + final double weightLbs; + + /// Human-readable prescription, e.g. `3 × 8 @ 135 lb` or `3 × 12` + /// for bodyweight work. + String get volumeLabel { + final load = weightLbs > 0 ? ' @ ${formatWeight(weightLbs)} lb' : ''; + return '$sets × $reps$load'; + } + + /// Formats a weight: whole numbers drop the decimal (`135`), fractional + /// loads keep one digit (`137.5`). + static String formatWeight(double weightLbs) { + final rounded = weightLbs.roundToDouble(); + return rounded == weightLbs + ? rounded.toStringAsFixed(0) + : weightLbs.toStringAsFixed(1); + } + + @override + String toString() => + 'ExerciseEntry($id, ${exerciseName.trim()}, ${muscleGroup.name}, ' + '$volumeLabel @ $timestamp)'; +} diff --git a/lib/features/exercise/domain/entities/muscle_group.dart b/lib/features/exercise/domain/entities/muscle_group.dart new file mode 100644 index 0000000..7851ae9 --- /dev/null +++ b/lib/features/exercise/domain/entities/muscle_group.dart @@ -0,0 +1,43 @@ +/// Muscle groups tracked by the resistance-training log. +/// +/// Pure Dart (no framework imports) so the enum can be shared by the domain +/// entity, the future drift-backed repository (persisted via +/// [persistenceKey]), and the presentation layer. +enum MuscleGroup { + chest, + back, + shoulders, + legs, + arms, + core; + + /// Stable string persisted in the database; never changes across renames. + String get persistenceKey => name; + + /// Human-readable label for dialogs and log grouping headers. + String get displayName { + switch (this) { + case MuscleGroup.chest: + return 'Chest'; + case MuscleGroup.back: + return 'Back'; + case MuscleGroup.shoulders: + return 'Shoulders'; + case MuscleGroup.legs: + return 'Legs'; + case MuscleGroup.arms: + return 'Arms'; + case MuscleGroup.core: + return 'Core'; + } + } + + /// Parses a persisted key; returns `null` for unknown/legacy values. + static MuscleGroup? tryParse(String? raw) { + if (raw == null) return null; + for (final group in values) { + if (group.persistenceKey == raw) return group; + } + return null; + } +} diff --git a/lib/features/exercise/domain/repositories/exercise_repository.dart b/lib/features/exercise/domain/repositories/exercise_repository.dart new file mode 100644 index 0000000..a9a12a4 --- /dev/null +++ b/lib/features/exercise/domain/repositories/exercise_repository.dart @@ -0,0 +1,19 @@ +import '../entities/exercise_entry.dart'; + +/// Persistence contract for the resistance-training log. +/// +/// Pure Dart (no framework imports). The current implementation is an +/// in-memory session store; a drift-backed repository (SQLCipher on native +/// platforms) will implement the same contract without touching the +/// presentation layer. +abstract class IExerciseRepository { + /// All logged entries, newest first, optionally restricted to the + /// half-open interval `[from, to)`. + Future> getEntries({DateTime? from, DateTime? to}); + + /// Inserts a new entry. + Future saveEntry(ExerciseEntry entry); + + /// Removes the entry with [id], if present. + Future deleteEntry(String id); +} diff --git a/lib/features/exercise/presentation/controllers/exercise_controller.dart b/lib/features/exercise/presentation/controllers/exercise_controller.dart new file mode 100644 index 0000000..5c9d6d8 --- /dev/null +++ b/lib/features/exercise/presentation/controllers/exercise_controller.dart @@ -0,0 +1,178 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/utils/date_time_utils.dart'; +import '../../../../core/utils/uuid.dart'; +import '../../data/repositories/in_memory_exercise_repository.dart'; +import '../../domain/entities/exercise_entry.dart'; +import '../../domain/entities/muscle_group.dart'; +import '../../domain/repositories/exercise_repository.dart'; + +/// Application-wide exercise repository. +/// +/// Overridable in widget/unit tests with a fake implementation. +final exerciseRepositoryProvider = Provider( + (ref) => InMemoryExerciseRepository(), +); + +/// Immutable snapshot of the resistance-training log. +class ExerciseState { + const ExerciseState({ + required this.entries, + required this.weekStart, + this.errorMessage, + }); + + /// All logged entries, newest first. + final List entries; + + /// First day (Monday) of the current ISO week at local start-of-day. + final DateTime weekStart; + + /// Last persistence failure message, cleared on the next success. + final String? errorMessage; + + /// The seven days of the current window, Monday through Sunday. + List get weekDays => List.unmodifiable( + List.generate( + 7, + (index) => DateTime( + weekStart.year, + weekStart.month, + weekStart.day + index, + ), + ), + ); + + /// Entries logged inside the current ISO week, newest first. + List get entriesThisWeek => entries + .where( + (entry) => + !entry.timestamp.isBefore(weekStart) && + entry.timestamp.isBefore(weekStart.add(const Duration(days: 7))), + ) + .toList(growable: false); + + /// Number of distinct calendar days with at least one logged session in + /// the current week — the weekly strength-workout frequency used for the + /// lean-mass-preservation card. + int get weeklyWorkoutCount { + final days = {}; + for (final entry in entriesThisWeek) { + days.add(startOfDay(entry.timestamp)); + } + return days.length; + } + + ExerciseState copyWith({ + List? entries, + DateTime? weekStart, + Object? errorMessage = _keep, + }) => + ExerciseState( + entries: entries ?? this.entries, + weekStart: weekStart ?? this.weekStart, + errorMessage: identical(errorMessage, _keep) + ? this.errorMessage + : errorMessage as String?, + ); + + /// Sentinel allowing `copyWith` to explicitly reset a field to `null`. + static const Object _keep = Object(); +} + +/// Orchestrates the resistance-training log: persists working sets, derives +/// the weekly strength-session frequency (target: 3 sessions/week for lean +/// mass preservation during GLP-1 therapy), and exposes delete/error flows. +class ExerciseController extends Notifier { + /// Recommended weekly strength-session frequency for lean mass + /// preservation on GLP-1/GIP agonist therapy. + static const int recommendedWeeklySessions = 3; + + @override + ExerciseState build() => ExerciseState( + entries: const [], + weekStart: startOfWeek(DateTime.now()), + ); + + /// Persists a working set and refreshes the snapshot. + /// + /// Returns whether the write succeeded. + Future logExercise({ + required MuscleGroup muscleGroup, + required String exerciseName, + required int sets, + required int reps, + double weightLbs = 0, + DateTime? timestamp, + }) async { + final trimmed = exerciseName.trim(); + if (trimmed.isEmpty || sets <= 0 || reps <= 0 || weightLbs < 0) { + state = state.copyWith(errorMessage: 'Check the entered values.'); + return false; + } + + try { + await ref.read(exerciseRepositoryProvider).saveEntry( + ExerciseEntry( + id: generateUuid(), + timestamp: timestamp ?? DateTime.now(), + muscleGroup: muscleGroup, + exerciseName: trimmed, + sets: sets, + reps: reps, + weightLbs: weightLbs, + ), + ); + await _refresh(); + return true; + } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: 'saveEntry failed'); + state = state.copyWith(errorMessage: 'Could not log the exercise.'); + return false; + } + } + + /// Removes an entry by id. + Future deleteEntry(String id) async { + try { + await ref.read(exerciseRepositoryProvider).deleteEntry(id); + await _refresh(); + return true; + } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: 'deleteEntry failed'); + state = state.copyWith(errorMessage: 'Could not delete the entry.'); + return false; + } + } + + /// Clears the transient error banner. + void clearError() { + if (state.errorMessage != null) { + state = state.copyWith(errorMessage: null); + } + } + + /// Reloads the full log, rolling the week window forward when the ISO week + /// changed since the last snapshot. + Future _refresh() async { + try { + final entries = + await ref.read(exerciseRepositoryProvider).getEntries(); + state = state.copyWith( + entries: entries, + weekStart: startOfWeek(DateTime.now()), + errorMessage: null, + ); + } catch (error, stackTrace) { + debugPrintStack(stackTrace: stackTrace, label: 'exercise refresh failed'); + state = state.copyWith(errorMessage: 'Could not load the exercise log.'); + } + } +} + +/// Exposes the [ExerciseController] to the widget tree. +final exerciseControllerProvider = + NotifierProvider( + ExerciseController.new, +); diff --git a/lib/features/exercise/presentation/screens/exercise_screen.dart b/lib/features/exercise/presentation/screens/exercise_screen.dart new file mode 100644 index 0000000..117cc74 --- /dev/null +++ b/lib/features/exercise/presentation/screens/exercise_screen.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/utils/date_time_utils.dart'; +import '../../domain/entities/exercise_entry.dart'; +import '../controllers/exercise_controller.dart'; +import '../widgets/log_exercise_dialog.dart'; + +/// Resistance-training log for lean mass preservation during GLP-1 therapy. +/// +/// The screen pairs a weekly strength-workout frequency card (sessions +/// completed vs. the recommended three per week) with the full working-set +/// log, grouped by day, plus an interactive "log exercise" dialog. +class ExerciseScreen extends ConsumerWidget { + const ExerciseScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(exerciseControllerProvider); + + ref.listen(exerciseControllerProvider, (previous, next) { + if (next.errorMessage != null && + next.errorMessage != previous?.errorMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next.errorMessage!), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + ref.read(exerciseControllerProvider.notifier).clearError(); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Exercise Log')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), + children: [ + _WeeklyFrequencyCard( + sessionsThisWeek: state.weeklyWorkoutCount, + targetSessions: ExerciseController.recommendedWeeklySessions, + ), + const SizedBox(height: 16), + Text( + 'Working Sets', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (state.entries.isEmpty) + const _EmptyLogHint() + else + ..._buildDayGroups(context, state), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showLogDialog(context), + icon: const Icon(Icons.add), + label: const Text('Log Exercise'), + ), + ); + } + + /// Groups the log by calendar day (newest day first, newest set first). + List _buildDayGroups(BuildContext context, ExerciseState state) { + final byDay = >{}; + for (final entry in state.entries) { + byDay.putIfAbsent(startOfDay(entry.timestamp), () => []).add(entry); + } + final days = byDay.keys.toList()..sort((a, b) => b.compareTo(a)); + + return [ + for (final day in days) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Text( + formatMonthDay(day), + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ), + for (final entry in byDay[day]!) _ExerciseTile(entry: entry), + ], + ]; + } + + Future _showLogDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const LogExerciseDialog(), + ); + } +} + +/// Card surfacing weekly strength-workout frequency against the lean-mass +/// preservation recommendation (three resistance sessions per week). +class _WeeklyFrequencyCard extends StatelessWidget { + const _WeeklyFrequencyCard({ + required this.sessionsThisWeek, + required this.targetSessions, + }); + + final int sessionsThisWeek; + final int targetSessions; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final progress = + (sessionsThisWeek / targetSessions).clamp(0.0, 1.0).toDouble(); + final goalMet = sessionsThisWeek >= targetSessions; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.fitness_center, + color: goalMet + ? Colors.green.shade700 + : theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Weekly Strength Frequency', + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (goalMet) + Chip( + visualDensity: VisualDensity.compact, + backgroundColor: Colors.green.shade100, + label: Text( + 'Goal met', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.green.shade800, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '$sessionsThisWeek of $targetSessions sessions this week', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + ), + const SizedBox(height: 8), + Text( + 'Resistance training preserves lean mass and offsets ' + 'GLP-1-driven muscle loss — aim for $targetSessions full-body ' + 'or split sessions per week.', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.outline), + ), + ], + ), + ), + ); + } +} + +/// One logged working-set row with a swipe-to-delete affordance. +class _ExerciseTile extends ConsumerWidget { + const _ExerciseTile({required this.entry}); + + final ExerciseEntry entry; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Dismissible( + key: ValueKey(entry.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + color: theme.colorScheme.errorContainer, + child: Icon(Icons.delete_outline, color: theme.colorScheme.error), + ), + onDismissed: (_) => ref + .read(exerciseControllerProvider.notifier) + .deleteEntry(entry.id), + child: Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.secondaryContainer, + child: Icon( + Icons.fitness_center, + size: 20, + color: theme.colorScheme.onSecondaryContainer, + ), + ), + title: Text( + entry.exerciseName, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + '${entry.muscleGroup.displayName} · ${entry.volumeLabel}', + ), + trailing: Text( + formatDateTime(entry.timestamp), + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.outline), + ), + ), + ), + ); + } +} + +/// Placeholder shown while the log has no entries. +class _EmptyLogHint extends StatelessWidget { + const _EmptyLogHint(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Icon(Icons.fitness_center_outlined, + size: 40, color: theme.colorScheme.outline), + const SizedBox(height: 8), + Text( + 'No sessions logged yet.\nTap "Log Exercise" to record your ' + 'first working set.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + ); + } +} diff --git a/lib/features/exercise/presentation/widgets/log_exercise_dialog.dart b/lib/features/exercise/presentation/widgets/log_exercise_dialog.dart new file mode 100644 index 0000000..4a60492 --- /dev/null +++ b/lib/features/exercise/presentation/widgets/log_exercise_dialog.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/entities/muscle_group.dart'; +import '../controllers/exercise_controller.dart'; + +/// Modal dialog for logging a resistance-training working set. +class LogExerciseDialog extends ConsumerStatefulWidget { + const LogExerciseDialog({super.key}); + + @override + ConsumerState createState() => _LogExerciseDialogState(); +} + +class _LogExerciseDialogState extends ConsumerState { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _setsController = + TextEditingController(text: '3'); + final TextEditingController _repsController = + TextEditingController(text: '8'); + final TextEditingController _weightController = TextEditingController(); + + MuscleGroup _muscleGroup = MuscleGroup.chest; + bool _isSaving = false; + + @override + void dispose() { + _nameController.dispose(); + _setsController.dispose(); + _repsController.dispose(); + _weightController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Log Exercise'), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _muscleGroup, + decoration: const InputDecoration( + labelText: 'Muscle group', + border: OutlineInputBorder(), + ), + items: [ + for (final group in MuscleGroup.values) + DropdownMenuItem( + value: group, + child: Text(group.displayName), + ), + ], + onChanged: (group) => setState( + () => _muscleGroup = group ?? _muscleGroup, + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Exercise', + hintText: 'e.g. Back Squat', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.words, + validator: (value) => (value == null || value.trim().isEmpty) + ? 'Enter an exercise name' + : null, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _setsController, + decoration: const InputDecoration( + labelText: 'Sets', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + validator: _positiveIntValidator, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _repsController, + decoration: const InputDecoration( + labelText: 'Reps', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + validator: _positiveIntValidator, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _weightController, + decoration: const InputDecoration( + labelText: 'Weight (lb)', + hintText: '0', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return null; // bodyweight work + } + final parsed = double.tryParse(value.trim()); + if (parsed == null || parsed < 0) return 'Invalid'; + return null; + }, + ), + ), + ], + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: const Text('Log Set'), + ), + ], + ); + } + + String? _positiveIntValidator(String? value) { + final parsed = int.tryParse((value ?? '').trim()); + if (parsed == null || parsed <= 0) return 'Must be > 0'; + return null; + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _isSaving = true); + final saved = + await ref.read(exerciseControllerProvider.notifier).logExercise( + muscleGroup: _muscleGroup, + exerciseName: _nameController.text, + sets: int.parse(_setsController.text.trim()), + reps: int.parse(_repsController.text.trim()), + weightLbs: _weightController.text.trim().isEmpty + ? 0 + : double.parse(_weightController.text.trim()), + ); + if (!mounted) return; + if (saved) { + Navigator.of(context).pop(); + } else { + setState(() => _isSaving = false); + } + } +} diff --git a/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart b/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart index 4abd164..2fdac5b 100644 --- a/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart +++ b/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart @@ -15,6 +15,41 @@ import '../entities/recipe_ingredient.dart'; /// without touching the network. typedef RecipeHtmlFetcher = Future Function(Uri url); +/// Optional CORS proxy for in-browser imports, configured at build/run time: +/// +/// ```sh +/// flutter run -d chrome \ +/// --dart-define=RECIPE_PROXY_URL=https://.supabase.co/functions/v1/recipe-proxy +/// ``` +/// +/// When set, downloads are routed as `?url=`. +/// Browsers cannot fetch recipe pages cross-origin (the sites do not send +/// `Access-Control-Allow-Origin`), so web builds need a server-side proxy — +/// the repository ships one as a Supabase Edge Function in +/// `supabase/functions/recipe-proxy/`. Desktop and mobile builds talk to the +/// sites directly and ignore this define. +const String recipeProxyUrl = String.fromEnvironment('RECIPE_PROXY_URL'); + +/// Whether this binary was compiled for the web (`dart:js_interop` is +/// present only in web compilations). Compile-time constant, so the check +/// costs nothing and tree-shakes the dead branch on native targets. +const bool isWebCompilation = + bool.fromEnvironment('dart.library.js_interop'); + +/// Resolves the URL actually requested for [target], wrapping it in the +/// configured CORS proxy when one is set. +/// +/// Exposed as a top-level function so unit tests can verify the wrapping +/// without a proxy define. +Uri resolveScrapeRequestUri(Uri target, {String? proxyUrl}) { + final proxy = (proxyUrl ?? recipeProxyUrl).trim(); + if (proxy.isEmpty) return target; + final separator = proxy.contains('?') ? '&' : '?'; + return Uri.parse( + '$proxy${separator}url=${Uri.encodeComponent(target.toString())}', + ); +} + /// Raised when a URL cannot be fetched or carries no recognizable recipe /// metadata (neither schema.org/JSON-LD nor OpenGraph). class RecipeScrapeException implements Exception { @@ -30,7 +65,11 @@ class RecipeScrapeException implements Exception { /// Imports a recipe from a web page. /// /// Pipeline (mirrors the Phase 4 architecture spec): -/// 1. Download the raw HTML via `package:http` (injectable fetcher). +/// 1. Download the raw HTML via `package:http` (injectable fetcher) with +/// browser-like headers — recipe sites (Allrecipes/Dotdash et al.) +/// answer 403 to clients that do not look like a browser. On the web +/// the request is routed through the optional [recipeProxyUrl] because +/// cross-origin fetches are blocked by CORS. /// 2. Parse the DOM with `package:html`. /// 3. Extract every `