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
|
# Build Execution Ledger
|
||||||
|
|
||||||
## Active Target
|
## Active Target
|
||||||
- **Current Milestone:** Phase 6: Cross-Platform Hardening, Integration Tests & Forgejo CI/CD — COMPLETE
|
- **Current Milestone:** Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings) — COMPLETE
|
||||||
- **Status:** Phase 6 Complete (release-ready: all phases 1–6 done)
|
- **Status:** Phase 7 Complete (release-ready: all phases 1–7 done)
|
||||||
|
|
||||||
## Phase Checklist
|
## Phase Checklist
|
||||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
- [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 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||||
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
- [x] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||||
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
- [x] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||||
|
- [x] Phase 7: Navigation & Domain Expansion (Exercise, Recipe Vault, Settings)
|
||||||
|
|
||||||
## Phase 2 Deliverables (2026-10-16)
|
## 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.
|
- [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] 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.
|
- [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] `dart run build_runner build --delete-conflicting-outputs` (44 outputs written)
|
||||||
- [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`)
|
- [x] `flutter analyze --fatal-infos` (Zero issues — `No issues found!`)
|
||||||
- [x] `flutter test` (123/123 passing)
|
- [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
|
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
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ android {
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
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
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +23,11 @@ android {
|
||||||
applicationId = "com.glpcompanion.glpcompanion"
|
applicationId = "com.glpcompanion.glpcompanion"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// 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
|
targetSdk = flutter.targetSdkVersion
|
||||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
// 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)
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
|
@ -47,3 +55,7 @@ kotlin {
|
||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,8 @@ android.useAndroidX=true
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
# This builtInKotlin flag was added by the Flutter template
|
# This builtInKotlin flag was added by the Flutter template
|
||||||
android.builtInKotlin=false
|
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/auth_controller.dart';
|
||||||
import '../../features/auth/presentation/controllers/dev_auth_bypass.dart';
|
import '../../features/auth/presentation/controllers/dev_auth_bypass.dart';
|
||||||
import '../../features/auth/presentation/screens/login_screen.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/medication/presentation/screens/medication_screen.dart';
|
||||||
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
|
import '../../features/nutrition/presentation/screens/nutrition_screen.dart';
|
||||||
import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart';
|
import '../../features/meal_planner/presentation/screens/meal_planner_screen.dart';
|
||||||
import '../../features/pantry/presentation/screens/pantry_screen.dart';
|
import '../../features/pantry/presentation/screens/pantry_screen.dart';
|
||||||
|
import '../../features/recipes/presentation/screens/recipes_screen.dart';
|
||||||
|
import '../../features/settings/presentation/screens/settings_screen.dart';
|
||||||
import '../scaffold/adaptive_home_scaffold.dart';
|
import '../scaffold/adaptive_home_scaffold.dart';
|
||||||
|
|
||||||
/// Locations that may be visited without an authenticated session.
|
/// Locations that may be visited without an authenticated session.
|
||||||
|
|
@ -63,14 +66,26 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
path: '/nutrition',
|
path: '/nutrition',
|
||||||
builder: (context, state) => const NutritionScreen(),
|
builder: (context, state) => const NutritionScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/exercise',
|
||||||
|
builder: (context, state) => const ExerciseScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/meal-planner',
|
path: '/meal-planner',
|
||||||
builder: (context, state) => const MealPlannerScreen(),
|
builder: (context, state) => const MealPlannerScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/recipes',
|
||||||
|
builder: (context, state) => const RecipesScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/pantry',
|
path: '/pantry',
|
||||||
builder: (context, state) => const PantryScreen(),
|
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';
|
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 {
|
class AdaptiveHomeScaffold extends ConsumerWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
|
|
@ -13,16 +23,168 @@ class AdaptiveHomeScaffold extends ConsumerWidget {
|
||||||
required this.child,
|
required this.child,
|
||||||
});
|
});
|
||||||
|
|
||||||
int _calculateSelectedIndex(BuildContext context) {
|
/// Key for the mobile scaffold so the "More" bottom-bar tab can open the
|
||||||
final location = GoRouterState.of(context).uri.toString();
|
/// 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('/medication')) return 0;
|
||||||
if (location.startsWith('/nutrition')) return 1;
|
if (location.startsWith('/nutrition')) return 1;
|
||||||
if (location.startsWith('/meal-planner')) return 2;
|
if (location.startsWith('/meal-planner')) return 2;
|
||||||
if (location.startsWith('/pantry')) return 3;
|
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');
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
context.go('/nutrition');
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
final isDesktop = width >= 1024;
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
if (showDevBanner) const _DevAccessBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(thickness: 1, width: 1),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: (index) {
|
||||||
|
if (index == 4) {
|
||||||
|
// "More": open the drawer with secondary destinations.
|
||||||
|
_mobileScaffoldKey.currentState?.openDrawer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
context.go('/medication');
|
context.go('/medication');
|
||||||
|
|
@ -37,77 +199,11 @@ class AdaptiveHomeScaffold extends ConsumerWidget {
|
||||||
context.go('/pantry');
|
context.go('/pantry');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final width = MediaQuery.of(context).size.width;
|
|
||||||
final isDesktop = width >= 1024;
|
|
||||||
final selectedIndex = _calculateSelectedIndex(context);
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (isDesktop) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
if (showDevBanner) const _DevAccessBanner(),
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
if (showDevBanner) const _DevAccessBanner(),
|
|
||||||
Expanded(child: child),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
bottomNavigationBar: NavigationBar(
|
|
||||||
selectedIndex: selectedIndex,
|
|
||||||
onDestinationSelected: (idx) => _onItemTapped(idx, context),
|
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.medication_outlined),
|
icon: Icon(Icons.medication_liquid_outlined),
|
||||||
selectedIcon: Icon(Icons.medication),
|
selectedIcon: Icon(Icons.medication_liquid),
|
||||||
label: 'Meds',
|
label: 'Meds',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
|
|
@ -121,16 +217,80 @@ class AdaptiveHomeScaffold extends ConsumerWidget {
|
||||||
label: 'Plan',
|
label: 'Plan',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.kitchen_outlined),
|
icon: Icon(Icons.inventory_2_outlined),
|
||||||
selectedIcon: Icon(Icons.kitchen),
|
selectedIcon: Icon(Icons.inventory_2),
|
||||||
label: 'Pantry',
|
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.
|
/// Slim status strip shown while the development auth bypass is active.
|
||||||
class _DevAccessBanner extends StatelessWidget {
|
class _DevAccessBanner extends StatelessWidget {
|
||||||
const _DevAccessBanner();
|
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.
|
/// without touching the network.
|
||||||
typedef RecipeHtmlFetcher = Future<String> Function(Uri url);
|
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
|
/// Raised when a URL cannot be fetched or carries no recognizable recipe
|
||||||
/// metadata (neither schema.org/JSON-LD nor OpenGraph).
|
/// metadata (neither schema.org/JSON-LD nor OpenGraph).
|
||||||
class RecipeScrapeException implements Exception {
|
class RecipeScrapeException implements Exception {
|
||||||
|
|
@ -30,7 +65,11 @@ class RecipeScrapeException implements Exception {
|
||||||
/// Imports a recipe from a web page.
|
/// Imports a recipe from a web page.
|
||||||
///
|
///
|
||||||
/// Pipeline (mirrors the Phase 4 architecture spec):
|
/// 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`.
|
/// 2. Parse the DOM with `package:html`.
|
||||||
/// 3. Extract every `<script type="application/ld+json">` node and locate a
|
/// 3. Extract every `<script type="application/ld+json">` node and locate a
|
||||||
/// `"@type": "Recipe"` object — searched at the document root, inside
|
/// `"@type": "Recipe"` object — searched at the document root, inside
|
||||||
|
|
@ -52,11 +91,52 @@ class ScrapeRecipeFromUrl {
|
||||||
|
|
||||||
final RecipeHtmlFetcher _fetcher;
|
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 {
|
static Future<String> _httpGetFetcher(Uri url) async {
|
||||||
final response = await http.get(
|
final http.Response response;
|
||||||
url,
|
try {
|
||||||
headers: {'Accept': 'text/html,application/xhtml+xml'},
|
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) {
|
if (response.statusCode != 200) {
|
||||||
throw RecipeScrapeException(
|
throw RecipeScrapeException(
|
||||||
'Download failed (HTTP ${response.statusCode}).',
|
'Download failed (HTTP ${response.statusCode}).',
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,15 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../nutrition/domain/entities/meal_type.dart';
|
import '../../../nutrition/domain/entities/meal_type.dart';
|
||||||
import '../../domain/entities/recipe.dart';
|
|
||||||
import '../controllers/meal_plan_calendar_controller.dart';
|
import '../controllers/meal_plan_calendar_controller.dart';
|
||||||
import '../controllers/recipe_import_controller.dart';
|
import '../controllers/recipe_import_controller.dart';
|
||||||
import '../widgets/assign_slot_dialog.dart';
|
import '../widgets/assign_recipe_sheet.dart';
|
||||||
import '../widgets/recipe_preview_dialog.dart';
|
|
||||||
import '../widgets/saved_recipes_tray.dart';
|
|
||||||
import '../widgets/weekly_calendar_view.dart';
|
import '../widgets/weekly_calendar_view.dart';
|
||||||
|
|
||||||
/// GLP-1 meal planner: recipe URL import bar, saved-recipes tray, and the
|
/// GLP-1 meal planner: the responsive 7-day weekly calendar (Monday–Sunday)
|
||||||
/// responsive weekly calendar with drag-and-drop assignment and per-day
|
/// with per-day protein rollups. Recipe import and browsing live in the
|
||||||
/// protein rollups.
|
/// Recipe Vault; slots are filled via the searchable "+ Assign Recipe"
|
||||||
|
/// sheet.
|
||||||
class MealPlannerScreen extends ConsumerStatefulWidget {
|
class MealPlannerScreen extends ConsumerStatefulWidget {
|
||||||
const MealPlannerScreen({super.key});
|
const MealPlannerScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -22,49 +20,19 @@ class MealPlannerScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||||
/// Body height below which the page collapses into a single scrollable
|
/// Body height below which the page collapses into a single scrollable
|
||||||
/// column: fixed top sections (~340 dp) plus a minimal usable calendar
|
/// column: the week header plus a minimal usable calendar area. Below
|
||||||
/// area. Below this, a plain `Column` would overflow and paint the
|
/// this, a plain `Column` would overflow and paint the yellow/black
|
||||||
/// yellow/black hatched stripes.
|
/// hatched stripes.
|
||||||
static const double _kFullScrollThreshold = 520;
|
static const double _kFullScrollThreshold = 360;
|
||||||
|
|
||||||
/// Calendar height used in the fully-scrollable fallback layout; tall
|
/// Calendar height used in the fully-scrollable fallback layout; tall
|
||||||
/// enough for the day header plus four meal slots.
|
/// enough for the day header plus four meal slots.
|
||||||
static const double _kCalendarScrollHeight = 560;
|
static const double _kCalendarScrollHeight = 560;
|
||||||
|
|
||||||
final TextEditingController _urlController = TextEditingController();
|
|
||||||
bool _previewDialogVisible = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_urlController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final importState = ref.watch(recipeImportControllerProvider);
|
|
||||||
final calendarState = ref.watch(mealPlanCalendarControllerProvider);
|
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) {
|
ref.listen(mealPlanCalendarControllerProvider, (previous, next) {
|
||||||
if (next.errorMessage != null &&
|
if (next.errorMessage != null &&
|
||||||
next.errorMessage != previous?.errorMessage) {
|
next.errorMessage != previous?.errorMessage) {
|
||||||
|
|
@ -73,19 +41,19 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Meal Planner & Import')),
|
appBar: AppBar(title: const Text('Meal Planner')),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// Short windows (e.g. resized desktop windows) can no longer fit
|
// Short windows (e.g. resized desktop windows) can no longer fit
|
||||||
// the fixed top sections plus a useful calendar area; collapse the
|
// a useful calendar area; collapse the whole page into a single
|
||||||
// whole page into a single scroll view instead of overflowing.
|
// scroll view instead of overflowing.
|
||||||
if (constraints.maxHeight < _kFullScrollThreshold) {
|
if (constraints.maxHeight < _kFullScrollThreshold) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildTopSections(importState, calendarState),
|
_WeekNavigationHeader(state: calendarState),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: _kCalendarScrollHeight,
|
height: _kCalendarScrollHeight,
|
||||||
child: _buildCalendar(calendarState),
|
child: _buildCalendar(calendarState),
|
||||||
|
|
@ -98,7 +66,8 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildTopSections(importState, calendarState),
|
_WeekNavigationHeader(state: calendarState),
|
||||||
|
const Divider(height: 1),
|
||||||
Expanded(child: _buildCalendar(calendarState)),
|
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).
|
/// Weekly calendar grid (or the initial loading spinner).
|
||||||
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
|
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
|
||||||
!calendarState.hasLoaded
|
!calendarState.hasLoaded
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: WeeklyCalendarView(
|
: WeeklyCalendarView(
|
||||||
state: calendarState,
|
state: calendarState,
|
||||||
onRecipeDropped: _onRecipeDropped,
|
|
||||||
onSlotTapped: _onSlotTapped,
|
onSlotTapped: _onSlotTapped,
|
||||||
onTogglePrepared: (slot) => ref
|
onTogglePrepared: (slot) => ref
|
||||||
.read(mealPlanCalendarControllerProvider.notifier)
|
.read(mealPlanCalendarControllerProvider.notifier)
|
||||||
|
|
@ -156,35 +100,11 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitUrl() async {
|
/// Opens the searchable assign sheet, populated from the Recipe Vault.
|
||||||
final url = _urlController.text;
|
Future<void> _onSlotTapped(DateTime day, MealType mealType) async {
|
||||||
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) {
|
|
||||||
final calendarState = ref.read(mealPlanCalendarControllerProvider);
|
final calendarState = ref.read(mealPlanCalendarControllerProvider);
|
||||||
final importState = ref.read(recipeImportControllerProvider);
|
final importState = ref.read(recipeImportControllerProvider);
|
||||||
showAssignSlotDialog(
|
await showAssignRecipeSheet(
|
||||||
context,
|
context,
|
||||||
day: day,
|
day: day,
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
|
|
@ -192,79 +112,6 @@ class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||||
existingSlot: calendarState.slotFor(day, mealType),
|
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
|
/// 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)
|
ProviderScope.containerOf(context, listen: false)
|
||||||
.read(mealPlanCalendarControllerProvider.notifier);
|
.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;
|
/// 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
|
/// 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
|
/// 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
|
/// target, and every meal slot offers a "+ Assign Recipe" action that opens
|
||||||
/// (`Draggable`/`DragTarget`) as well as tappable for the quick-assign
|
/// the searchable Recipe Vault sheet.
|
||||||
/// modal.
|
|
||||||
class WeeklyCalendarView extends StatelessWidget {
|
class WeeklyCalendarView extends StatelessWidget {
|
||||||
const WeeklyCalendarView({
|
const WeeklyCalendarView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.onRecipeDropped,
|
|
||||||
required this.onSlotTapped,
|
required this.onSlotTapped,
|
||||||
required this.onTogglePrepared,
|
required this.onTogglePrepared,
|
||||||
required this.onClearSlot,
|
required this.onClearSlot,
|
||||||
|
|
@ -25,11 +23,7 @@ class WeeklyCalendarView extends StatelessWidget {
|
||||||
|
|
||||||
final MealPlanCalendarState state;
|
final MealPlanCalendarState state;
|
||||||
|
|
||||||
/// A tray recipe was dropped onto [day]'s [mealType] slot.
|
/// The slot was tapped — open the searchable assign sheet.
|
||||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
|
||||||
onRecipeDropped;
|
|
||||||
|
|
||||||
/// The slot was tapped — open the quick-assign modal.
|
|
||||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||||
|
|
||||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||||
|
|
@ -61,7 +55,6 @@ class WeeklyCalendarView extends StatelessWidget {
|
||||||
child: _DayColumn(
|
child: _DayColumn(
|
||||||
day: day,
|
day: day,
|
||||||
state: state,
|
state: state,
|
||||||
onRecipeDropped: onRecipeDropped,
|
|
||||||
onSlotTapped: onSlotTapped,
|
onSlotTapped: onSlotTapped,
|
||||||
onTogglePrepared: onTogglePrepared,
|
onTogglePrepared: onTogglePrepared,
|
||||||
onClearSlot: onClearSlot,
|
onClearSlot: onClearSlot,
|
||||||
|
|
@ -75,7 +68,6 @@ class WeeklyCalendarView extends StatelessWidget {
|
||||||
return _CompactWeekPager(
|
return _CompactWeekPager(
|
||||||
days: days,
|
days: days,
|
||||||
state: state,
|
state: state,
|
||||||
onRecipeDropped: onRecipeDropped,
|
|
||||||
onSlotTapped: onSlotTapped,
|
onSlotTapped: onSlotTapped,
|
||||||
onTogglePrepared: onTogglePrepared,
|
onTogglePrepared: onTogglePrepared,
|
||||||
onClearSlot: onClearSlot,
|
onClearSlot: onClearSlot,
|
||||||
|
|
@ -90,7 +82,6 @@ class _CompactWeekPager extends StatefulWidget {
|
||||||
const _CompactWeekPager({
|
const _CompactWeekPager({
|
||||||
required this.days,
|
required this.days,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.onRecipeDropped,
|
|
||||||
required this.onSlotTapped,
|
required this.onSlotTapped,
|
||||||
required this.onTogglePrepared,
|
required this.onTogglePrepared,
|
||||||
required this.onClearSlot,
|
required this.onClearSlot,
|
||||||
|
|
@ -98,8 +89,6 @@ class _CompactWeekPager extends StatefulWidget {
|
||||||
|
|
||||||
final List<DateTime> days;
|
final List<DateTime> days;
|
||||||
final MealPlanCalendarState state;
|
final MealPlanCalendarState state;
|
||||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
|
||||||
onRecipeDropped;
|
|
||||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||||
final void Function(MealPlanSlot slot) onClearSlot;
|
final void Function(MealPlanSlot slot) onClearSlot;
|
||||||
|
|
@ -148,7 +137,6 @@ class _CompactWeekPagerState extends State<_CompactWeekPager>
|
||||||
child: _DayColumn(
|
child: _DayColumn(
|
||||||
day: day,
|
day: day,
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
onRecipeDropped: widget.onRecipeDropped,
|
|
||||||
onSlotTapped: widget.onSlotTapped,
|
onSlotTapped: widget.onSlotTapped,
|
||||||
onTogglePrepared: widget.onTogglePrepared,
|
onTogglePrepared: widget.onTogglePrepared,
|
||||||
onClearSlot: widget.onClearSlot,
|
onClearSlot: widget.onClearSlot,
|
||||||
|
|
@ -169,7 +157,6 @@ class _DayColumn extends StatelessWidget {
|
||||||
const _DayColumn({
|
const _DayColumn({
|
||||||
required this.day,
|
required this.day,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.onRecipeDropped,
|
|
||||||
required this.onSlotTapped,
|
required this.onSlotTapped,
|
||||||
required this.onTogglePrepared,
|
required this.onTogglePrepared,
|
||||||
required this.onClearSlot,
|
required this.onClearSlot,
|
||||||
|
|
@ -177,8 +164,6 @@ class _DayColumn extends StatelessWidget {
|
||||||
|
|
||||||
final DateTime day;
|
final DateTime day;
|
||||||
final MealPlanCalendarState state;
|
final MealPlanCalendarState state;
|
||||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
|
||||||
onRecipeDropped;
|
|
||||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||||
final void Function(MealPlanSlot slot) onClearSlot;
|
final void Function(MealPlanSlot slot) onClearSlot;
|
||||||
|
|
@ -194,6 +179,10 @@ class _DayColumn extends StatelessWidget {
|
||||||
final target = state.dailyProteinTargetGrams;
|
final target = state.dailyProteinTargetGrams;
|
||||||
final progress = state.proteinProgressForDay(day).clamp(0.0, 1.0);
|
final progress = state.proteinProgressForDay(day).clamp(0.0, 1.0);
|
||||||
final reachedGoal = protein >= target;
|
final reachedGoal = protein >= target;
|
||||||
|
final roundedProtein = protein.roundToDouble();
|
||||||
|
final proteinLabel = roundedProtein == protein
|
||||||
|
? roundedProtein.toStringAsFixed(0)
|
||||||
|
: protein.toStringAsFixed(1);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -203,32 +192,23 @@ class _DayColumn extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
'${_weekdayLabels[day.weekday - 1]} ${day.day}',
|
|
||||||
style: theme.textTheme.titleSmall
|
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
|
||||||
Icons.fitness_center,
|
|
||||||
size: 14,
|
|
||||||
color: reachedGoal ? Colors.green : theme.colorScheme.outline,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${protein.toStringAsFixed(0)}/'
|
_weekdayLabels[day.weekday - 1],
|
||||||
'${target.toStringAsFixed(0)} g',
|
style: theme.textTheme.labelLarge
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
color: reachedGoal
|
|
||||||
? Colors.green.shade800
|
|
||||||
: theme.colorScheme.outline,
|
|
||||||
fontWeight:
|
|
||||||
reachedGoal ? FontWeight.bold : FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
const Divider(height: 12),
|
||||||
for (final mealType in MealType.values) ...[
|
for (final mealType in MealType.values) ...[
|
||||||
_DaySlotCard(
|
_DaySlotCard(
|
||||||
day: day,
|
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
slot: state.slotFor(day, mealType),
|
slot: state.slotFor(day, mealType),
|
||||||
recipe: _recipeForSlot(state.slotFor(day, mealType)),
|
recipe: _recipeForSlot(state.slotFor(day, mealType)),
|
||||||
onRecipeDropped: onRecipeDropped,
|
|
||||||
onTap: () => onSlotTapped(day, mealType),
|
onTap: () => onSlotTapped(day, mealType),
|
||||||
onTogglePrepared: onTogglePrepared,
|
onTogglePrepared: onTogglePrepared,
|
||||||
onClearSlot: onClearSlot,
|
onClearSlot: onClearSlot,
|
||||||
|
|
@ -267,27 +245,22 @@ class _DayColumn extends StatelessWidget {
|
||||||
slot == null ? null : state.recipeFor(slot);
|
slot == null ? null : state.recipeFor(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single meal slot: drop target for tray drags and tap target for the
|
/// A single meal slot: tap target for the searchable assign sheet; shows
|
||||||
/// quick-assign modal; shows the assigned recipe (with protein badge) or a
|
/// the assigned recipe (with protein badge) or a free-text note, a
|
||||||
/// free-text note, a prepared checkbox, and a clear action.
|
/// prepared checkbox, and a clear action.
|
||||||
class _DaySlotCard extends StatelessWidget {
|
class _DaySlotCard extends StatelessWidget {
|
||||||
const _DaySlotCard({
|
const _DaySlotCard({
|
||||||
required this.day,
|
|
||||||
required this.mealType,
|
required this.mealType,
|
||||||
required this.slot,
|
required this.slot,
|
||||||
required this.recipe,
|
required this.recipe,
|
||||||
required this.onRecipeDropped,
|
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onTogglePrepared,
|
required this.onTogglePrepared,
|
||||||
required this.onClearSlot,
|
required this.onClearSlot,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DateTime day;
|
|
||||||
final MealType mealType;
|
final MealType mealType;
|
||||||
final MealPlanSlot? slot;
|
final MealPlanSlot? slot;
|
||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
|
||||||
onRecipeDropped;
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||||
final void Function(MealPlanSlot slot) onClearSlot;
|
final void Function(MealPlanSlot slot) onClearSlot;
|
||||||
|
|
@ -311,15 +284,8 @@ class _DaySlotCard extends StatelessWidget {
|
||||||
final currentSlot = slot;
|
final currentSlot = slot;
|
||||||
final filled = currentSlot != null && !currentSlot.isEmpty;
|
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(
|
return Material(
|
||||||
color: highlighted
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
? theme.colorScheme.primaryContainer
|
|
||||||
: theme.colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -331,8 +297,6 @@ class _DaySlotCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(
|
Widget _buildContent(
|
||||||
|
|
@ -409,15 +373,24 @@ class _DaySlotCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
] else
|
] else
|
||||||
Text(
|
Row(
|
||||||
'Tap or drop a recipe',
|
children: [
|
||||||
|
Icon(Icons.add_circle_outline,
|
||||||
|
size: 14, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Assign Recipe',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
fontStyle: FontStyle.italic,
|
fontWeight: FontWeight.w600,
|
||||||
color: theme.colorScheme.outline,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ class _LogInjectionDialogState extends ConsumerState<LogInjectionDialog> {
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<Glp1Compound>(
|
DropdownButtonFormField<Glp1Compound>(
|
||||||
initialValue: _compound,
|
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(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Compound',
|
labelText: 'Compound',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
|
|
@ -83,6 +87,8 @@ class _LogInjectionDialogState extends ConsumerState<LogInjectionDialog> {
|
||||||
value: compound,
|
value: compound,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${compound.displayName} (${compound.brandNames})',
|
'${compound.displayName} (${compound.brandNames})',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -105,12 +105,47 @@ class NutritionController extends AsyncNotifier<NutritionState> {
|
||||||
/// Phase 3 default daily hydration goal.
|
/// Phase 3 default daily hydration goal.
|
||||||
static const double defaultHydrationTargetOz = 96;
|
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
|
@override
|
||||||
Future<NutritionState> build() async {
|
Future<NutritionState> build() async {
|
||||||
final repository = ref.watch(nutritionRepositoryProvider);
|
final repository = ref.watch(nutritionRepositoryProvider);
|
||||||
return _load(repository, day: DateTime.now());
|
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.
|
/// Persists a meal entry and refreshes the daily snapshot.
|
||||||
///
|
///
|
||||||
/// Returns whether the write succeeded; failures keep the last good state.
|
/// Returns whether the write succeeded; failures keep the last good state.
|
||||||
|
|
@ -214,8 +249,8 @@ class NutritionController extends AsyncNotifier<NutritionState> {
|
||||||
foodLogs: foodLogs,
|
foodLogs: foodLogs,
|
||||||
hydrationLogs: hydrationLogs,
|
hydrationLogs: hydrationLogs,
|
||||||
day: day,
|
day: day,
|
||||||
proteinTargetGrams: defaultProteinTargetGrams,
|
proteinTargetGrams: _proteinTargetGrams,
|
||||||
hydrationTargetOz: defaultHydrationTargetOz,
|
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(
|
testWidgets(
|
||||||
'short compact window collapses into a scrollable page without overflow',
|
'short compact window collapses into a scrollable page without overflow',
|
||||||
(tester) async {
|
(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.byType(MealPlannerScreen), findsOneWidget);
|
||||||
expect(find.text('Saved Recipes'), findsOneWidget);
|
expect(find.text('Today'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
// The whole page scrolls: drag upward and verify the content moved up.
|
// The whole page scrolls: drag upward and verify the content moved up.
|
||||||
final before = tester.getTopLeft(find.text('Saved Recipes')).dy;
|
final before = tester.getTopLeft(find.text('Today')).dy;
|
||||||
await tester.drag(find.text('Saved Recipes'), const Offset(0, -200));
|
await tester.drag(find.text('Today'), const Offset(0, -120));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
final after = tester.getTopLeft(find.text('Saved Recipes')).dy;
|
final after = tester.getTopLeft(find.text('Today')).dy;
|
||||||
|
|
||||||
expect(after, lessThan(before),
|
expect(after, lessThan(before),
|
||||||
reason: 'the page should scroll vertically when the window is short');
|
reason: 'the page should scroll vertically when the window is short');
|
||||||
|
|
@ -83,14 +83,6 @@ void main() {
|
||||||
expect(find.textContaining(label), findsWidgets);
|
expect(find.textContaining(label), findsWidgets);
|
||||||
}
|
}
|
||||||
expect(tester.takeException(), isNull);
|
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);
|
await unmountAndFlushTimers(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -98,7 +90,10 @@ void main() {
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpScreen(tester, logicalSize: const Size(1000, 900));
|
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 [
|
for (final label in const [
|
||||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
||||||
]) {
|
]) {
|
||||||
|
|
@ -116,4 +111,18 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
await unmountAndFlushTimers(tester);
|
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';
|
import 'package:glp1_companion/main.dart';
|
||||||
|
|
||||||
/// Shell-level integration tests: the adaptive scaffold must route between
|
/// Shell-level integration tests: the adaptive scaffold must route between
|
||||||
/// every core destination — `/medication`, `/nutrition`, `/meal-planner`,
|
/// every destination — `/medication`, `/nutrition`, `/exercise`,
|
||||||
/// `/pantry` — under both an authenticated session and the debug dev bypass,
|
/// `/meal-planner`, `/recipes`, `/pantry`, `/settings` — under both an
|
||||||
/// on desktop (navigation rail) and mobile (bottom navigation bar) layouts.
|
/// 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`
|
/// Supabase is never booted: the auth guard reads `isAuthenticatedProvider`
|
||||||
/// (overridden per scenario) and the drift database is an in-memory
|
/// (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
|
/// Taps the rail destination [railLabel] and asserts the shell switched to
|
||||||
/// the screen titled [title] with [index] selected.
|
/// 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(
|
Future<void> expectRailHop(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required String railLabel,
|
required String railLabel,
|
||||||
required int index,
|
required int index,
|
||||||
required String title,
|
required String title,
|
||||||
|
int titleMatches = 1,
|
||||||
}) async {
|
}) async {
|
||||||
await tester.tap(find.text(railLabel));
|
await tester.tap(find.text(railLabel).first);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text(title), findsOneWidget);
|
expect(find.text(title), findsNWidgets(titleMatches));
|
||||||
expect(
|
expect(
|
||||||
tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex,
|
tester.widget<NavigationRail>(find.byType(NavigationRail)).selectedIndex,
|
||||||
index,
|
index,
|
||||||
|
|
@ -114,16 +120,36 @@ void main() {
|
||||||
);
|
);
|
||||||
await expectRailHop(
|
await expectRailHop(
|
||||||
tester,
|
tester,
|
||||||
railLabel: 'Meal Plan',
|
railLabel: 'Exercise',
|
||||||
index: 2,
|
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(
|
await expectRailHop(
|
||||||
tester,
|
tester,
|
||||||
railLabel: 'Pantry',
|
railLabel: 'Pantry',
|
||||||
index: 3,
|
index: 5,
|
||||||
title: 'Pantry & Shopping Delta',
|
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(
|
await expectRailHop(
|
||||||
tester,
|
tester,
|
||||||
railLabel: 'Medication',
|
railLabel: 'Medication',
|
||||||
|
|
@ -166,7 +192,7 @@ void main() {
|
||||||
await unmountAndFlushTimers(tester);
|
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 {
|
(tester) async {
|
||||||
await pumpApp(
|
await pumpApp(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -188,12 +214,53 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
await tapBar('Food', 1, 'Nutrition & Hydration');
|
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('Pantry', 3, 'Pantry & Shopping Delta');
|
||||||
await tapBar('Meds', 0, 'Medication Tracker');
|
await tapBar('Meds', 0, 'Medication Tracker');
|
||||||
await unmountAndFlushTimers(tester);
|
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',
|
testWidgets('unauthenticated sessions without bypass are bounced to /login',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Loading…
Reference in New Issue