feat(app,exercise,recipes,settings): complete Phase 7 navigation and domain expansion

- Exercise feature (lean-mass preservation): MuscleGroup enum + ExerciseEntry
  entity, IExerciseRepository contract with session-scoped in-memory
  implementation, ExerciseController with weekly distinct-session rollup vs.
  the recommended 3 sessions/week target, ExerciseScreen (weekly strength
  frequency card, day-grouped working-set log with swipe-to-delete) and the
  LogExerciseDialog (muscle group / name / sets / reps / weight).
- Recipe Vault: new features/recipes screen with pinned URL import bar wired
  to RecipeImportController, search field + macro filter chips (Protein >
  30g / > 20g / Under 500 kcal / All), responsive GridView.builder
  (SliverGridDelegateWithMaxCrossAxisExtent) with chunked infinite scroll,
  read-only details bottom sheet, and delete confirmation.
- Meal planner refactor: strip the screen to week navigation + the 7-day
  calendar (Mon-Sun) with daily protein rollups; replace the inline import
  bar, SavedRecipesTray, and AssignSlotDialog with the searchable
  AssignRecipeSheet modal bottom sheet (Recipe Vault search + custom-entry
  fallback); empty slots render a "+ Assign Recipe" affordance; drop the
  drag-and-drop tray plumbing.
- Settings: new features/settings screen with Identity (email, Supabase
  status badge, sign-out), Targets (daily protein/water inputs feeding the
  new NutritionController.updateTargets), Security & Storage (SQLCipher
  AES-256 badge with web-degradation note), and Integrations (Apple Health /
  Health Connect / HealthGateway toggles via IntegrationsController through
  the IHealthGateway boundary).
- Shell: register /exercise, /recipes, /settings routes; expand the desktop
  NavigationRail to the 7 specified destinations (medication_liquid,
  restaurant, fitness_center, calendar_month, menu_book, inventory_2,
  settings) with labelType.all inside a height-constrained scroll view;
  mobile keeps 4 core bottom-bar tabs plus a "More" tab opening the
  secondary-destinations drawer.
- Android toolchain fixes (first-ever Android build): enable core library
  desugaring + desugar_jdk_libs 2.1.4 for flutter_local_notifications, set
  kotlin.incremental=false (pub cache on C: vs project on D: breaks the
  incremental compiler), and raise minSdk to maxOf(flutter, 26) for the
  health plugin.
- Mobile overflow fix: LogInjectionDialog compound dropdown gains
  isExpanded + ellipsis so long labels ("Tirzepatide (Mounjaro /
  Zepbound)") no longer paint stripes at phone widths.
- Web drift assets: commit web/sqlite3.wasm (sqlite3-3.7.1 release, matching
  the locked package) and web/drift_worker.js (drift-2.35.2) so the WASM
  database opens at runtime; both were missing and every drift operation
  failed in Chrome.
- Recipe import hardening: browser-like Accept/Accept-Language/User-Agent
  headers on native (403 anti-bot bypass; UA stripped on web), optional
  --dart-define=RECIPE_PROXY_URL routing downloads through a server-side
  proxy, actionable CORS guidance in the web error path, and a deployable
  Supabase Edge Function relay (supabase/functions/recipe-proxy) with
  http(s)-only validation, SSRF host guard, 2 MiB cap, and 15 s timeout.
- Tests: 123 -> 135 (exercise controller/screen, log-injection dialog
  320dp/390dp overflow regressions, scraper header/proxy/error-path
  hardening, meal planner calendar-only rewrite); navigation integration
  tests now cover all 7 rail routes and the mobile More drawer.
- Docs: README web recipe-import (CORS proxy) section; BUILD_STATE.md
  Phase 7 deliverables, Post-Phase-7 additions, and checklist updated.

Verified: dart analyze lib test clean, flutter test 135/135,
flutter build windows --debug, flutter build apk --debug, and
flutter build web --no-tree-shake-icons all succeed.
This commit is contained in:
Mike Kell 2026-10-16 19:17:20 -04:00
parent 51364a8f4f
commit 837b4434f0
31 changed files with 16681 additions and 738 deletions

View File

@ -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=<url>` that routes downloads through a server-side proxy as `<proxy>?url=<encoded>`; (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)

View File

@ -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://<project>.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

View File

@ -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")
}

View File

@ -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

View File

@ -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<GoRouter>((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(),
),
],
),
],

View File

@ -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<ScaffoldState> _mobileScaffoldKey =
GlobalKey<ScaffoldState>();
/// 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();

View File

@ -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<ExerciseEntry> _entries = [];
@override
Future<List<ExerciseEntry>> 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<void> saveEntry(ExerciseEntry entry) async {
_entries.add(entry);
}
@override
Future<void> deleteEntry(String id) async {
_entries.removeWhere((entry) => entry.id == id);
}
}

View File

@ -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)';
}

View File

@ -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;
}
}

View File

@ -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<List<ExerciseEntry>> getEntries({DateTime? from, DateTime? to});
/// Inserts a new entry.
Future<void> saveEntry(ExerciseEntry entry);
/// Removes the entry with [id], if present.
Future<void> deleteEntry(String id);
}

View File

@ -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<IExerciseRepository>(
(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<ExerciseEntry> 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<DateTime> get weekDays => List<DateTime>.unmodifiable(
List.generate(
7,
(index) => DateTime(
weekStart.year,
weekStart.month,
weekStart.day + index,
),
),
);
/// Entries logged inside the current ISO week, newest first.
List<ExerciseEntry> 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 = <DateTime>{};
for (final entry in entriesThisWeek) {
days.add(startOfDay(entry.timestamp));
}
return days.length;
}
ExerciseState copyWith({
List<ExerciseEntry>? 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<ExerciseState> {
/// 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<bool> 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<bool> 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<void> _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, ExerciseState>(
ExerciseController.new,
);

View File

@ -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<Widget> _buildDayGroups(BuildContext context, ExerciseState state) {
final byDay = <DateTime, List<ExerciseEntry>>{};
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<void> _showLogDialog(BuildContext context) {
return showDialog<void>(
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),
),
],
),
);
}
}

View File

@ -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<LogExerciseDialog> createState() => _LogExerciseDialogState();
}
class _LogExerciseDialogState extends ConsumerState<LogExerciseDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
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<MuscleGroup>(
initialValue: _muscleGroup,
decoration: const InputDecoration(
labelText: 'Muscle group',
border: OutlineInputBorder(),
),
items: [
for (final group in MuscleGroup.values)
DropdownMenuItem<MuscleGroup>(
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<void> _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);
}
}
}

View File

@ -15,6 +15,41 @@ import '../entities/recipe_ingredient.dart';
/// without touching the network.
typedef RecipeHtmlFetcher = Future<String> 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://<project>.supabase.co/functions/v1/recipe-proxy
/// ```
///
/// When set, downloads are routed as `<proxy>?url=<encoded recipe 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 `<script type="application/ld+json">` node and locate a
/// `"@type": "Recipe"` object — searched at the document root, inside
@ -52,11 +91,52 @@ class ScrapeRecipeFromUrl {
final RecipeHtmlFetcher _fetcher;
/// Browser-like request headers for recipe downloads.
///
/// `User-Agent` and `Accept-Language` are forbidden headers in browsers
/// (fetch/XHR silently drop or reject them), so [buildScrapeHeaders]
/// strips the UA on web builds.
static Map<String, String> buildScrapeHeaders({required bool isWeb}) {
const accept =
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
const acceptLanguage = 'en-US,en;q=0.9';
// Latest-evergreen desktop Chrome signature; recipe sites gate their
// anti-bot checks on the presence of a real-browser UA, not its version.
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/131.0.0.0 Safari/537.36';
if (isWeb) {
return const {'Accept': accept, 'Accept-Language': acceptLanguage};
}
return const {
'Accept': accept,
'Accept-Language': acceptLanguage,
'User-Agent': userAgent,
};
}
static Future<String> _httpGetFetcher(Uri url) async {
final response = await http.get(
url,
headers: {'Accept': 'text/html,application/xhtml+xml'},
);
final http.Response response;
try {
response = await http.get(
resolveScrapeRequestUri(url),
headers: buildScrapeHeaders(isWeb: isWebCompilation),
);
} catch (error) {
// On the web the overwhelming cause is the CORS policy: recipe sites
// do not allow cross-origin fetches, so the browser aborts before a
// status code ever exists. Surface actionable guidance instead of a
// raw `ClientException`.
if (isWebCompilation && recipeProxyUrl.trim().isEmpty) {
throw RecipeScrapeException(
'The browser blocked this download (CORS). In-browser imports '
'need a server-side proxy: deploy supabase/functions/recipe-proxy '
'and run with --dart-define=RECIPE_PROXY_URL=<function-url>, or '
'import from the desktop/mobile app.',
);
}
rethrow; // Wrapped by [execute] into a RecipeScrapeException.
}
if (response.statusCode != 200) {
throw RecipeScrapeException(
'Download failed (HTTP ${response.statusCode}).',

View File

@ -2,17 +2,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../nutrition/domain/entities/meal_type.dart';
import '../../domain/entities/recipe.dart';
import '../controllers/meal_plan_calendar_controller.dart';
import '../controllers/recipe_import_controller.dart';
import '../widgets/assign_slot_dialog.dart';
import '../widgets/recipe_preview_dialog.dart';
import '../widgets/saved_recipes_tray.dart';
import '../widgets/assign_recipe_sheet.dart';
import '../widgets/weekly_calendar_view.dart';
/// GLP-1 meal planner: recipe URL import bar, saved-recipes tray, and the
/// responsive weekly calendar with drag-and-drop assignment and per-day
/// protein rollups.
/// GLP-1 meal planner: the responsive 7-day weekly calendar (Monday–Sunday)
/// with per-day protein rollups. Recipe import and browsing live in the
/// Recipe Vault; slots are filled via the searchable "+ Assign Recipe"
/// sheet.
class MealPlannerScreen extends ConsumerStatefulWidget {
const MealPlannerScreen({super.key});
@ -22,49 +20,19 @@ class MealPlannerScreen extends ConsumerStatefulWidget {
class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
/// Body height below which the page collapses into a single scrollable
/// column: fixed top sections (~340 dp) plus a minimal usable calendar
/// area. Below this, a plain `Column` would overflow and paint the
/// yellow/black hatched stripes.
static const double _kFullScrollThreshold = 520;
/// column: the week header plus a minimal usable calendar area. Below
/// this, a plain `Column` would overflow and paint the yellow/black
/// hatched stripes.
static const double _kFullScrollThreshold = 360;
/// Calendar height used in the fully-scrollable fallback layout; tall
/// enough for the day header plus four meal slots.
static const double _kCalendarScrollHeight = 560;
final TextEditingController _urlController = TextEditingController();
bool _previewDialogVisible = false;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final importState = ref.watch(recipeImportControllerProvider);
final calendarState = ref.watch(mealPlanCalendarControllerProvider);
// Open the preview modal when a scrape completes; surface messages as
// snackbars.
ref.listen(recipeImportControllerProvider, (previous, next) {
if (next.previewRecipe != null &&
previous?.previewRecipe == null &&
!_previewDialogVisible) {
_showPreview(next.previewRecipe!);
}
if (next.statusMessage != null &&
next.statusMessage != previous?.statusMessage) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(next.statusMessage!)));
}
if (next.errorMessage != null &&
next.errorMessage != previous?.errorMessage &&
next.previewRecipe == null) {
_showErrorSnack(next.errorMessage!);
}
});
ref.listen(mealPlanCalendarControllerProvider, (previous, next) {
if (next.errorMessage != null &&
next.errorMessage != previous?.errorMessage) {
@ -73,19 +41,19 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
});
return Scaffold(
appBar: AppBar(title: const Text('Meal Planner & Import')),
appBar: AppBar(title: const Text('Meal Planner')),
body: LayoutBuilder(
builder: (context, constraints) {
// Short windows (e.g. resized desktop windows) can no longer fit
// the fixed top sections plus a useful calendar area; collapse the
// whole page into a single scroll view instead of overflowing.
// a useful calendar area; collapse the whole page into a single
// scroll view instead of overflowing.
if (constraints.maxHeight < _kFullScrollThreshold) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTopSections(importState, calendarState),
_WeekNavigationHeader(state: calendarState),
SizedBox(
height: _kCalendarScrollHeight,
child: _buildCalendar(calendarState),
@ -98,7 +66,8 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTopSections(importState, calendarState),
_WeekNavigationHeader(state: calendarState),
const Divider(height: 1),
Expanded(child: _buildCalendar(calendarState)),
],
);
@ -107,37 +76,12 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
);
}
/// Import bar + saved recipes tray + week navigation header.
Widget _buildTopSections(
RecipeImportState importState,
MealPlanCalendarState calendarState,
) =>
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_RecipeImportBar(
controller: _urlController,
isLoading: importState.isImporting,
onSubmit: _submitUrl,
),
const SizedBox(height: 12),
SavedRecipesTray(
recipes: importState.savedRecipes,
onAssignPressed: _openTrayAssign,
),
const SizedBox(height: 12),
_WeekNavigationHeader(state: calendarState),
const Divider(height: 1),
],
);
/// Weekly calendar grid (or the initial loading spinner).
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
!calendarState.hasLoaded
? const Center(child: CircularProgressIndicator())
: WeeklyCalendarView(
state: calendarState,
onRecipeDropped: _onRecipeDropped,
onSlotTapped: _onSlotTapped,
onTogglePrepared: (slot) => ref
.read(mealPlanCalendarControllerProvider.notifier)
@ -156,35 +100,11 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
);
}
Future<void> _submitUrl() async {
final url = _urlController.text;
final notifier = ref.read(recipeImportControllerProvider.notifier);
await notifier.submitUrl(url);
if (mounted) _urlController.clear();
}
Future<void> _showPreview(Recipe recipe) async {
_previewDialogVisible = true;
final notifier = ref.read(recipeImportControllerProvider.notifier);
final saved = await showRecipePreviewDialog(context, recipe: recipe);
_previewDialogVisible = false;
// Cancelling (or abandoning a failed save) clears the parked preview so
// the modal never re-opens on the next rebuild.
if (saved != true) notifier.dismissPreview();
}
void _onRecipeDropped(Recipe recipe, DateTime day, MealType mealType) {
ref.read(mealPlanCalendarControllerProvider.notifier).assignRecipe(
day: day,
mealType: mealType,
recipeId: recipe.id,
);
}
void _onSlotTapped(DateTime day, MealType mealType) {
/// Opens the searchable assign sheet, populated from the Recipe Vault.
Future<void> _onSlotTapped(DateTime day, MealType mealType) async {
final calendarState = ref.read(mealPlanCalendarControllerProvider);
final importState = ref.read(recipeImportControllerProvider);
showAssignSlotDialog(
await showAssignRecipeSheet(
context,
day: day,
mealType: mealType,
@ -192,79 +112,6 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
existingSlot: calendarState.slotFor(day, mealType),
);
}
void _openTrayAssign(Recipe recipe) {
final calendarState = ref.read(mealPlanCalendarControllerProvider);
final importState = ref.read(recipeImportControllerProvider);
// Default target: today when inside the window, else the first day.
final now = DateTime.now();
final day = calendarState.days.firstWhere(
(day) =>
day.year == now.year &&
day.month == now.month &&
day.day == now.day,
orElse: () => calendarState.days.first,
);
showAssignSlotDialog(
context,
day: day,
mealType: MealType.breakfast,
savedRecipes: importState.savedRecipes,
existingSlot: calendarState.slotFor(day, MealType.breakfast),
);
}
}
/// URL import bar: text field + Import button with an inline loading
/// spinner.
class _RecipeImportBar extends StatelessWidget {
const _RecipeImportBar({
required this.controller,
required this.isLoading,
required this.onSubmit,
});
final TextEditingController controller;
final bool isLoading;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: !isLoading,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Recipe URL',
hintText: 'https://example.com/best-lasagna',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.link),
),
onSubmitted: (_) => onSubmit(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isLoading ? null : onSubmit,
icon: isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: const Text('Import'),
),
],
),
);
}
}
/// Week navigation row: prev/next arrows, the visible week label, and a
@ -337,5 +184,3 @@ class _WeekNavigationHeader extends StatelessWidget {
ProviderScope.containerOf(context, listen: false)
.read(mealPlanCalendarControllerProvider.notifier);
}

View File

@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../nutrition/domain/entities/meal_type.dart';
import '../../domain/entities/meal_plan_slot.dart';
import '../../domain/entities/recipe.dart';
import '../controllers/meal_plan_calendar_controller.dart';
/// Opens the searchable "Assign Recipe" modal sheet for a day/meal slot.
///
/// Populated from the Recipe Vault catalog; resolves to `true` when a slot
/// was written, otherwise `null`/`false`.
Future<bool?> showAssignRecipeSheet(
BuildContext context, {
required DateTime day,
required MealType mealType,
required List<Recipe> savedRecipes,
MealPlanSlot? existingSlot,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => AssignRecipeSheet(
day: day,
mealType: mealType,
savedRecipes: savedRecipes,
existingSlot: existingSlot,
),
);
}
/// Modal bottom sheet: search field over the Recipe Vault plus a free-text
/// custom entry fallback for meals without a saved recipe.
class AssignRecipeSheet extends ConsumerStatefulWidget {
const AssignRecipeSheet({
super.key,
required this.day,
required this.mealType,
required this.savedRecipes,
this.existingSlot,
});
final DateTime day;
final MealType mealType;
final List<Recipe> savedRecipes;
final MealPlanSlot? existingSlot;
@override
ConsumerState<AssignRecipeSheet> createState() => _AssignRecipeSheetState();
}
class _AssignRecipeSheetState extends ConsumerState<AssignRecipeSheet> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _customController = TextEditingController();
String _query = '';
bool _isSaving = false;
@override
void initState() {
super.initState();
_customController.text = widget.existingSlot?.customDescription ?? '';
}
@override
void dispose() {
_searchController.dispose();
_customController.dispose();
super.dispose();
}
List<Recipe> get _filtered {
final query = _query.trim().toLowerCase();
if (query.isEmpty) return widget.savedRecipes;
return widget.savedRecipes
.where((recipe) => recipe.title.toLowerCase().contains(query))
.toList(growable: false);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dayLabel =
MaterialLocalizations.of(context).formatCompactDate(widget.day);
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final filtered = _filtered;
return Padding(
padding: EdgeInsets.only(left: 16, right: 16, bottom: bottomInset + 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Assign ${widget.mealType.displayName} — $dayLabel',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Search Recipe Vault',
hintText: 'e.g. chicken',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.search),
),
onChanged: (value) => setState(() => _query = value),
),
const SizedBox(height: 12),
if (widget.savedRecipes.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'No saved recipes yet — import one from the Recipe Vault.',
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
)
else if (filtered.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'No recipes match "$_query".',
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
itemCount: filtered.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final recipe = filtered[index];
final protein = recipe.macrosPerServing.proteinGrams.round();
return ListTile(
dense: true,
enabled: !_isSaving,
leading: const Icon(Icons.menu_book_outlined),
title: Text(
recipe.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${protein}g P',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
),
onTap: () => _assignRecipe(recipe),
);
},
),
),
const SizedBox(height: 12),
const Divider(),
TextField(
controller: _customController,
enabled: !_isSaving,
decoration: const InputDecoration(
labelText: 'Custom entry (no recipe)',
hintText: 'e.g. Leftover chicken & greens',
border: OutlineInputBorder(),
isDense: true,
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _isSaving ? null : _saveCustom,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.edit_outlined),
label: const Text('Save Custom'),
),
],
),
],
),
);
}
Future<void> _assignRecipe(Recipe recipe) async {
setState(() => _isSaving = true);
final saved = await ref
.read(mealPlanCalendarControllerProvider.notifier)
.assignRecipe(
day: widget.day,
mealType: widget.mealType,
recipeId: recipe.id,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
Future<void> _saveCustom() async {
final description = _customController.text.trim();
if (description.isEmpty) return;
setState(() => _isSaving = true);
final saved = await ref
.read(mealPlanCalendarControllerProvider.notifier)
.saveCustomEntry(
day: widget.day,
mealType: widget.mealType,
description: description,
);
if (!mounted) return;
Navigator.of(context).pop(saved);
}
}

View File

@ -1,188 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../nutrition/domain/entities/meal_type.dart';
import '../../domain/entities/meal_plan_slot.dart';
import '../../domain/entities/recipe.dart';
import '../controllers/meal_plan_calendar_controller.dart';
/// Opens the quick-assign modal for a specific day/meal slot.
///
/// Shows the saved recipes to pick from, plus a free-text override for
/// meals without a saved recipe. Resolves to `true` when a slot was
/// written, otherwise `null`/`false`.
Future<bool?> showAssignSlotDialog(
BuildContext context, {
required DateTime day,
required MealType mealType,
required List<Recipe> savedRecipes,
MealPlanSlot? existingSlot,
}) {
return showDialog<bool>(
context: context,
builder: (context) => AssignSlotDialog(
day: day,
mealType: mealType,
savedRecipes: savedRecipes,
existingSlot: existingSlot,
),
);
}
/// Modal used by the weekly calendar's "+" affordance: assign a saved
/// recipe (via dropdown) or type a quick custom description for the slot.
class AssignSlotDialog extends ConsumerStatefulWidget {
const AssignSlotDialog({
super.key,
required this.day,
required this.mealType,
required this.savedRecipes,
this.existingSlot,
});
final DateTime day;
final MealType mealType;
final List<Recipe> savedRecipes;
final MealPlanSlot? existingSlot;
@override
ConsumerState<AssignSlotDialog> createState() => _AssignSlotDialogState();
}
class _AssignSlotDialogState extends ConsumerState<AssignSlotDialog> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final TextEditingController _descriptionController;
Recipe? _selectedRecipe;
bool _isSaving = false;
@override
void initState() {
super.initState();
_descriptionController = TextEditingController(
text: widget.existingSlot?.customDescription ?? '',
);
// Pre-select the currently assigned recipe, if any.
final recipeId = widget.existingSlot?.recipeId;
if (recipeId != null) {
for (final recipe in widget.savedRecipes) {
if (recipe.id == recipeId) {
_selectedRecipe = recipe;
break;
}
}
}
}
@override
Widget build(BuildContext context) {
final dayLabel = MaterialLocalizations.of(context).formatCompactDate(widget.day);
return AlertDialog(
title: Text('Assign ${widget.mealType.displayName} — $dayLabel'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<Recipe>(
initialValue: _selectedRecipe,
decoration: const InputDecoration(
labelText: 'Saved recipe',
border: OutlineInputBorder(),
),
items: [
for (final recipe in widget.savedRecipes)
DropdownMenuItem<Recipe>(
value: recipe,
child: Text(
recipe.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (recipe) => setState(() => _selectedRecipe = recipe),
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
hintText: 'e.g. Leftover chicken & greens',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
validator: _validate,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: _isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _isSaving ? null : _assign,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.event_available_outlined),
label: const Text('Assign'),
),
],
);
}
String? _validate(String? value) {
// A description is only required when no saved recipe is selected.
if (_selectedRecipe == null && (value == null || value.trim().isEmpty)) {
return 'Select a recipe or enter a description';
}
return null;
}
Future<void> _assign() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() => _isSaving = true);
final controller = ref.read(mealPlanCalendarControllerProvider.notifier);
final description = _descriptionController.text.trim();
final bool saved;
final recipe = _selectedRecipe;
if (recipe != null) {
saved = await controller.assignRecipe(
day: widget.day,
mealType: widget.mealType,
recipeId: recipe.id,
note: description.isEmpty ? null : description,
);
} else {
saved = await controller.saveCustomEntry(
day: widget.day,
mealType: widget.mealType,
description: description,
);
}
if (!mounted) return;
Navigator.of(context).pop(saved);
}
@override
void dispose() {
_descriptionController.dispose();
super.dispose();
}
}

View File

@ -1,227 +0,0 @@
import 'package:flutter/material.dart';
import '../../domain/entities/recipe.dart';
/// Horizontally scrolling tray of saved recipe cards.
///
/// Each card is draggable onto the weekly calendar's meal slots and
/// carries a "+" affordance that opens the quick-assign modal.
class SavedRecipesTray extends StatelessWidget {
const SavedRecipesTray({
super.key,
required this.recipes,
this.onAssignPressed,
});
/// Saved recipes, newest first.
final List<Recipe> recipes;
/// Called when the card's quick-assign button (or the card itself) is
/// tapped.
final void Function(Recipe recipe)? onAssignPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text(
'Saved Recipes',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Text(
'${recipes.length}',
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
height: 148,
child: recipes.isEmpty
? Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Import a recipe above — it will appear here, ready to '
'drag onto the weekly plan.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
),
)
: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: recipes.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 12),
itemBuilder: (context, index) {
final recipe = recipes[index];
return _RecipeCard(
recipe: recipe,
onAssignPressed: onAssignPressed == null
? null
: () => onAssignPressed!(recipe),
);
},
),
),
],
);
}
}
/// Draggable card showing a recipe thumbnail, title, and protein badge.
class _RecipeCard extends StatelessWidget {
const _RecipeCard({required this.recipe, this.onAssignPressed});
final Recipe recipe;
final VoidCallback? onAssignPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final protein = recipe.macrosPerServing.proteinGrams;
final card = SizedBox(
width: 180,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onAssignPressed,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: recipe.imageUrl == null
? _ThumbnailPlaceholder(theme: theme)
: Image.network(
recipe.imageUrl!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_ThumbnailPlaceholder(theme: theme),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Row(
children: [
_ProteinBadge(proteinGrams: protein),
const Spacer(),
if (onAssignPressed != null)
Icon(
Icons.add_circle_outline,
size: 20,
color: theme.colorScheme.primary,
),
],
),
],
),
),
],
),
),
),
);
return Draggable<Recipe>(
data: recipe,
feedback: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
recipe.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: card),
child: card,
);
}
}
/// Fallback tile when a recipe has no (loadable) thumbnail.
class _ThumbnailPlaceholder extends StatelessWidget {
const _ThumbnailPlaceholder({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.restaurant_menu,
size: 28,
color: theme.colorScheme.outline,
),
),
);
}
}
/// Compact green badge showing the protein contribution of a recipe.
class _ProteinBadge extends StatelessWidget {
const _ProteinBadge({required this.proteinGrams});
final double proteinGrams;
@override
Widget build(BuildContext context) {
final rounded = proteinGrams.roundToDouble();
final label = rounded == proteinGrams
? '${rounded.toStringAsFixed(0)}g P'
: '${proteinGrams.toStringAsFixed(1)}g P';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
);
}
}

View File

@ -10,14 +10,12 @@ import '../controllers/meal_plan_calendar_controller.dart';
/// On wide layouts (>= 900 dp) all seven day columns are visible at once;
/// compact widths fall back to a tabbed pager with one day per tab. Each
/// column header shows the day's scheduled-protein rollup against the daily
/// target, and every meal slot is a drop target for the saved-recipe tray
/// (`Draggable`/`DragTarget`) as well as tappable for the quick-assign
/// modal.
/// target, and every meal slot offers a "+ Assign Recipe" action that opens
/// the searchable Recipe Vault sheet.
class WeeklyCalendarView extends StatelessWidget {
const WeeklyCalendarView({
super.key,
required this.state,
required this.onRecipeDropped,
required this.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
@ -25,11 +23,7 @@ class WeeklyCalendarView extends StatelessWidget {
final MealPlanCalendarState state;
/// A tray recipe was dropped onto [day]'s [mealType] slot.
final void Function(Recipe recipe, DateTime day, MealType mealType)
onRecipeDropped;
/// The slot was tapped — open the quick-assign modal.
/// The slot was tapped — open the searchable assign sheet.
final void Function(DateTime day, MealType mealType) onSlotTapped;
final void Function(MealPlanSlot slot) onTogglePrepared;
@ -61,7 +55,6 @@ class WeeklyCalendarView extends StatelessWidget {
child: _DayColumn(
day: day,
state: state,
onRecipeDropped: onRecipeDropped,
onSlotTapped: onSlotTapped,
onTogglePrepared: onTogglePrepared,
onClearSlot: onClearSlot,
@ -75,7 +68,6 @@ class WeeklyCalendarView extends StatelessWidget {
return _CompactWeekPager(
days: days,
state: state,
onRecipeDropped: onRecipeDropped,
onSlotTapped: onSlotTapped,
onTogglePrepared: onTogglePrepared,
onClearSlot: onClearSlot,
@ -90,7 +82,6 @@ class _CompactWeekPager extends StatefulWidget {
const _CompactWeekPager({
required this.days,
required this.state,
required this.onRecipeDropped,
required this.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
@ -98,8 +89,6 @@ class _CompactWeekPager extends StatefulWidget {
final List<DateTime> days;
final MealPlanCalendarState state;
final void Function(Recipe recipe, DateTime day, MealType mealType)
onRecipeDropped;
final void Function(DateTime day, MealType mealType) onSlotTapped;
final void Function(MealPlanSlot slot) onTogglePrepared;
final void Function(MealPlanSlot slot) onClearSlot;
@ -148,7 +137,6 @@ class _CompactWeekPagerState extends State<_CompactWeekPager>
child: _DayColumn(
day: day,
state: widget.state,
onRecipeDropped: widget.onRecipeDropped,
onSlotTapped: widget.onSlotTapped,
onTogglePrepared: widget.onTogglePrepared,
onClearSlot: widget.onClearSlot,
@ -169,7 +157,6 @@ class _DayColumn extends StatelessWidget {
const _DayColumn({
required this.day,
required this.state,
required this.onRecipeDropped,
required this.onSlotTapped,
required this.onTogglePrepared,
required this.onClearSlot,
@ -177,8 +164,6 @@ class _DayColumn extends StatelessWidget {
final DateTime day;
final MealPlanCalendarState state;
final void Function(Recipe recipe, DateTime day, MealType mealType)
onRecipeDropped;
final void Function(DateTime day, MealType mealType) onSlotTapped;
final void Function(MealPlanSlot slot) onTogglePrepared;
final void Function(MealPlanSlot slot) onClearSlot;
@ -194,6 +179,10 @@ class _DayColumn extends StatelessWidget {
final target = state.dailyProteinTargetGrams;
final progress = state.proteinProgressForDay(day).clamp(0.0, 1.0);
final reachedGoal = protein >= target;
final roundedProtein = protein.roundToDouble();
final proteinLabel = roundedProtein == protein
? roundedProtein.toStringAsFixed(0)
: protein.toStringAsFixed(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -203,31 +192,22 @@ class _DayColumn extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_weekdayLabels[day.weekday - 1]} ${day.day}',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.fitness_center,
size: 14,
color: reachedGoal ? Colors.green : theme.colorScheme.outline,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'${protein.toStringAsFixed(0)}/'
'${target.toStringAsFixed(0)} g',
style: theme.textTheme.labelSmall?.copyWith(
color: reachedGoal
? Colors.green.shade800
: theme.colorScheme.outline,
fontWeight:
reachedGoal ? FontWeight.bold : FontWeight.normal,
),
_weekdayLabels[day.weekday - 1],
style: theme.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
),
Text(
reachedGoal ? '✓ $proteinLabel g' : '$proteinLabel g',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: reachedGoal
? Colors.green.shade700
: theme.colorScheme.outline,
),
),
],
@ -248,11 +228,9 @@ class _DayColumn extends StatelessWidget {
const Divider(height: 12),
for (final mealType in MealType.values) ...[
_DaySlotCard(
day: day,
mealType: mealType,
slot: state.slotFor(day, mealType),
recipe: _recipeForSlot(state.slotFor(day, mealType)),
onRecipeDropped: onRecipeDropped,
onTap: () => onSlotTapped(day, mealType),
onTogglePrepared: onTogglePrepared,
onClearSlot: onClearSlot,
@ -267,27 +245,22 @@ class _DayColumn extends StatelessWidget {
slot == null ? null : state.recipeFor(slot);
}
/// A single meal slot: drop target for tray drags and tap target for the
/// quick-assign modal; shows the assigned recipe (with protein badge) or a
/// free-text note, a prepared checkbox, and a clear action.
/// A single meal slot: tap target for the searchable assign sheet; shows
/// the assigned recipe (with protein badge) or a free-text note, a
/// prepared checkbox, and a clear action.
class _DaySlotCard extends StatelessWidget {
const _DaySlotCard({
required this.day,
required this.mealType,
required this.slot,
required this.recipe,
required this.onRecipeDropped,
required this.onTap,
required this.onTogglePrepared,
required this.onClearSlot,
});
final DateTime day;
final MealType mealType;
final MealPlanSlot? slot;
final Recipe? recipe;
final void Function(Recipe recipe, DateTime day, MealType mealType)
onRecipeDropped;
final VoidCallback onTap;
final void Function(MealPlanSlot slot) onTogglePrepared;
final void Function(MealPlanSlot slot) onClearSlot;
@ -311,27 +284,18 @@ class _DaySlotCard extends StatelessWidget {
final currentSlot = slot;
final filled = currentSlot != null && !currentSlot.isEmpty;
return DragTarget<Recipe>(
onAcceptWithDetails: (details) =>
onRecipeDropped(details.data, day, mealType),
builder: (context, candidateRecipes, rejectedRecipes) {
final highlighted = candidateRecipes.isNotEmpty;
return Material(
color: highlighted
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.all(8),
child: _buildContent(theme, currentSlot, filled),
),
),
);
},
return Material(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.all(8),
child: _buildContent(theme, currentSlot, filled),
),
),
);
}
@ -409,14 +373,23 @@ class _DaySlotCard extends StatelessWidget {
),
],
] else
Text(
'Tap or drop a recipe',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.outline,
),
Row(
children: [
Icon(Icons.add_circle_outline,
size: 14, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Expanded(
child: Text(
'Assign Recipe',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
],
),
],
);

View File

@ -73,6 +73,10 @@ class _LogInjectionDialogState extends ConsumerState<LogInjectionDialog> {
children: [
DropdownButtonFormField<Glp1Compound>(
initialValue: _compound,
// Long labels ("Tirzepatide (Mounjaro / Zepbound)") must
// ellipsize in the narrow mobile dialog instead of
// overflowing the selected-item row.
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Compound',
border: OutlineInputBorder(),
@ -83,6 +87,8 @@ class _LogInjectionDialogState extends ConsumerState<LogInjectionDialog> {
value: compound,
child: Text(
'${compound.displayName} (${compound.brandNames})',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],

View File

@ -105,12 +105,47 @@ class NutritionController extends AsyncNotifier<NutritionState> {
/// Phase 3 default daily hydration goal.
static const double defaultHydrationTargetOz = 96;
/// Session-scoped daily protein goal; defaults to
/// [defaultProteinTargetGrams] and is updated by the Settings screen.
double _proteinTargetGrams = defaultProteinTargetGrams;
/// Session-scoped daily hydration goal; defaults to
/// [defaultHydrationTargetOz] and is updated by the Settings screen.
double _hydrationTargetOz = defaultHydrationTargetOz;
@override
Future<NutritionState> build() async {
final repository = ref.watch(nutritionRepositoryProvider);
return _load(repository, day: DateTime.now());
}
/// Updates the daily protein/hydration targets (Settings screen) and
/// refreshes the snapshot so progress meters re-evaluate immediately.
///
/// Values must be positive; invalid input leaves the current target
/// unchanged. Targets are session-scoped (in-memory), matching the
/// exercise-log scaffold.
void updateTargets({double? proteinTargetGrams, double? hydrationTargetOz}) {
if (proteinTargetGrams != null && proteinTargetGrams > 0) {
_proteinTargetGrams = proteinTargetGrams;
}
if (hydrationTargetOz != null && hydrationTargetOz > 0) {
_hydrationTargetOz = hydrationTargetOz;
}
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(
NutritionState(
foodLogs: current.foodLogs,
hydrationLogs: current.hydrationLogs,
day: current.day,
proteinTargetGrams: _proteinTargetGrams,
hydrationTargetOz: _hydrationTargetOz,
),
);
}
}
/// Persists a meal entry and refreshes the daily snapshot.
///
/// Returns whether the write succeeded; failures keep the last good state.
@ -214,8 +249,8 @@ class NutritionController extends AsyncNotifier<NutritionState> {
foodLogs: foodLogs,
hydrationLogs: hydrationLogs,
day: day,
proteinTargetGrams: defaultProteinTargetGrams,
hydrationTargetOz: defaultHydrationTargetOz,
proteinTargetGrams: _proteinTargetGrams,
hydrationTargetOz: _hydrationTargetOz,
);
}

View File

@ -0,0 +1,598 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../meal_planner/domain/entities/recipe.dart';
import '../../../meal_planner/presentation/controllers/recipe_import_controller.dart';
import '../../../meal_planner/presentation/widgets/recipe_preview_dialog.dart';
/// One selectable macro filter chip applied to the recipe vault grid.
enum MacroFilter {
all('All'),
protein30('Protein > 30g'),
protein20('Protein > 20g'),
under500('Under 500 kcal');
const MacroFilter(this.label);
/// Human-readable chip label.
final String label;
/// Whether [recipe] satisfies this filter.
bool accepts(Recipe recipe) {
final macros = recipe.macrosPerServing;
switch (this) {
case MacroFilter.all:
return true;
case MacroFilter.protein30:
return macros.proteinGrams > 30;
case MacroFilter.protein20:
return macros.proteinGrams > 20;
case MacroFilter.under500:
return macros.calories < 500;
}
}
}
/// Dedicated Recipe Vault: URL import bar, search + macro filter chips, and
/// a responsive virtualized grid of recipe cards with chunked infinite
/// scroll (cards are built lazily by [GridView.builder]; the visible window
/// extends as the user approaches the end of the loaded chunk).
class RecipesScreen extends ConsumerStatefulWidget {
const RecipesScreen({super.key});
@override
ConsumerState<RecipesScreen> createState() => _RecipesScreenState();
}
class _RecipesScreenState extends ConsumerState<RecipesScreen> {
/// Recipes materialized per scroll chunk.
static const int _pageSize = 24;
/// How close (in items) the viewport must get to the end of the built
/// window before the next chunk is scheduled.
static const int _prefetchMargin = 6;
final TextEditingController _urlController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
String _query = '';
MacroFilter _activeFilter = MacroFilter.all;
/// Number of filtered recipes currently materialized in the grid; grows
/// in [_pageSize] chunks to emulate infinite scroll over the catalog.
int _visibleCount = _pageSize;
/// Guards the preview modal against double-opens while animating.
bool _previewDialogVisible = false;
@override
void dispose() {
_urlController.dispose();
_searchController.dispose();
super.dispose();
}
List<Recipe> _applyFilters(List<Recipe> recipes) {
final query = _query.trim().toLowerCase();
return recipes.where((recipe) {
if (!_activeFilter.accepts(recipe)) return false;
if (query.isEmpty) return true;
return recipe.title.toLowerCase().contains(query) ||
(recipe.description ?? '').toLowerCase().contains(query) ||
recipe.sourceUrl.toLowerCase().contains(query);
}).toList(growable: false);
}
void _extendWindow(int filteredLength) {
if (_visibleCount >= filteredLength) return;
// Defer past the current build phase: itemBuilder may run mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_visibleCount = (_visibleCount + _pageSize).clamp(0, filteredLength);
});
});
}
@override
Widget build(BuildContext context) {
final importState = ref.watch(recipeImportControllerProvider);
ref.listen(recipeImportControllerProvider, (previous, next) {
if (next.previewRecipe != null &&
previous?.previewRecipe == null &&
!_previewDialogVisible) {
_showPreview(next.previewRecipe!);
}
if (next.statusMessage != null &&
next.statusMessage != previous?.statusMessage) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(next.statusMessage!)));
}
if (next.errorMessage != null &&
next.errorMessage != previous?.errorMessage &&
next.previewRecipe == null) {
_showErrorSnack(next.errorMessage!);
}
});
final filtered = _applyFilters(importState.savedRecipes);
return Scaffold(
appBar: AppBar(title: const Text('Recipe Vault')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildImportBar(importState.isImporting),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: SearchBar(
controller: _searchController,
hintText: 'Search recipes…',
leading: const Icon(Icons.search),
elevation: const WidgetStatePropertyAll(0),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
onChanged: (value) => setState(() {
_query = value;
_visibleCount = _pageSize;
}),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final filter in MacroFilter.values)
ChoiceChip(
label: Text(filter.label),
selected: _activeFilter == filter,
onSelected: (_) => setState(() {
_activeFilter = filter;
_visibleCount = _pageSize;
}),
),
],
),
),
Expanded(
child: filtered.isEmpty
? _EmptyVaultHint(
hasAnyRecipes: importState.savedRecipes.isNotEmpty,
)
: _buildGrid(filtered),
),
],
),
);
}
/// URL import bar pinned above the searchable grid.
Widget _buildImportBar(bool isImporting) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _urlController,
enabled: !isImporting,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Import from URL',
hintText: 'https://example.com/best-lasagna',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.link),
),
onSubmitted: (_) => _submitUrl(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: isImporting ? null : _submitUrl,
icon: isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: const Text('Import'),
),
],
),
);
}
/// Responsive virtualized grid: column count adapts to the available
/// width and only the first [_visibleCount] filtered recipes are
/// materialized, extending in chunks as the user scrolls.
Widget _buildGrid(List<Recipe> filtered) {
final materialized =
filtered.length < _visibleCount ? filtered.length : _visibleCount;
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240,
mainAxisExtent: 232,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: materialized,
itemBuilder: (context, index) {
// Infinite scroll: extend the materialized window when the viewport
// nears the end of the current chunk.
if (index >= materialized - _prefetchMargin) {
_extendWindow(filtered.length);
}
return _RecipeVaultCard(
recipe: filtered[index],
onTap: () => _showDetails(filtered[index]),
onDelete: () => _deleteRecipe(filtered[index]),
);
},
);
}
Future<void> _submitUrl() async {
final url = _urlController.text;
await ref.read(recipeImportControllerProvider.notifier).submitUrl(url);
if (mounted) _urlController.clear();
}
Future<void> _showPreview(Recipe recipe) async {
_previewDialogVisible = true;
final notifier = ref.read(recipeImportControllerProvider.notifier);
final saved = await showRecipePreviewDialog(context, recipe: recipe);
_previewDialogVisible = false;
// Cancelling (or abandoning a failed save) clears the parked preview so
// the modal never re-opens on the next rebuild.
if (saved != true) notifier.dismissPreview();
}
Future<void> _deleteRecipe(Recipe recipe) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete "${recipe.title}"?'),
content: const Text(
'Assigned meal-plan slots will fall back to empty. This cannot '
'be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
await ref
.read(recipeImportControllerProvider.notifier)
.deleteRecipe(recipe.id);
}
void _showDetails(Recipe recipe) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => _RecipeDetailsSheet(recipe: recipe),
);
}
void _showErrorSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
/// Recipe card used inside the vault grid: thumbnail, title, macro badges,
/// and a delete affordance.
class _RecipeVaultCard extends StatelessWidget {
const _RecipeVaultCard({
required this.recipe,
required this.onTap,
required this.onDelete,
});
final Recipe recipe;
final VoidCallback onTap;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final macros = recipe.macrosPerServing;
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: recipe.imageUrl == null
? _VaultThumbnailPlaceholder(theme: theme)
: Image.network(
recipe.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_VaultThumbnailPlaceholder(theme: theme),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Row(
children: [
_VaultMacroBadge(
label: '${_fmt(macros.proteinGrams)}g P',
color: Colors.green.shade100,
textColor: Colors.green.shade800,
),
const SizedBox(width: 4),
_VaultMacroBadge(
label: '${macros.calories} kcal',
color: theme.colorScheme.secondaryContainer,
textColor: theme.colorScheme.onSecondaryContainer,
),
const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Delete recipe',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
),
],
),
],
),
),
],
),
),
);
}
static String _fmt(double value) {
final rounded = value.roundToDouble();
return rounded == value
? rounded.toStringAsFixed(0)
: value.toStringAsFixed(1);
}
}
/// Fallback tile when a recipe has no (loadable) thumbnail.
class _VaultThumbnailPlaceholder extends StatelessWidget {
const _VaultThumbnailPlaceholder({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Container(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.menu_book_outlined,
size: 32,
color: theme.colorScheme.outline,
),
),
);
}
}
/// Small colored badge used for per-serving macro chips.
class _VaultMacroBadge extends StatelessWidget {
const _VaultMacroBadge({
required this.label,
required this.color,
required this.textColor,
});
final String label;
final Color color;
final Color textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: textColor,
),
),
);
}
}
/// Placeholder shown while the (filtered) vault is empty.
class _EmptyVaultHint extends StatelessWidget {
const _EmptyVaultHint({required this.hasAnyRecipes});
/// `true` when recipes exist but the active search/filter hides them.
final bool hasAnyRecipes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasAnyRecipes ? Icons.search_off : Icons.menu_book_outlined,
size: 40,
color: theme.colorScheme.outline,
),
const SizedBox(height: 8),
Text(
hasAnyRecipes
? 'No recipes match the current search or filter.'
: 'Your vault is empty.\nImport a recipe URL above to get '
'started.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
],
),
),
);
}
}
/// Read-only details sheet for a saved recipe: macros, ingredients, and
/// instructions.
class _RecipeDetailsSheet extends StatelessWidget {
const _RecipeDetailsSheet({required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final macros = recipe.macrosPerServing;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
recipe.title,
style: theme.textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.restaurant, size: 16),
label: Text('${recipe.servings} servings'),
),
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.timer_outlined, size: 16),
label: Text('${recipe.totalMinutes} min'),
),
Chip(
visualDensity: VisualDensity.compact,
backgroundColor: Colors.green.shade100,
label: Text(
'${_fmt(macros.proteinGrams)}g protein',
style: TextStyle(
color: Colors.green.shade800,
fontWeight: FontWeight.bold,
),
),
),
Chip(
visualDensity: VisualDensity.compact,
label: Text('${macros.calories} kcal'),
),
],
),
if (recipe.description != null) ...[
const SizedBox(height: 12),
Text(recipe.description!, style: theme.textTheme.bodyMedium),
],
if (recipe.ingredients.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Ingredients',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
for (final ingredient in recipe.ingredients)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('• ${ingredient.rawText}',
style: theme.textTheme.bodySmall),
),
],
if (recipe.instructions.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Instructions',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
for (var i = 0; i < recipe.instructions.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'${i + 1}. ${recipe.instructions[i]}',
style: theme.textTheme.bodySmall,
),
),
],
const SizedBox(height: 16),
Text(
'Source: ${recipe.sourceUrl}',
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
static String _fmt(double value) {
final rounded = value.roundToDouble();
return rounded == value
? rounded.toStringAsFixed(0)
: value.toStringAsFixed(1);
}
}

View File

@ -0,0 +1,151 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/platform/health_gateway.dart';
import '../../../health_sync/presentation/controllers/biometric_controller.dart';
/// Immutable snapshot of the integrations toggles shown on the Settings
/// screen.
///
/// Native health toggles are gated by the platform actually backing the
/// gateway (HealthKit on iOS, Health Connect on Android); the HealthGateway
/// connector (cloud partner REST sync) is available everywhere and reports
/// manual-entry fallback on Web/Windows/macOS.
class IntegrationsState {
const IntegrationsState({
this.appleHealthEnabled = false,
this.healthConnectEnabled = false,
this.healthGatewayEnabled = false,
this.isRequestingPermissions = false,
this.errorMessage,
});
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
static const Object _keep = Object();
/// Apple HealthKit sync armed (iOS only).
final bool appleHealthEnabled;
/// Google Health Connect sync armed (Android only).
final bool healthConnectEnabled;
/// HealthGateway (cloud partner / manual-entry fallback) sync armed.
final bool healthGatewayEnabled;
/// `true` while a native permission round-trip is in flight.
final bool isRequestingPermissions;
/// Last permission/persistence failure, surfaced inline.
final String? errorMessage;
IntegrationsState copyWith({
bool? appleHealthEnabled,
bool? healthConnectEnabled,
bool? healthGatewayEnabled,
bool? isRequestingPermissions,
Object? errorMessage = _keep,
}) =>
IntegrationsState(
appleHealthEnabled: appleHealthEnabled ?? this.appleHealthEnabled,
healthConnectEnabled: healthConnectEnabled ?? this.healthConnectEnabled,
healthGatewayEnabled: healthGatewayEnabled ?? this.healthGatewayEnabled,
isRequestingPermissions:
isRequestingPermissions ?? this.isRequestingPermissions,
errorMessage: identical(errorMessage, _keep)
? this.errorMessage
: errorMessage as String?,
);
}
/// Orchestrates the integrations toggles: requests native health
/// permissions through the cross-platform [IHealthGateway] before arming a
/// toggle, and degrades gracefully on platforms without native support.
class IntegrationsController extends Notifier<IntegrationsState> {
@override
IntegrationsState build() {
final gateway = ref.read(healthGatewayProvider);
return IntegrationsState(
// Toggles start disarmed; enabling requests fresh permissions.
appleHealthEnabled: false,
healthConnectEnabled: false,
healthGatewayEnabled: false,
errorMessage:
gateway.supportsNativeHealth ? null : _fallbackNotice(gateway),
);
}
/// Human-readable capability notice for platforms without native health
/// SDKs (Web / Windows / macOS fallback driver).
static String? _fallbackNotice(IHealthGateway gateway) {
if (gateway.supportsNativeHealth) return null;
return 'Native health SDKs are unavailable on this platform — '
'HealthGateway runs in manual-entry mode.';
}
/// Arms/disarms the Apple Health toggle. Enabling requests native
/// permission; denial reverts the toggle and records a message.
Future<void> setAppleHealthEnabled(bool enabled) =>
_setNative(enabled: enabled, isApple: true);
/// Arms/disarms the Health Connect toggle (same permission contract as
/// Apple Health; the gateway routes to the right SDK per platform).
Future<void> setHealthConnectEnabled(bool enabled) =>
_setNative(enabled: enabled, isApple: false);
/// Arms/disarms the HealthGateway connector (no native permission needed).
void setHealthGatewayEnabled(bool enabled) {
state = state.copyWith(healthGatewayEnabled: enabled, errorMessage: null);
}
/// Clears the transient error banner.
void clearError() {
if (state.errorMessage != null) {
state = state.copyWith(errorMessage: null);
}
}
Future<void> _setNative({required bool enabled, required bool isApple}) async {
if (!enabled) {
state = state.copyWith(
appleHealthEnabled: isApple ? false : state.appleHealthEnabled,
healthConnectEnabled: isApple ? state.healthConnectEnabled : false,
errorMessage: null,
);
return;
}
state = state.copyWith(isRequestingPermissions: true, errorMessage: null);
try {
final granted = await ref.read(healthGatewayProvider).requestPermissions();
if (granted) {
state = state.copyWith(
appleHealthEnabled: isApple ? true : state.appleHealthEnabled,
healthConnectEnabled: isApple ? state.healthConnectEnabled : true,
isRequestingPermissions: false,
);
} else {
state = state.copyWith(
isRequestingPermissions: false,
errorMessage:
'Health permissions were denied. Enable them in your device '
'settings to sync biometrics.',
);
}
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'health permission request failed',
);
state = state.copyWith(
isRequestingPermissions: false,
errorMessage: 'Could not request health permissions: $error',
);
}
}
}
/// Exposes the [IntegrationsController] to the widget tree.
final integrationsControllerProvider =
NotifierProvider<IntegrationsController, IntegrationsState>(
IntegrationsController.new,
);

View File

@ -0,0 +1,445 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../../core/network/supabase_status.dart';
import '../../../auth/presentation/controllers/auth_controller.dart';
import '../../../nutrition/presentation/controllers/nutrition_controller.dart';
import '../controllers/integrations_controller.dart';
/// Application settings hub: identity & session, daily tracking targets,
/// encrypted-storage status, and health integrations.
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final integrations = ref.watch(integrationsControllerProvider);
ref.listen(integrationsControllerProvider, (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(integrationsControllerProvider.notifier).clearError();
}
});
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
const _IdentitySection(),
const SizedBox(height: 16),
const _TargetsSection(),
const SizedBox(height: 16),
const _SecuritySection(),
const SizedBox(height: 16),
_IntegrationsSection(state: integrations),
],
),
);
}
}
/// Identity: signed-in email, Supabase connection status, and sign-out.
class _IdentitySection extends ConsumerWidget {
const _IdentitySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final email = _currentEmail();
final authState = ref.watch(authControllerProvider);
final isSigningOut = authState.isLoading;
return _SettingsCard(
title: 'Identity',
icon: Icons.person_outline,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.email_outlined,
size: 18, color: theme.colorScheme.outline),
const SizedBox(width: 8),
Expanded(
child: Text(
email ?? 'Not signed in',
style: theme.textTheme.bodyMedium,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.cloud_outlined,
size: 18, color: theme.colorScheme.outline),
const SizedBox(width: 8),
Flexible(
child: _StatusBadge(
label:
isSupabaseAvailable ? 'Supabase connected' : 'Offline',
ok: isSupabaseAvailable,
),
),
],
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed:
email == null || isSigningOut ? null : () => _signOut(ref),
icon: isSigningOut
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.logout),
label: const Text('Sign Out'),
),
),
],
),
);
}
/// Authenticated user's email, or `null` when the SDK is unavailable or no
/// session exists (e.g. debug dev bypass).
String? _currentEmail() {
if (!isSupabaseAvailable) return null;
return Supabase.instance.client.auth.currentUser?.email;
}
Future<void> _signOut(WidgetRef ref) =>
ref.read(authControllerProvider.notifier).signOut();
}
/// Targets: editable daily protein (g) and water (oz) goals driving the
/// nutrition progress meters and meal-plan rollups.
class _TargetsSection extends ConsumerStatefulWidget {
const _TargetsSection();
@override
ConsumerState<_TargetsSection> createState() => _TargetsSectionState();
}
class _TargetsSectionState extends ConsumerState<_TargetsSection> {
late final TextEditingController _proteinController;
late final TextEditingController _waterController;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
final nutrition = ref.read(nutritionControllerProvider).valueOrNull;
_proteinController = TextEditingController(
text: _fmt(nutrition?.proteinTargetGrams ??
NutritionController.defaultProteinTargetGrams),
);
_waterController = TextEditingController(
text: _fmt(nutrition?.hydrationTargetOz ??
NutritionController.defaultHydrationTargetOz),
);
}
@override
void dispose() {
_proteinController.dispose();
_waterController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final nutrition = ref.watch(nutritionControllerProvider);
final loaded = nutrition.valueOrNull != null;
return _SettingsCard(
title: 'Targets',
icon: Icons.flag_outlined,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _proteinController,
enabled: loaded,
decoration: const InputDecoration(
labelText: 'Daily protein goal (g)',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.fitness_center),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: _positiveDouble,
),
const SizedBox(height: 12),
TextFormField(
controller: _waterController,
enabled: loaded,
decoration: const InputDecoration(
labelText: 'Daily water goal (oz)',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.water_drop_outlined),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
validator: _positiveDouble,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: loaded ? _save : null,
icon: const Icon(Icons.save_outlined),
label: const Text('Save Targets'),
),
),
],
),
),
);
}
String? _positiveDouble(String? value) {
final parsed = double.tryParse((value ?? '').trim());
if (parsed == null || parsed <= 0) return 'Must be a positive number';
return null;
}
void _save() {
if (!(_formKey.currentState?.validate() ?? false)) return;
ref.read(nutritionControllerProvider.notifier).updateTargets(
proteinTargetGrams: double.parse(_proteinController.text.trim()),
hydrationTargetOz: double.parse(_waterController.text.trim()),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Targets updated.')),
);
}
static String _fmt(double value) {
final rounded = value.roundToDouble();
return rounded == value
? rounded.toStringAsFixed(0)
: value.toStringAsFixed(1);
}
}
/// Security & Storage: SQLCipher encryption status badge.
///
/// Native builds open the drift database through SQLCipher with a
/// vault-managed AES-256 key; web builds fall back to unencrypted WASM
/// SQLite (SQLCipher cannot run in the browser).
class _SecuritySection extends StatelessWidget {
const _SecuritySection();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const encrypted = !kIsWeb;
return _SettingsCard(
title: 'Security & Storage',
icon: Icons.shield_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
encrypted ? Icons.lock_outline : Icons.lock_open_outlined,
size: 18,
color: encrypted ? Colors.green.shade700 : Colors.orange,
),
const SizedBox(width: 8),
const Flexible(
child: _StatusBadge(
label: encrypted
? 'SQLCipher AES-256 active'
: 'Web storage (unencrypted)',
ok: encrypted,
),
),
],
),
const SizedBox(height: 8),
Text(
encrypted
? 'Health markers are persisted in a SQLCipher-encrypted '
'database; the master key lives in your platform keystore '
'(Keychain / EncryptedSharedPreferences / DPAPI).'
: 'SQLCipher cannot run inside the browser — web builds '
'store data in OPFS/IndexedDB without at-rest '
'encryption. Use a native app for encrypted storage.',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.outline),
),
],
),
);
}
}
/// Integrations: Apple Health / Health Connect / HealthGateway toggles.
class _IntegrationsSection extends ConsumerWidget {
const _IntegrationsSection({required this.state});
final IntegrationsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return _SettingsCard(
title: 'Integrations',
icon: Icons.sync_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.apple),
title: const Text('Apple Health'),
subtitle: const Text('Sync weight & active energy (iOS)'),
value: state.appleHealthEnabled,
onChanged: state.isRequestingPermissions
? null
: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setAppleHealthEnabled(enabled),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.fingerprint),
title: const Text('Health Connect'),
subtitle: const Text('Sync weight & active energy (Android)'),
value: state.healthConnectEnabled,
onChanged: state.isRequestingPermissions
? null
: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setHealthConnectEnabled(enabled),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.cloud_sync_outlined),
title: const Text('HealthGateway'),
subtitle: const Text(
'Cloud partner sync (MyFitnessPal / Withings) with '
'manual-entry fallback',
),
value: state.healthGatewayEnabled,
onChanged: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setHealthGatewayEnabled(enabled),
),
if (state.isRequestingPermissions)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Requesting health permissions…',
style: theme.textTheme.labelSmall,
),
],
),
),
],
),
);
}
}
/// Shared card chrome for a settings section.
class _SettingsCard extends StatelessWidget {
const _SettingsCard({
required this.title,
required this.icon,
required this.child,
});
final String title;
final IconData icon;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
/// Compact colored badge used for capability/status rows.
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.label, required this.ok});
final String label;
final bool ok;
@override
Widget build(BuildContext context) {
final color = ok ? Colors.green.shade100 : Colors.orange.shade100;
final textColor = ok ? Colors.green.shade800 : Colors.orange.shade900;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: textColor,
),
),
);
}
}

View File

@ -0,0 +1,118 @@
// Supabase Edge Function: recipe-proxy
//
// Browsers cannot download recipe pages cross-origin — recipe sites never
// send `Access-Control-Allow-Origin`, so the Recipe Vault's web build needs
// a server-side relay. This function fetches the target URL with
// browser-like headers and returns the HTML with permissive CORS headers.
//
// Deploy (requires the Supabase CLI):
// supabase functions deploy recipe-proxy
// Then run the web app with:
// flutter run -d chrome \
// --dart-define=RECIPE_PROXY_URL=https://<project>.supabase.co/functions/v1/recipe-proxy
//
// Security posture (mirrors the platform's privacy tier):
// * Only http(s) URLs are accepted; localhost and RFC-1918/link-local
// hosts are refused so the function cannot be abused as an SSRF probe
// into internal networks or cloud metadata endpoints.
// * The response body is capped at 2 MiB — recipe pages are far smaller.
// * Only GET semantics are exposed; the function never executes or stores
// anything server-side.
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
};
/// Maximum relayed response size (2 MiB).
const maxBodyBytes = 2 * 1024 * 1024;
/// Browser-like headers so upstream anti-bot checks accept the request.
const browserHeaders = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
};
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const targetParam = new URL(req.url).searchParams.get('url');
if (!targetParam) {
return jsonError(400, 'Missing required "url" query parameter.');
}
let target: URL;
try {
target = new URL(targetParam);
} catch {
return jsonError(400, 'The "url" parameter is not a valid URL.');
}
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
return jsonError(400, 'Only http(s) URLs can be proxied.');
}
if (isPrivateHost(target.hostname)) {
return jsonError(
400,
'Private or loopback hosts cannot be proxied (SSRF protection).',
);
}
try {
const upstream = await fetch(target, {
headers: browserHeaders,
redirect: 'follow',
signal: AbortSignal.timeout(15_000),
});
const declaredLength = Number(upstream.headers.get('content-length') ?? 0);
if (declaredLength > maxBodyBytes) {
return jsonError(413, 'The requested page is too large to proxy.');
}
const body = await upstream.text();
if (body.length > maxBodyBytes) {
return jsonError(413, 'The requested page is too large to proxy.');
}
return new Response(body, {
status: upstream.status,
headers: {
...corsHeaders,
'Content-Type':
upstream.headers.get('content-type') ?? 'text/html; charset=utf-8',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return jsonError(502, `Upstream download failed: ${message}`);
}
});
function jsonError(status: number, message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
/// Refuses loopback, RFC-1918, link-local, and IPv6-loopback hosts so the
/// relay cannot reach internal services or cloud metadata endpoints.
function isPrivateHost(hostname: string): boolean {
const host = hostname.toLowerCase();
if (host === 'localhost' || host === '::1' || host === '[::1]') return true;
if (/^127\./.test(host)) return true;
if (/^10\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
if (/^169\.254\./.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
return false;
}

View File

@ -53,17 +53,17 @@ void main() {
testWidgets(
'short compact window collapses into a scrollable page without overflow',
(tester) async {
await pumpScreen(tester, logicalSize: const Size(480, 400));
await pumpScreen(tester, logicalSize: const Size(480, 320));
expect(find.byType(MealPlannerScreen), findsOneWidget);
expect(find.text('Saved Recipes'), findsOneWidget);
expect(find.text('Today'), findsOneWidget);
expect(tester.takeException(), isNull);
// The whole page scrolls: drag upward and verify the content moved up.
final before = tester.getTopLeft(find.text('Saved Recipes')).dy;
await tester.drag(find.text('Saved Recipes'), const Offset(0, -200));
final before = tester.getTopLeft(find.text('Today')).dy;
await tester.drag(find.text('Today'), const Offset(0, -120));
await tester.pump();
final after = tester.getTopLeft(find.text('Saved Recipes')).dy;
final after = tester.getTopLeft(find.text('Today')).dy;
expect(after, lessThan(before),
reason: 'the page should scroll vertically when the window is short');
@ -83,14 +83,6 @@ void main() {
expect(find.textContaining(label), findsWidgets);
}
expect(tester.takeException(), isNull);
// The outer page scrolls so the calendar area is reachable when the
// window is shorter than the fixed top sections + calendar.
final before = tester.getTopLeft(find.text('Saved Recipes')).dy;
await tester.drag(find.text('Saved Recipes'), const Offset(0, -200));
await tester.pump();
expect(tester.getTopLeft(find.text('Saved Recipes')).dy, lessThan(before));
expect(tester.takeException(), isNull);
await unmountAndFlushTimers(tester);
});
@ -98,7 +90,10 @@ void main() {
(tester) async {
await pumpScreen(tester, logicalSize: const Size(1000, 900));
expect(find.text('Saved Recipes'), findsOneWidget);
expect(find.text('Today'), findsOneWidget);
// 1000 dp is above the 900 dp wide breakpoint, so the full seven-column
// grid renders; empty slots surface the "+ Assign Recipe" affordance.
expect(find.text('Assign Recipe'), findsWidgets);
for (final label in const [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
]) {
@ -116,4 +111,18 @@ void main() {
expect(tester.takeException(), isNull);
await unmountAndFlushTimers(tester);
});
testWidgets('slot tap opens the searchable assign sheet from the vault',
(tester) async {
await pumpScreen(tester, logicalSize: const Size(1400, 900));
// Wide grid: tap the first "+ Assign Recipe" affordance.
await tester.tap(find.text('Assign Recipe').first);
await tester.pumpAndSettle();
expect(find.text('Search Recipe Vault'), findsOneWidget);
expect(find.text('Custom entry (no recipe)'), findsOneWidget);
expect(tester.takeException(), isNull);
await unmountAndFlushTimers(tester);
});
}

View File

@ -382,4 +382,91 @@ void main() {
]);
});
});
group('download hardening', () {
test('native headers carry a browser User-Agent (anti-403 hardening)', () {
final headers = ScrapeRecipeFromUrl.buildScrapeHeaders(isWeb: false);
expect(headers['User-Agent'], startsWith('Mozilla/5.0'));
expect(headers['Accept'], contains('text/html'));
expect(headers['Accept-Language'], 'en-US,en;q=0.9');
});
test('web headers omit the forbidden User-Agent header', () {
final headers = ScrapeRecipeFromUrl.buildScrapeHeaders(isWeb: true);
expect(headers.containsKey('User-Agent'), isFalse);
expect(headers['Accept'], contains('text/html'));
});
test('proxy wrapping percent-encodes the target URL', () {
final wrapped = resolveScrapeRequestUri(
Uri.parse('https://example.com/recipes/chicken?x=1'),
proxyUrl: 'https://proxy.example/fetch',
);
expect(
wrapped.toString(),
'https://proxy.example/fetch'
'?url=https%3A%2F%2Fexample.com%2Frecipes%2Fchicken%3Fx%3D1',
);
});
test('proxy wrapping appends with & when the proxy has its own query', () {
final wrapped = resolveScrapeRequestUri(
Uri.parse('https://example.com/r'),
proxyUrl: 'https://proxy.example/fetch?token=abc',
);
expect(
wrapped.toString(),
'https://proxy.example/fetch?token=abc'
'&url=https%3A%2F%2Fexample.com%2Fr',
);
});
test('without a proxy the target URL is requested directly', () {
final target = Uri.parse('https://example.com/r');
expect(resolveScrapeRequestUri(target, proxyUrl: ''), target);
expect(resolveScrapeRequestUri(target, proxyUrl: ' '), target);
});
test('execute rethrows RecipeScrapeException from the fetcher verbatim',
() async {
final useCase = ScrapeRecipeFromUrl(
fetcher: (url) async =>
throw RecipeScrapeException('Download failed (HTTP 403).'),
);
await expectLater(
useCase.execute('https://example.com/r'),
throwsA(
isA<RecipeScrapeException>().having(
(e) => e.message,
'message',
'Download failed (HTTP 403).',
),
),
);
});
test('execute wraps unexpected fetcher errors with download context',
() async {
final useCase = ScrapeRecipeFromUrl(
fetcher: (url) async => throw Exception('socket closed'),
);
await expectLater(
useCase.execute('https://example.com/r'),
throwsA(
isA<RecipeScrapeException>().having(
(e) => e.message,
'message',
contains('Could not download the page'),
),
),
);
});
});
}

View File

@ -0,0 +1,126 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glp1_companion/core/database/app_database.dart';
import 'package:glp1_companion/core/platform/notification_service.dart';
import 'package:glp1_companion/features/medication/domain/value_objects/glp1_compound.dart';
import 'package:glp1_companion/features/medication/presentation/controllers/medication_controller.dart';
import 'package:glp1_companion/features/medication/presentation/widgets/log_injection_dialog.dart';
/// No-op reminders: `flutter_local_notifications` has no test host.
class _NoopNotificationService implements INotificationService {
@override
Future<void> initialize() async {}
@override
Future<bool> requestPermissions() async => false;
@override
Future<bool> scheduleDoseReminder({
required DateTime scheduledAt,
required String title,
required String body,
int id = kDoseReminderNotificationId,
String? payload,
}) async =>
false;
@override
Future<void> cancelReminder(int id) async {}
@override
Future<void> cancelAllReminders() async {}
}
/// Regression tests for the narrow-mobile overflow: the compound dropdown's
/// long labels ("Tirzepatide (Mounjaro / Zepbound)") must ellipsize instead
/// of painting yellow/black stripes on a phone-width dialog.
///
/// RenderFlex overflows raise a [FlutterError] inside widget tests, so a
/// clean run also proves the stripes are gone.
void main() {
late AppDatabase database;
setUp(() {
database = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() => database.close());
Future<void> pumpDialog(
WidgetTester tester, {
required Size logicalSize,
Glp1Compound? initialCompound,
}) async {
tester.view.physicalSize = logicalSize;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [
appDatabaseProvider.overrideWithValue(database),
notificationServiceProvider
.overrideWithValue(_NoopNotificationService()),
],
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showLogInjectionDialog(
context,
initialCompound: initialCompound,
),
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
testWidgets(
'narrow phone dialog renders the long tirzepatide label without overflow',
(tester) async {
await pumpDialog(
tester,
logicalSize: const Size(320, 640),
initialCompound: Glp1Compound.tirzepatide,
);
// The longest compound label renders (ellipsized if needed).
expect(find.text('Tirzepatide (Mounjaro / Zepbound)'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('narrow phone dialog renders the semaglutide label cleanly',
(tester) async {
await pumpDialog(
tester,
logicalSize: const Size(320, 640),
initialCompound: Glp1Compound.semaglutide,
);
expect(find.text('Semaglutide (Ozempic / Wegovy / Rybelsus)'),
findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('standard mobile width still renders the full dialog',
(tester) async {
await pumpDialog(tester, logicalSize: const Size(390, 844));
// Title + save-button label both read "Log Injection".
expect(find.text('Log Injection'), findsNWidgets(2));
expect(find.text('Injection site'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@ -11,9 +11,11 @@ import 'package:glp1_companion/features/medication/presentation/controllers/medi
import 'package:glp1_companion/main.dart';
/// Shell-level integration tests: the adaptive scaffold must route between
/// every core destination — `/medication`, `/nutrition`, `/meal-planner`,
/// `/pantry` — under both an authenticated session and the debug dev bypass,
/// on desktop (navigation rail) and mobile (bottom navigation bar) layouts.
/// every destination — `/medication`, `/nutrition`, `/exercise`,
/// `/meal-planner`, `/recipes`, `/pantry`, `/settings` — under both an
/// authenticated session and the debug dev bypass, on desktop (seven-item
/// navigation rail) and mobile (bottom navigation bar + secondary
/// "More" drawer) layouts.
///
/// Supabase is never booted: the auth guard reads `isAuthenticatedProvider`
/// (overridden per scenario) and the drift database is an in-memory
@ -89,16 +91,20 @@ void main() {
/// Taps the rail destination [railLabel] and asserts the shell switched to
/// the screen titled [title] with [index] selected.
///
/// [titleMatches] accounts for destinations whose rail label equals the
/// screen title (e.g. "Settings"), which makes [find.text] ambiguous.
Future<void> expectRailHop(
WidgetTester tester, {
required String railLabel,
required int index,
required String title,
int titleMatches = 1,
}) async {
await tester.tap(find.text(railLabel));
await tester.tap(find.text(railLabel).first);
await tester.pumpAndSettle();
expect(find.text(title), findsOneWidget);
expect(find.text(title), findsNWidgets(titleMatches));
expect(
tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex,
index,
@ -114,16 +120,36 @@ void main() {
);
await expectRailHop(
tester,
railLabel: 'Meal Plan',
railLabel: 'Exercise',
index: 2,
title: 'Meal Planner & Import',
title: 'Exercise Log',
);
await expectRailHop(
tester,
railLabel: 'Meal Plan',
index: 3,
title: 'Meal Planner',
);
await expectRailHop(
tester,
railLabel: 'Recipes',
index: 4,
title: 'Recipe Vault',
);
await expectRailHop(
tester,
railLabel: 'Pantry',
index: 3,
index: 5,
title: 'Pantry & Shopping Delta',
);
await expectRailHop(
tester,
railLabel: 'Settings',
index: 6,
// Rail label + screen app-bar title both read "Settings".
title: 'Settings',
titleMatches: 2,
);
await expectRailHop(
tester,
railLabel: 'Medication',
@ -166,7 +192,7 @@ void main() {
await unmountAndFlushTimers(tester);
});
testWidgets('mobile widths use the bottom navigation bar for every route',
testWidgets('mobile widths use the bottom navigation bar for core routes',
(tester) async {
await pumpApp(
tester,
@ -188,12 +214,53 @@ void main() {
}
await tapBar('Food', 1, 'Nutrition & Hydration');
await tapBar('Plan', 2, 'Meal Planner & Import');
await tapBar('Plan', 2, 'Meal Planner');
await tapBar('Pantry', 3, 'Pantry & Shopping Delta');
await tapBar('Meds', 0, 'Medication Tracker');
await unmountAndFlushTimers(tester);
});
testWidgets('mobile "More" tab opens the secondary-destinations drawer',
(tester) async {
await pumpApp(
tester,
viewport: const Size(390, 2400),
extraOverrides: [devAuthBypassProvider.overrideWith((ref) => true)],
);
// Open the drawer via the "More" bottom-bar tab.
await tester.tap(find.text('More'));
await tester.pumpAndSettle();
expect(find.byType(Drawer), findsOneWidget);
// Exercise routes from the drawer and the "More" tab stays selected.
await tester.tap(find.text('Exercise'));
await tester.pumpAndSettle();
expect(find.text('Exercise Log'), findsOneWidget);
expect(
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
4,
);
// Recipes routes from the drawer as well.
await tester.tap(find.text('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Recipes'));
await tester.pumpAndSettle();
expect(find.text('Recipe Vault'), findsOneWidget);
// Settings routes from the drawer as well.
await tester.tap(find.text('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Settings'));
await tester.pumpAndSettle();
expect(find.text('Identity'), findsOneWidget);
expect(find.text('Targets'), findsOneWidget);
expect(find.text('Security & Storage'), findsOneWidget);
expect(find.text('Integrations'), findsOneWidget);
await unmountAndFlushTimers(tester);
});
testWidgets('unauthenticated sessions without bypass are bounced to /login',
(tester) async {
await pumpApp(tester);

13572
web/drift_worker.js Normal file

File diff suppressed because one or more lines are too long

BIN
web/sqlite3.wasm Normal file

Binary file not shown.