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:
parent
51364a8f4f
commit
837b4434f0
|
|
@ -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)
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -117,6 +117,23 @@ dart run build_runner build --delete-conflicting-outputs # regenerate drift co
|
|||
flutter run -d windows # or: chrome / macos / android / ios
|
||||
```
|
||||
|
||||
### Web recipe imports (CORS proxy)
|
||||
|
||||
Browsers cannot download recipe pages cross-origin — recipe sites never send
|
||||
`Access-Control-Allow-Origin`, so the Recipe Vault's URL import needs a
|
||||
server-side relay on web builds (desktop/mobile import directly and need
|
||||
nothing extra). The repository ships one as a Supabase Edge Function:
|
||||
|
||||
```bash
|
||||
supabase functions deploy recipe-proxy
|
||||
|
||||
flutter run -d chrome \
|
||||
--dart-define=RECIPE_PROXY_URL=https://<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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}).',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
Loading…
Reference in New Issue