feat(meal_planner): complete Phase 4 recipe scraper and meal planning calendar
Domain (pure Dart): - Add Recipe, MacroProfile, and RecipeIngredient entities (quantity/unit/ item parsing: decimals, ASCII/unicode/mixed fractions, known-unit vocabulary) plus MealPlanSlot reusing the nutrition MealType enum - Define IRecipeRepository and IMealPlanRepository contracts - Add ScrapeRecipeFromUrl use case: package:http download with injectable fetcher, package:html DOM parse, JSON-LD @graph/root/array/list-@type navigation, HowToStep/HowToSection flattening, ISO-8601 prep/cook times, recipeYield, regex nutrition parsing, OpenGraph fallback, and typed RecipeScrapeException - Add core/utils/uuid.dart RFC-4122 v4 generator (no new dependency) Data: - Bump AppDatabase to schema v3 (migration for v1/v2 installs) with RecipesTable (UUID PK, JSON ingredient/instruction columns, macros) and MealPlanSlotsTable (UUID PK, nullable recipeId FK ON DELETE SET NULL) - Implement DriftRecipeRepository (URL-deduplicated importRecipe, createdAt preserved on re-import) and DriftMealPlanRepository (half-open [from, to) window, upsert by id) Presentation: - Add RecipeImportController (URL submit -> loading -> preview modal state, live catalog watch stream, confirm-save/dismiss/delete) and MealPlanCalendarController (Monday-Sunday window navigation, slot assign/upsert/clear, prepared toggles, per-day protein rollup against the 100 g target, deleted-recipe slot normalization) - Rework MealPlannerScreen: URL import bar with spinner, RecipePreviewDialog (macros, ingredients, steps, save with error surface), draggable SavedRecipesTray with protein badges, responsive WeeklyCalendarView (>=900 dp: 7-column grid, below: tabbed pager) with DragTarget slots and daily protein indicators, AssignSlotDialog quick-assign modal Fixes: - Eliminate desktop resize overflow (RenderFlex hatched stripes): wide-mode day columns scroll vertically, and a LayoutBuilder collapses the page into a single scrollable column with a fixed-height calendar when the window body is shorter than ~520 dp Testing: - 29 new tests: recipe scraper (JSON-LD variants, macro/ingredient/ instruction/duration parsing, OG fallback, execute error mapping), meal-plan repositories (window filter, upsert, dedup), calendar controller (rollup, navigation, prepared/custom entries), and screen widget tests pumping short-compact/short-wide/tall/extremely-short window sizes to prove no overflow; full suite green (89/89) - BUILD_STATE.md: mark Phase 4 complete and record all verification gates
This commit is contained in:
parent
094cf08f25
commit
bdee2de35f
|
|
@ -1,14 +1,14 @@
|
|||
# Build Execution Ledger
|
||||
|
||||
## Active Target
|
||||
- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||
- **Status:** Phase 3 Complete
|
||||
- **Current Milestone:** Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar — COMPLETE
|
||||
- **Status:** Phase 4 Complete (next: Phase 5)
|
||||
|
||||
## Phase Checklist
|
||||
- [x] Phase 1: Foundation & Identity (Passwordless SSO & Encrypted Drift DB)
|
||||
- [x] Phase 2: Medication Domain, Injection Rotation, Serum Modeling
|
||||
- [x] Phase 3: Nutrition, Protein Target Tracking, HealthGateway Sync
|
||||
- [ ] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||
- [x] Phase 4: Recipe Web Scraper Engine & Meal Planning Calendar
|
||||
- [ ] Phase 5: Pantry Delta Engine & Auto-Shopping Generator
|
||||
- [ ] Phase 6: Hardening, Native Desktop/Web Polish, CI/CD
|
||||
|
||||
|
|
@ -33,8 +33,20 @@
|
|||
- [x] Tests: 23 new tests — nutrition repository (in-memory drift: CRUD, day-range filter, watch, `MealType` round-trip), nutrition controller (targets, grouping, validation, quick-add, delete, failure retention), biometric controller (manual entry, deltas, fallback short-circuit, permission denial, weight+energy sync idempotency).
|
||||
- [x] Regression fix: none required — existing 37 tests untouched and green.
|
||||
|
||||
## Phase 4 Deliverables (2026-10-16)
|
||||
- [x] Domain: `Recipe` (+ `MacroProfile`), `RecipeIngredient` (quantity/unit/item parsing: decimals, ASCII + unicode + mixed fractions, known-unit vocabulary), `MealPlanSlot` (reuses nutrition `MealType`), `IRecipeRepository`, `IMealPlanRepository` — all pure Dart; `core/utils/uuid.dart` v4 generator (no new dependency).
|
||||
- [x] Domain: `ScrapeRecipeFromUrl` use case — `package:http` download (injectable `RecipeHtmlFetcher` for tests), `package:html` DOM parse, JSON-LD `@graph`/root/array/`@type`-list navigation, `HowToStep`/`HowToSection` instruction flattening, ISO-8601 `prepTime`/`cookTime`, `recipeYield`, regex nutrition parsing (`calories`, `proteinContent`, `carbohydrateContent`, `fatContent`), image list/object unwrap, OpenGraph fallback (`og:title`/`og:image`/`og:description`), typed `RecipeScrapeException`.
|
||||
- [x] Data: `AppDatabase` schema v3 (migration for v1/v2 installs) with `RecipesTable` (UUID PK, JSON ingredient/instruction columns, macros, `createdAt`) and `MealPlanSlotsTable` (UUID PK, plannedDate, mealType, nullable `recipeId` FK with `ON DELETE SET NULL`, customNotes, isCompleted); drift-backed `DriftRecipeRepository` (URL-deduplicated `importRecipe`, `createdAt` preserved on re-import) and `DriftMealPlanRepository` (half-open `[from, to)` window, upsert-by-id).
|
||||
- [x] Presentation: `RecipeImportController` (Riverpod `Notifier`; URL submit → loading → preview modal state, live catalog watch stream, confirm-save/dismiss/delete) and `MealPlanCalendarController` (Monday–Sunday window, next/previous/today navigation, slot assign/upsert/clear, prepared toggle, per-day protein rollup against the nutrition 100 g target, deleted-recipe slot normalization, stale-window refresh guard).
|
||||
- [x] Presentation: `MealPlannerScreen` refactor — URL import bar with spinner, `RecipePreviewDialog` (image, meta chips, macro tiles, ingredients, steps, save with error surface), `SavedRecipesTray` (draggable cards with thumbnail + protein badge + quick-assign), `WeeklyCalendarView` (≥900 dp: 7-column grid; below: tabbed pager) with `DragTarget` meal slots, prepared checkboxes, clear menu, per-day protein indicator (progress bar vs. target), `AssignSlotDialog` (saved-recipe dropdown or custom description), week navigation header.
|
||||
- [x] Tests: 25 new tests — `recipe_scraper_test.dart` (JSON-LD `@graph`/root/array/list-`@type` extraction, macro/ingredient/instruction/duration/yield parsing, malformed-JSON fallthrough, OpenGraph fallback, `execute()` URL validation & error mapping) and `meal_plan_repository_test.dart` (`startOfWeek`, recipe CRUD/URL-dedup/watch, slot window filter/upsert/delete, calendar rollup/assignment/navigation/custom entries/prepared toggles/recipe-deletion normalization, import-controller empty-URL guard).
|
||||
- [x] Regression fix: HowToSection flattening now recurses into `itemListElement` before using the section `name` (parser ordering bug caught by the new test).
|
||||
|
||||
## Post-Phase-4 Additions
|
||||
- [x] Desktop resize overflow fix (2026-10-16): the hatched yellow/black stripes (RenderFlex overflow) shown when shrinking the Windows window are gone — wide-mode day columns now scroll vertically (matching the compact pager), and a `LayoutBuilder` in `MealPlannerScreen` collapses the page into a single scrollable column (with a fixed-height calendar) whenever the body is shorter than ~520 dp. Covered by `test/features/meal_planner/meal_planner_screen_test.dart` (4 widget tests pumping the screen at short-compact, short-wide, tall, and extremely-short window sizes; overflow errors fail widget tests, so green proves no stripes).
|
||||
|
||||
## Active Verification Gates
|
||||
- [x] `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||
- [x] `flutter analyze` (Zero issues — `No issues found!`)
|
||||
- [x] `flutter test` (60/60 passing)
|
||||
- [x] `flutter test` (89/89 passing)
|
||||
- [x] `flutter build web --no-tree-shake-icons` (conditional import keeps `package:health`/`dart:io` out of the web bundle)
|
||||
|
|
@ -100,6 +100,83 @@ class BiometricLogsTable extends Table {
|
|||
TextColumn get source => text().withLength(min: 1, max: 32)();
|
||||
}
|
||||
|
||||
/// A saved recipe in the local catalog (imported via the web scraper or
|
||||
/// created manually).
|
||||
///
|
||||
/// [id] is a UUID string assigned by the meal-planner domain; ingredient
|
||||
/// and instruction payloads are stored as JSON arrays so the core schema
|
||||
/// stays decoupled from the feature's entity types.
|
||||
@DataClassName('RecipeRecord')
|
||||
class RecipesTable extends Table {
|
||||
/// UUID primary key (see `core/utils/uuid.dart`).
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get title => text().withLength(min: 1, max: 256)();
|
||||
|
||||
/// Canonical URL the recipe was imported from; used to de-duplicate
|
||||
/// re-imports.
|
||||
TextColumn get sourceUrl => text().withLength(min: 1, max: 1024)();
|
||||
|
||||
TextColumn get imageUrl => text().nullable()();
|
||||
|
||||
/// Short summary (schema.org `description` / `og:description`).
|
||||
TextColumn get description => text().nullable()();
|
||||
|
||||
/// Preparation time in minutes; `null` when unknown.
|
||||
IntColumn get prepTimeMinutes => integer().nullable()();
|
||||
|
||||
/// Cook time in minutes; `null` when unknown.
|
||||
IntColumn get cookTimeMinutes => integer().nullable()();
|
||||
|
||||
IntColumn get servings => integer().withDefault(const Constant(1))();
|
||||
|
||||
/// JSON-encoded `List<Map>` of ingredient records.
|
||||
TextColumn get rawIngredientsJson => text()();
|
||||
|
||||
/// JSON-encoded `List<String>` of instruction steps.
|
||||
TextColumn get instructionsJson => text()();
|
||||
|
||||
IntColumn get calories => integer().withDefault(const Constant(0))();
|
||||
|
||||
RealColumn get proteinGrams => real().withDefault(const Constant(0))();
|
||||
|
||||
RealColumn get carbsGrams => real().withDefault(const Constant(0))();
|
||||
|
||||
RealColumn get fatGrams => real().withDefault(const Constant(0))();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column>? get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// One assignable slot of the weekly meal-plan calendar: a day plus a meal
|
||||
/// slot referencing a saved recipe (nullable FK) or free-text notes.
|
||||
@DataClassName('MealPlanSlotRecord')
|
||||
class MealPlanSlotsTable extends Table {
|
||||
/// UUID primary key.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Calendar day the slot belongs to (local start-of-day).
|
||||
DateTimeColumn get plannedDate => dateTime()();
|
||||
|
||||
/// Meal slot identifier (e.g. `breakfast`) — the `MealType.name` key of
|
||||
/// the nutrition domain enum.
|
||||
TextColumn get mealType => text().withLength(min: 1, max: 32)();
|
||||
|
||||
/// Assigned recipe; falls back to `null` when the recipe is deleted.
|
||||
TextColumn get recipeId =>
|
||||
text().nullable().references(RecipesTable, #id, onDelete: KeyAction.setNull)();
|
||||
|
||||
/// Free-text meal description for custom entries / notes.
|
||||
TextColumn get customNotes => text().nullable()();
|
||||
|
||||
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column>? get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Root drift database for the GLP-1 Companion.
|
||||
///
|
||||
/// On native platforms the connection is backed by SQLCipher: the 256-bit AES
|
||||
|
|
@ -108,7 +185,14 @@ class BiometricLogsTable extends Table {
|
|||
/// builds transparently fall back to a WASM SQLite database, since SQLCipher
|
||||
/// cannot run inside the browser.
|
||||
@DriftDatabase(
|
||||
tables: [MedicationDosesTable, FoodLogsTable, HydrationLogsTable, BiometricLogsTable],
|
||||
tables: [
|
||||
MedicationDosesTable,
|
||||
FoodLogsTable,
|
||||
HydrationLogsTable,
|
||||
BiometricLogsTable,
|
||||
RecipesTable,
|
||||
MealPlanSlotsTable,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
/// Opens the platform-appropriate (encrypted on native) database connection.
|
||||
|
|
@ -118,7 +202,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
AppDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
|
|
@ -131,6 +215,12 @@ class AppDatabase extends _$AppDatabase {
|
|||
await m.createTable(hydrationLogsTable);
|
||||
await m.createTable(biometricLogsTable);
|
||||
}
|
||||
// v3 added the recipe catalog and weekly meal-plan slots
|
||||
// (recipes first: meal-plan slots reference them).
|
||||
if (from < 3) {
|
||||
await m.createTable(recipesTable);
|
||||
await m.createTable(mealPlanSlotsTable);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -403,6 +493,98 @@ class AppDatabase extends _$AppDatabase {
|
|||
(t) => OrderingTerm.desc(t.id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recipe catalog CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These helpers intentionally operate on drift records/companions only:
|
||||
// mapping between `RecipeRecord` and the meal-planner feature's `Recipe`
|
||||
// entity happens in `features/meal_planner/data/repositories/`.
|
||||
|
||||
/// Emits the saved recipe catalog (newest first) and re-emits on every
|
||||
/// write.
|
||||
Stream<List<RecipeRecord>> watchRecipes() =>
|
||||
(_selectRecipes()).watch();
|
||||
|
||||
/// One-shot snapshot of the saved recipe catalog (newest first).
|
||||
Future<List<RecipeRecord>> getRecipes() => _selectRecipes().get();
|
||||
|
||||
/// The recipe row with [id], or `null` when absent.
|
||||
Future<RecipeRecord?> getRecipeById(String id) =>
|
||||
(select(recipesTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
|
||||
/// The most recent recipe imported from [sourceUrl], or `null`; used to
|
||||
/// de-duplicate re-imports.
|
||||
Future<RecipeRecord?> getRecipeBySourceUrl(String sourceUrl) =>
|
||||
(select(recipesTable)
|
||||
..where((t) => t.sourceUrl.equals(sourceUrl))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
/// Inserts a new recipe row.
|
||||
Future<void> insertRecipe(RecipesTableCompanion entry) =>
|
||||
into(recipesTable).insert(entry);
|
||||
|
||||
/// Partially updates the recipe identified by [id] (only non-absent
|
||||
/// companion columns are written, so `createdAt` survives re-imports).
|
||||
Future<void> updateRecipe(String id, RecipesTableCompanion updates) =>
|
||||
(update(recipesTable)..where((t) => t.id.equals(id))).write(updates);
|
||||
|
||||
/// Deletes the recipe with [id]; referencing meal-plan slots receive a
|
||||
/// `null` recipe id (`ON DELETE SET NULL`).
|
||||
Future<int> deleteRecipe(String id) =>
|
||||
(delete(recipesTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
/// Catalog query shared by the watch/get helpers, newest first.
|
||||
SimpleSelectStatement<$RecipesTableTable, RecipeRecord> _selectRecipes() =>
|
||||
select(recipesTable)
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.desc(t.createdAt),
|
||||
(t) => OrderingTerm.desc(t.id),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meal-plan slot CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Emits the meal-plan slots in the half-open window `[from, to)` and
|
||||
/// re-emits on every write.
|
||||
Stream<List<MealPlanSlotRecord>> watchMealPlanSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) =>
|
||||
_selectMealPlanSlots(from: from, to: to).watch();
|
||||
|
||||
/// One-shot snapshot of the meal-plan slots in `[from, to)`, ordered by
|
||||
/// day then slot id.
|
||||
Future<List<MealPlanSlotRecord>> getMealPlanSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) =>
|
||||
_selectMealPlanSlots(from: from, to: to).get();
|
||||
|
||||
/// Inserts the slot or updates the existing row with the same id.
|
||||
Future<void> upsertMealPlanSlot(MealPlanSlotsTableCompanion slot) =>
|
||||
into(mealPlanSlotsTable).insertOnConflictUpdate(slot);
|
||||
|
||||
/// Deletes the meal-plan slot with [id], if present.
|
||||
Future<int> deleteMealPlanSlot(String id) =>
|
||||
(delete(mealPlanSlotsTable)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
/// Window query shared by the watch/get helpers.
|
||||
SimpleSelectStatement<$MealPlanSlotsTableTable, MealPlanSlotRecord>
|
||||
_selectMealPlanSlots({required DateTime from, required DateTime to}) =>
|
||||
(select(mealPlanSlotsTable)
|
||||
..where(
|
||||
(t) =>
|
||||
t.plannedDate.isBiggerOrEqualValue(from) &
|
||||
t.plannedDate.isSmallerThanValue(to),
|
||||
))
|
||||
..orderBy([
|
||||
(t) => OrderingTerm.asc(t.plannedDate),
|
||||
(t) => OrderingTerm.asc(t.id),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Application-wide database instance; closed when the container is disposed.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,6 +30,22 @@ DateTime startOfDay(DateTime time) =>
|
|||
DateTime endOfDay(DateTime time) =>
|
||||
DateTime(time.year, time.month, time.day + 1);
|
||||
|
||||
/// Start of the ISO-8601 calendar week containing [time] — Monday
|
||||
/// `00:00:00.000` as a local calendar day. Day arithmetic uses the
|
||||
/// [DateTime] constructor (not `Duration`) so daylight-saving transitions
|
||||
/// can never shift the result off midnight.
|
||||
DateTime startOfWeek(DateTime time) {
|
||||
final day = startOfDay(time);
|
||||
return DateTime(day.year, day.month, day.day - (day.weekday - DateTime.monday));
|
||||
}
|
||||
|
||||
/// Formats [time] as a short month/day label, e.g. `Oct 16`.
|
||||
String formatMonthDay(DateTime time) {
|
||||
final local = time.toLocal();
|
||||
return '${_monthNames[local.month - 1]} ${local.day}';
|
||||
}
|
||||
|
||||
|
||||
/// Formats a coarse elapsed time relative to [now], e.g. `never`, `3 hours
|
||||
/// ago`, `5 days ago`.
|
||||
String formatRelativeTime(DateTime time, {DateTime? now}) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import 'dart:math';
|
||||
|
||||
/// Generates a random RFC 4122 version 4 UUID string.
|
||||
///
|
||||
/// Example: `f47ac10b-58cc-4372-a567-0e02b2c3d479`.
|
||||
///
|
||||
/// The project deliberately avoids a `uuid` package dependency; these keys
|
||||
/// are local-only record identifiers (recipe catalog, meal-plan slots), so a
|
||||
/// `dart:math` pseudo-random source is sufficient and behaves identically on
|
||||
/// Android, iOS, Web, macOS, and Windows.
|
||||
String generateUuid([Random? random]) {
|
||||
final rng = random ?? Random();
|
||||
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||
|
||||
// Version 4 (random) + variant 10xx per RFC 4122 §4.4.
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20)}';
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../../nutrition/domain/entities/meal_type.dart';
|
||||
import '../../domain/entities/meal_plan_slot.dart';
|
||||
import '../../domain/repositories/meal_plan_repository.dart';
|
||||
|
||||
/// Drift-backed implementation of [IMealPlanRepository].
|
||||
///
|
||||
/// Owns the translation between persistence records ([MealPlanSlotRecord])
|
||||
/// and the pure-Dart [MealPlanSlot] entity. The meal slot is persisted as
|
||||
/// its stable enum name string so the schema stays decoupled from domain
|
||||
/// types.
|
||||
class DriftMealPlanRepository implements IMealPlanRepository {
|
||||
DriftMealPlanRepository(this._database);
|
||||
|
||||
final AppDatabase _database;
|
||||
|
||||
@override
|
||||
Stream<List<MealPlanSlot>> watchSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) =>
|
||||
_database
|
||||
.watchMealPlanSlots(from: from, to: to)
|
||||
.map((records) => records.map(_mapSlot).toList(growable: false));
|
||||
|
||||
@override
|
||||
Future<List<MealPlanSlot>> getSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
}) async =>
|
||||
(await _database.getMealPlanSlots(from: from, to: to))
|
||||
.map(_mapSlot)
|
||||
.toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<MealPlanSlot> saveSlot(MealPlanSlot slot) async {
|
||||
await _database.upsertMealPlanSlot(_toCompanion(slot));
|
||||
return slot;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSlot(String id) => _database.deleteMealPlanSlot(id);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts a persistence record into the domain entity. Unknown meal keys
|
||||
/// fall back to [MealType.snack] so legacy rows never break the calendar.
|
||||
MealPlanSlot _mapSlot(MealPlanSlotRecord record) => MealPlanSlot(
|
||||
id: record.id,
|
||||
date: record.plannedDate,
|
||||
mealType: MealType.tryParse(record.mealType) ?? MealType.snack,
|
||||
recipeId: record.recipeId,
|
||||
customDescription: record.customNotes,
|
||||
isPrepared: record.isCompleted,
|
||||
);
|
||||
|
||||
/// Converts a domain entity into an upsert companion.
|
||||
///
|
||||
/// Nullable columns always carry an explicit [Value] (including `null`)
|
||||
/// so re-assigning a slot (e.g. recipe → custom entry) clears stale data
|
||||
/// instead of leaving the previous value untouched.
|
||||
MealPlanSlotsTableCompanion _toCompanion(MealPlanSlot slot) =>
|
||||
MealPlanSlotsTableCompanion(
|
||||
id: Value(slot.id),
|
||||
plannedDate: Value(slot.date),
|
||||
mealType: Value(slot.mealType.persistenceKey),
|
||||
recipeId: Value(slot.recipeId),
|
||||
customNotes: Value(slot.customDescription),
|
||||
isCompleted: Value(slot.isPrepared),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../domain/entities/macro_profile.dart';
|
||||
import '../../domain/entities/recipe.dart';
|
||||
import '../../domain/entities/recipe_ingredient.dart';
|
||||
import '../../domain/repositories/recipe_repository.dart';
|
||||
|
||||
/// Drift-backed implementation of [IRecipeRepository].
|
||||
///
|
||||
/// Owns the translation between persistence records ([RecipeRecord]) and
|
||||
/// the pure-Dart [Recipe] entity. Ingredient/instruction payloads round-trip
|
||||
/// through JSON columns so the core schema never imports feature code.
|
||||
class DriftRecipeRepository implements IRecipeRepository {
|
||||
DriftRecipeRepository(this._database);
|
||||
|
||||
final AppDatabase _database;
|
||||
|
||||
@override
|
||||
Stream<List<Recipe>> watchRecipes() => _database
|
||||
.watchRecipes()
|
||||
.map((records) => records.map(_mapRecipe).toList(growable: false));
|
||||
|
||||
@override
|
||||
Future<List<Recipe>> getRecipes() async =>
|
||||
(await _database.getRecipes()).map(_mapRecipe).toList(growable: false);
|
||||
|
||||
@override
|
||||
Future<Recipe?> getRecipe(String id) async {
|
||||
final record = await _database.getRecipeById(id);
|
||||
return record == null ? null : _mapRecipe(record);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Recipe> saveRecipe(Recipe recipe) async {
|
||||
final existing = await _database.getRecipeById(recipe.id);
|
||||
final companion = _toCompanion(recipe, existingCreatedAt: existing?.createdAt);
|
||||
|
||||
if (existing == null) {
|
||||
await _database.insertRecipe(companion);
|
||||
} else {
|
||||
await _database.updateRecipe(recipe.id, companion);
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Recipe> importRecipe(Recipe recipe) async {
|
||||
// De-duplicate by source URL: re-importing the same page refreshes the
|
||||
// stored row (keeping its original identifier) instead of duplicating.
|
||||
final existing = await _database.getRecipeBySourceUrl(recipe.sourceUrl);
|
||||
if (existing == null) return saveRecipe(recipe);
|
||||
return saveRecipe(recipe.copyWith(id: existing.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteRecipe(String id) => _database.deleteRecipe(id);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Recipe _mapRecipe(RecipeRecord record) => Recipe(
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
sourceUrl: record.sourceUrl,
|
||||
imageUrl: record.imageUrl,
|
||||
description: record.description,
|
||||
prepTimeMinutes: record.prepTimeMinutes ?? 0,
|
||||
cookTimeMinutes: record.cookTimeMinutes ?? 0,
|
||||
servings: record.servings,
|
||||
ingredients: _decodeIngredients(record.rawIngredientsJson),
|
||||
instructions: _decodeInstructions(record.instructionsJson),
|
||||
macrosPerServing: MacroProfile(
|
||||
calories: record.calories,
|
||||
proteinGrams: record.proteinGrams,
|
||||
carbsGrams: record.carbsGrams,
|
||||
fatGrams: record.fatGrams,
|
||||
),
|
||||
);
|
||||
|
||||
/// `createdAt` is only provided on insert; on update it stays absent so
|
||||
/// the original catalog timestamp survives re-imports.
|
||||
RecipesTableCompanion _toCompanion(
|
||||
Recipe recipe, {
|
||||
DateTime? existingCreatedAt,
|
||||
}) =>
|
||||
RecipesTableCompanion(
|
||||
id: Value(recipe.id),
|
||||
title: Value(recipe.title),
|
||||
sourceUrl: Value(recipe.sourceUrl),
|
||||
imageUrl: Value(recipe.imageUrl),
|
||||
description: Value(recipe.description),
|
||||
prepTimeMinutes: Value(recipe.prepTimeMinutes),
|
||||
cookTimeMinutes: Value(recipe.cookTimeMinutes),
|
||||
servings: Value(recipe.servings),
|
||||
rawIngredientsJson: Value(
|
||||
jsonEncode(
|
||||
recipe.ingredients.map((e) => e.toMap()).toList(growable: false),
|
||||
),
|
||||
),
|
||||
instructionsJson: Value(jsonEncode(recipe.instructions)),
|
||||
calories: Value(recipe.macrosPerServing.calories),
|
||||
proteinGrams: Value(recipe.macrosPerServing.proteinGrams),
|
||||
carbsGrams: Value(recipe.macrosPerServing.carbsGrams),
|
||||
fatGrams: Value(recipe.macrosPerServing.fatGrams),
|
||||
createdAt: existingCreatedAt == null
|
||||
? Value(DateTime.now())
|
||||
: const Value.absent(),
|
||||
);
|
||||
|
||||
List<RecipeIngredient> _decodeIngredients(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return const [];
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(map) =>
|
||||
RecipeIngredient.fromMap(Map<String, Object?>.from(map)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} on FormatException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _decodeInstructions(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return const [];
|
||||
return decoded.map((step) => step.toString()).toList(growable: false);
|
||||
} on FormatException {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/// Nutritional macros for a single recipe serving.
|
||||
///
|
||||
/// Pure Dart (no framework imports) so the value can travel from the
|
||||
/// schema.org scraper through the drift-backed repository into the
|
||||
/// presentation layer without leaking infrastructure concerns into the
|
||||
/// domain.
|
||||
class MacroProfile {
|
||||
const MacroProfile({
|
||||
this.calories = 0,
|
||||
this.proteinGrams = 0,
|
||||
this.carbsGrams = 0,
|
||||
this.fatGrams = 0,
|
||||
});
|
||||
|
||||
/// Energy content in kilocalories per serving.
|
||||
final int calories;
|
||||
|
||||
/// Protein in grams; the primary lean-mass tracking metric for GLP-1
|
||||
/// therapy (muscle-preservation target).
|
||||
final double proteinGrams;
|
||||
|
||||
/// Carbohydrate content in grams per serving.
|
||||
final double carbsGrams;
|
||||
|
||||
/// Fat content in grams per serving.
|
||||
final double fatGrams;
|
||||
|
||||
/// `true` when no macro could be extracted (e.g. OpenGraph-only imports).
|
||||
bool get isEmpty =>
|
||||
calories == 0 &&
|
||||
proteinGrams == 0 &&
|
||||
carbsGrams == 0 &&
|
||||
fatGrams == 0;
|
||||
|
||||
MacroProfile copyWith({
|
||||
int? calories,
|
||||
double? proteinGrams,
|
||||
double? carbsGrams,
|
||||
double? fatGrams,
|
||||
}) =>
|
||||
MacroProfile(
|
||||
calories: calories ?? this.calories,
|
||||
proteinGrams: proteinGrams ?? this.proteinGrams,
|
||||
carbsGrams: carbsGrams ?? this.carbsGrams,
|
||||
fatGrams: fatGrams ?? this.fatGrams,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is MacroProfile &&
|
||||
other.calories == calories &&
|
||||
other.proteinGrams == proteinGrams &&
|
||||
other.carbsGrams == carbsGrams &&
|
||||
other.fatGrams == fatGrams;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(calories, proteinGrams, carbsGrams, fatGrams);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MacroProfile($calories kcal, P $proteinGrams g, '
|
||||
'C $carbsGrams g, F $fatGrams g)';
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import '../../../nutrition/domain/entities/meal_type.dart';
|
||||
|
||||
/// One slot of the weekly meal plan: a calendar day plus a meal slot
|
||||
/// (breakfast, lunch, dinner, snack) that either references a saved [Recipe]
|
||||
/// by id or carries a free-text description.
|
||||
///
|
||||
/// Pure Dart (no framework imports). The meal vocabulary reuses the
|
||||
/// nutrition feature's [MealType] so planner slots and food-diary entries
|
||||
/// stay aligned across bounded contexts.
|
||||
class MealPlanSlot {
|
||||
const MealPlanSlot({
|
||||
required this.id,
|
||||
required this.date,
|
||||
required this.mealType,
|
||||
this.recipeId,
|
||||
this.customDescription,
|
||||
this.isPrepared = false,
|
||||
});
|
||||
|
||||
/// UUID primary key assigned when the slot is first created.
|
||||
final String id;
|
||||
|
||||
/// Calendar day the slot belongs to (stored at local start-of-day).
|
||||
final DateTime date;
|
||||
|
||||
/// Meal slot within the day.
|
||||
final MealType mealType;
|
||||
|
||||
/// Saved recipe assigned to this slot; `null` for custom entries.
|
||||
final String? recipeId;
|
||||
|
||||
/// Free-text meal description for custom entries (or a note alongside an
|
||||
/// assigned recipe).
|
||||
final String? customDescription;
|
||||
|
||||
/// Whether the meal has been prepared / logged as done.
|
||||
final bool isPrepared;
|
||||
|
||||
/// `true` when the slot renders as an empty cell (no recipe and no text).
|
||||
bool get isEmpty =>
|
||||
recipeId == null && (customDescription ?? '').trim().isEmpty;
|
||||
|
||||
MealPlanSlot copyWith({
|
||||
String? id,
|
||||
DateTime? date,
|
||||
MealType? mealType,
|
||||
String? recipeId,
|
||||
String? customDescription,
|
||||
bool? isPrepared,
|
||||
}) =>
|
||||
MealPlanSlot(
|
||||
id: id ?? this.id,
|
||||
date: date ?? this.date,
|
||||
mealType: mealType ?? this.mealType,
|
||||
recipeId: recipeId ?? this.recipeId,
|
||||
customDescription: customDescription ?? this.customDescription,
|
||||
isPrepared: isPrepared ?? this.isPrepared,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'MealPlanSlot($id, ${date.year}-${date.month}-'
|
||||
'${date.day} ${mealType.name}, recipe: ${recipeId ?? '-'})';
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import 'macro_profile.dart';
|
||||
import 'recipe_ingredient.dart';
|
||||
|
||||
/// Immutable recipe imported from the web (schema.org/JSON-LD or
|
||||
/// OpenGraph fallback) or saved manually.
|
||||
///
|
||||
/// Pure Dart (no framework imports) so the entity can be shared by the
|
||||
/// scraper use case, the drift-backed repository, and the Riverpod
|
||||
/// presentation layer without leaking infrastructure concerns into the
|
||||
/// domain.
|
||||
class Recipe {
|
||||
const Recipe({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.sourceUrl,
|
||||
this.imageUrl,
|
||||
this.description,
|
||||
this.prepTimeMinutes = 0,
|
||||
this.cookTimeMinutes = 0,
|
||||
this.servings = 1,
|
||||
this.ingredients = const [],
|
||||
this.instructions = const [],
|
||||
required this.macrosPerServing,
|
||||
});
|
||||
|
||||
/// UUID primary key assigned when the recipe is first materialized.
|
||||
final String id;
|
||||
|
||||
/// Human-readable recipe title (`name` / `og:title`).
|
||||
final String title;
|
||||
|
||||
/// URL the recipe was imported from; also used to de-duplicate re-imports.
|
||||
final String sourceUrl;
|
||||
|
||||
/// Representative image URL (`image` / `og:image`), if any.
|
||||
final String? imageUrl;
|
||||
|
||||
/// Optional summary (`description` / `og:description`).
|
||||
final String? description;
|
||||
|
||||
/// Preparation time in minutes (`prepTime`, ISO-8601 duration decoded);
|
||||
/// `0` when unknown.
|
||||
final int prepTimeMinutes;
|
||||
|
||||
/// Cook time in minutes (`cookTime`); `0` when unknown.
|
||||
final int cookTimeMinutes;
|
||||
|
||||
/// Yield parsed from `recipeYield` (defaults to 4 when unparsable).
|
||||
final int servings;
|
||||
|
||||
/// Parsed ingredient lines (raw text preserved).
|
||||
final List<RecipeIngredient> ingredients;
|
||||
|
||||
/// Instruction steps with `HowToStep` / `HowToSection` wrappers flattened.
|
||||
final List<String> instructions;
|
||||
|
||||
/// Nutrition facts per serving (`nutrition` block).
|
||||
final MacroProfile macrosPerServing;
|
||||
|
||||
/// Total active time (prep + cook) in minutes.
|
||||
int get totalMinutes => prepTimeMinutes + cookTimeMinutes;
|
||||
|
||||
Recipe copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? sourceUrl,
|
||||
String? imageUrl,
|
||||
String? description,
|
||||
int? prepTimeMinutes,
|
||||
int? cookTimeMinutes,
|
||||
int? servings,
|
||||
List<RecipeIngredient>? ingredients,
|
||||
List<String>? instructions,
|
||||
MacroProfile? macrosPerServing,
|
||||
}) =>
|
||||
Recipe(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
sourceUrl: sourceUrl ?? this.sourceUrl,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
description: description ?? this.description,
|
||||
prepTimeMinutes: prepTimeMinutes ?? this.prepTimeMinutes,
|
||||
cookTimeMinutes: cookTimeMinutes ?? this.cookTimeMinutes,
|
||||
servings: servings ?? this.servings,
|
||||
ingredients: ingredients ?? this.ingredients,
|
||||
instructions: instructions ?? this.instructions,
|
||||
macrosPerServing: macrosPerServing ?? this.macrosPerServing,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Recipe($id, "$title", '
|
||||
'${macrosPerServing.proteinGrams}g protein/serving)';
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/// A single ingredient line from a recipe with best-effort structured
|
||||
/// parsing of quantity, unit, and item name.
|
||||
///
|
||||
/// Pure Dart (no framework imports).
|
||||
class RecipeIngredient {
|
||||
const RecipeIngredient({
|
||||
required this.rawText,
|
||||
required this.parsedItemName,
|
||||
this.quantity,
|
||||
this.unit,
|
||||
});
|
||||
|
||||
/// Unit vocabulary recognized during parsing (lowercase). Anything else
|
||||
/// stays part of [parsedItemName] (e.g. "1 large egg" keeps "large egg").
|
||||
static const Set<String> _knownUnits = {
|
||||
'cup', 'cups',
|
||||
'tbsp', 'tbsps', 'tablespoon', 'tablespoons', 'tablespoonful', 'tablespoonfuls',
|
||||
'tsp', 'tsps', 'teaspoon', 'teaspoons', 'teaspoonful', 'teaspoonfuls',
|
||||
'ounce', 'ounces', 'oz',
|
||||
'pound', 'pounds', 'lb', 'lbs',
|
||||
'gram', 'grams', 'g',
|
||||
'kilogram', 'kilograms', 'kg',
|
||||
'milligram', 'milligrams', 'mg',
|
||||
'milliliter', 'milliliters', 'millilitre', 'millilitres', 'ml',
|
||||
'liter', 'liters', 'litre', 'litres', 'l',
|
||||
'pinch', 'pinches', 'dash', 'dashes',
|
||||
'clove', 'cloves', 'slice', 'slices', 'piece', 'pieces',
|
||||
'can', 'cans', 'bottle', 'bottles', 'jar', 'jars',
|
||||
'package', 'packages', 'pack', 'bag', 'bags',
|
||||
'bunch', 'bunches', 'sprig', 'sprigs',
|
||||
'head', 'heads', 'stick', 'sticks', 'cube', 'cubes',
|
||||
'dozen', 'bowl', 'bowls', 'handful', 'handfuls',
|
||||
'serving', 'servings', 'rib', 'ribs', 'stalk', 'stalks',
|
||||
};
|
||||
|
||||
static final RegExp _numberToken = RegExp(r'^\d+(?:\.\d+)?$');
|
||||
static final RegExp _fractionToken = RegExp(r'^(\d+)/(\d+)$');
|
||||
|
||||
/// The original (trimmed) ingredient line exactly as published.
|
||||
final String rawText;
|
||||
|
||||
/// Remaining text after quantity/unit extraction, e.g. `chicken breasts`.
|
||||
final String parsedItemName;
|
||||
|
||||
/// Leading numeric quantity (`1.5`, `0.25`, mixed `1 1/2` → `1.5`).
|
||||
final double? quantity;
|
||||
|
||||
/// Lowercased unit word when recognized (e.g. `cup`, `tsp`).
|
||||
final String? unit;
|
||||
|
||||
/// Splits [rawText] into quantity / unit / item name.
|
||||
///
|
||||
/// Handles decimals (`2.5`), ASCII fractions (`1/2`), mixed numbers
|
||||
/// (`1 1/2 cups`), and unicode fractions (`1½`), and only consumes a unit
|
||||
/// token when a leading quantity was present and the token belongs to
|
||||
/// [_knownUnits].
|
||||
factory RecipeIngredient.parse(String rawText) {
|
||||
// Normalize unicode fractions to plain decimal tokens first, so
|
||||
// "1½ cup sugar" becomes "1 0.5 cup sugar" and sums to 1.5.
|
||||
var normalized = rawText
|
||||
.trim()
|
||||
.replaceAll('¼', ' 0.25 ')
|
||||
.replaceAll('½', ' 0.5 ')
|
||||
.replaceAll('¾', ' 0.75 ');
|
||||
normalized = normalized.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (normalized.isEmpty) {
|
||||
return RecipeIngredient(rawText: rawText.trim(), parsedItemName: '');
|
||||
}
|
||||
|
||||
final tokens = normalized.split(' ');
|
||||
var index = 0;
|
||||
double? quantity;
|
||||
|
||||
if (_numberToken.hasMatch(tokens[index])) {
|
||||
quantity = double.parse(tokens[index]);
|
||||
index++;
|
||||
// Mixed number: "1 1/2" — or the normalized "1 0.5" of "1½".
|
||||
if (index < tokens.length) {
|
||||
final fraction = _fractionToken.firstMatch(tokens[index]);
|
||||
if (fraction != null) {
|
||||
quantity += _fractionValue(fraction);
|
||||
index++;
|
||||
} else if (_numberToken.hasMatch(tokens[index]) && quantity == quantity.roundToDouble()) {
|
||||
quantity += double.parse(tokens[index]);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final fraction = _fractionToken.firstMatch(tokens[index]);
|
||||
if (fraction != null) {
|
||||
quantity = _fractionValue(fraction);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
String? unit;
|
||||
if (quantity != null && index < tokens.length) {
|
||||
final candidate = tokens[index].toLowerCase();
|
||||
if (_knownUnits.contains(candidate)) {
|
||||
unit = candidate;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return RecipeIngredient(
|
||||
rawText: rawText.trim(),
|
||||
parsedItemName: tokens.sublist(index).join(' '),
|
||||
quantity: quantity,
|
||||
unit: unit,
|
||||
);
|
||||
}
|
||||
|
||||
static double _fractionValue(Match match) {
|
||||
final numerator = double.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final denominator = double.tryParse(match.group(2) ?? '') ?? 1;
|
||||
return denominator == 0 ? 0 : numerator / denominator;
|
||||
}
|
||||
|
||||
/// Serialization helper for the `rawIngredientsJson` persistence column.
|
||||
Map<String, Object?> toMap() => <String, Object?>{
|
||||
'rawText': rawText,
|
||||
'parsedItemName': parsedItemName,
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
};
|
||||
|
||||
/// Deserializes a map produced by [toMap]; malformed entries fall back to
|
||||
/// the raw text only.
|
||||
factory RecipeIngredient.fromMap(Map<String, Object?> map) {
|
||||
final rawText = map['rawText']?.toString() ?? '';
|
||||
return RecipeIngredient(
|
||||
rawText: rawText,
|
||||
parsedItemName: map['parsedItemName']?.toString() ?? rawText,
|
||||
quantity: (map['quantity'] as num?)?.toDouble(),
|
||||
unit: map['unit']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'RecipeIngredient($rawText → qty $quantity ${unit ?? ''} '
|
||||
'$parsedItemName)';
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import '../entities/meal_plan_slot.dart';
|
||||
|
||||
/// Persistence contract for the weekly meal-plan calendar (bounded
|
||||
/// context: meal planner).
|
||||
///
|
||||
/// The presentation layer depends only on this interface; the drift-backed
|
||||
/// implementation lives in `data/repositories/` so widgets and controllers
|
||||
/// remain testable with in-memory fakes.
|
||||
abstract class IMealPlanRepository {
|
||||
/// Emits the slots within the half-open window `[from, to)` whenever they
|
||||
/// change.
|
||||
Stream<List<MealPlanSlot>> watchSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
});
|
||||
|
||||
/// One-shot snapshot of the slots within the half-open window
|
||||
/// `[from, to)`, ordered by day then slot id.
|
||||
Future<List<MealPlanSlot>> getSlots({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
});
|
||||
|
||||
/// Inserts or updates the slot identified by [MealPlanSlot.id] and returns
|
||||
/// the stored slot.
|
||||
Future<MealPlanSlot> saveSlot(MealPlanSlot slot);
|
||||
|
||||
/// Removes the slot with [id], if present.
|
||||
Future<void> deleteSlot(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import '../entities/recipe.dart';
|
||||
|
||||
/// Persistence contract for the recipe catalog (bounded context:
|
||||
/// meal planner / recipe import).
|
||||
///
|
||||
/// The presentation layer depends only on this interface; the drift-backed
|
||||
/// implementation lives in `data/repositories/` so widgets and controllers
|
||||
/// remain testable with in-memory fakes.
|
||||
abstract class IRecipeRepository {
|
||||
/// Emits the saved recipe catalog (newest first) whenever it changes.
|
||||
Stream<List<Recipe>> watchRecipes();
|
||||
|
||||
/// One-shot snapshot of the saved recipe catalog (newest first).
|
||||
Future<List<Recipe>> getRecipes();
|
||||
|
||||
/// The recipe with [id], or `null` when it does not exist.
|
||||
Future<Recipe?> getRecipe(String id);
|
||||
|
||||
/// Persists [recipe]; inserts a new row when [Recipe.id] is unknown,
|
||||
/// otherwise updates the existing row. Returns the stored recipe.
|
||||
Future<Recipe> saveRecipe(Recipe recipe);
|
||||
|
||||
/// Persists a freshly scraped recipe, de-duplicating by [Recipe.sourceUrl]:
|
||||
/// re-importing the same URL refreshes the previously stored row (keeping
|
||||
/// its original identifier) instead of creating a duplicate.
|
||||
Future<Recipe> importRecipe(Recipe recipe);
|
||||
|
||||
/// Removes the recipe with [id]. Assigned meal-plan slots fall back to
|
||||
/// empty slots (nullable foreign key).
|
||||
Future<void> deleteRecipe(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../../core/utils/uuid.dart';
|
||||
import '../entities/macro_profile.dart';
|
||||
import '../entities/recipe.dart';
|
||||
import '../entities/recipe_ingredient.dart';
|
||||
|
||||
/// Downloads and returns the raw HTML document of [url].
|
||||
///
|
||||
/// Injectable so unit tests can exercise the parser against mock HTML
|
||||
/// without touching the network.
|
||||
typedef RecipeHtmlFetcher = Future<String> Function(Uri url);
|
||||
|
||||
/// Raised when a URL cannot be fetched or carries no recognizable recipe
|
||||
/// metadata (neither schema.org/JSON-LD nor OpenGraph).
|
||||
class RecipeScrapeException implements Exception {
|
||||
RecipeScrapeException(this.message);
|
||||
|
||||
/// Human-readable reason suitable for direct display in the UI.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Imports a recipe from a web page.
|
||||
///
|
||||
/// Pipeline (mirrors the Phase 4 architecture spec):
|
||||
/// 1. Download the raw HTML via `package:http` (injectable fetcher).
|
||||
/// 2. Parse the DOM with `package:html`.
|
||||
/// 3. Extract every `<script type="application/ld+json">` node and locate a
|
||||
/// `"@type": "Recipe"` object — searched at the document root, inside
|
||||
/// top-level arrays, and inside `@graph` containers.
|
||||
/// 4. Map `name`, `recipeIngredient`, `recipeInstructions` (plain strings,
|
||||
/// `HowToStep`, and nested `HowToSection` shapes), `nutrition`
|
||||
/// (calories / protein / carbs / fat via numeric parsing), `image`,
|
||||
/// `prepTime`/`cookTime` (ISO-8601 durations), and `recipeYield`.
|
||||
/// 5. Fall back to OpenGraph tags (`og:title`, `og:image`,
|
||||
/// `og:description`) when JSON-LD is absent.
|
||||
class ScrapeRecipeFromUrl {
|
||||
ScrapeRecipeFromUrl({
|
||||
RecipeHtmlFetcher? fetcher,
|
||||
this.defaultServings = 4,
|
||||
}) : _fetcher = fetcher ?? _httpGetFetcher;
|
||||
|
||||
/// Yield assumed when `recipeYield` is absent or unparsable.
|
||||
final int defaultServings;
|
||||
|
||||
final RecipeHtmlFetcher _fetcher;
|
||||
|
||||
static Future<String> _httpGetFetcher(Uri url) async {
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {'Accept': 'text/html,application/xhtml+xml'},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw RecipeScrapeException(
|
||||
'Download failed (HTTP ${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
return response.body;
|
||||
}
|
||||
|
||||
/// Convenience entry point mirroring the use-case notation `call(url)`.
|
||||
Future<Recipe> call(String url) => execute(url);
|
||||
|
||||
/// Downloads the page at [url] and returns the parsed [Recipe].
|
||||
///
|
||||
/// Throws [RecipeScrapeException] when [url] is not an absolute http(s)
|
||||
/// URL, the download fails, or no recipe metadata can be extracted.
|
||||
Future<Recipe> execute(String url) async {
|
||||
final trimmed = url.trim();
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!uri.hasAuthority ||
|
||||
!(uri.isScheme('http') || uri.isScheme('https'))) {
|
||||
throw RecipeScrapeException(
|
||||
'Enter a valid http:// or https:// recipe URL.',
|
||||
);
|
||||
}
|
||||
|
||||
final String html;
|
||||
try {
|
||||
html = await _fetcher(uri);
|
||||
} on RecipeScrapeException {
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
throw RecipeScrapeException('Could not download the page ($error).');
|
||||
}
|
||||
|
||||
final recipe = parseHtml(html, sourceUrl: uri.toString());
|
||||
if (recipe == null) {
|
||||
throw RecipeScrapeException(
|
||||
'No schema.org Recipe or OpenGraph metadata found at $trimmed.',
|
||||
);
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
|
||||
/// Parses [html] into a [Recipe], or `null` when the document carries
|
||||
/// neither JSON-LD recipe data nor the minimal OpenGraph fallback tags.
|
||||
///
|
||||
/// Exposed separately from [execute] so tests can verify the parser
|
||||
/// against static HTML strings.
|
||||
Recipe? parseHtml(String html, {required String sourceUrl}) {
|
||||
final document = html_parser.parse(html);
|
||||
|
||||
for (final tag
|
||||
in document.querySelectorAll('script[type="application/ld+json"]')) {
|
||||
final raw = tag.text.trim();
|
||||
if (raw.isEmpty) continue;
|
||||
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(raw);
|
||||
} on FormatException {
|
||||
continue; // Malformed JSON-LD is common; try the next script block.
|
||||
}
|
||||
|
||||
final node = _findRecipeNode(decoded);
|
||||
if (node == null) continue;
|
||||
|
||||
final recipe = _mapRecipeNode(node, sourceUrl: sourceUrl);
|
||||
if (recipe != null) return recipe;
|
||||
}
|
||||
|
||||
return _parseOpenGraphFallback(document, sourceUrl: sourceUrl);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON-LD navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Searches decoded JSON-LD (root object, root array, or `@graph`
|
||||
/// containers — including nested inside array elements) for the first
|
||||
/// object whose `@type` is `Recipe` (string or list form).
|
||||
Map<String, dynamic>? _findRecipeNode(Object? decoded) {
|
||||
if (decoded is Map) {
|
||||
final graph = decoded['@graph'];
|
||||
if (graph is List) {
|
||||
for (final item in graph) {
|
||||
final node = _asRecipeMap(item);
|
||||
if (node != null) return node;
|
||||
}
|
||||
}
|
||||
return _asRecipeMap(decoded);
|
||||
}
|
||||
if (decoded is List) {
|
||||
for (final item in decoded) {
|
||||
if (item is! Map) continue;
|
||||
final graph = item['@graph'];
|
||||
if (graph is List) {
|
||||
for (final graphItem in graph) {
|
||||
final node = _asRecipeMap(graphItem);
|
||||
if (node != null) return node;
|
||||
}
|
||||
}
|
||||
final node = _asRecipeMap(item);
|
||||
if (node != null) return node;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns [node] coerced to a map when its `@type` declares `Recipe`.
|
||||
Map<String, dynamic>? _asRecipeMap(Object? node) {
|
||||
if (node is! Map) return null;
|
||||
final type = node['@type'];
|
||||
final isRecipe = type == 'Recipe' ||
|
||||
(type is List && type.any((entry) => entry?.toString() == 'Recipe'));
|
||||
return isRecipe ? Map<String, dynamic>.from(node) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Recipe? _mapRecipeNode(Map<String, dynamic> node, {required String sourceUrl}) {
|
||||
final title = _stringOf(node['name'])?.trim();
|
||||
if (title == null || title.isEmpty) return null;
|
||||
|
||||
final nutrition = node['nutrition'] is Map
|
||||
? Map<String, dynamic>.from(node['nutrition'] as Map)
|
||||
: const <String, dynamic>{};
|
||||
|
||||
return Recipe(
|
||||
id: generateUuid(),
|
||||
title: title,
|
||||
sourceUrl: sourceUrl,
|
||||
imageUrl: _parseImage(node['image']),
|
||||
description: _normalizedOrNull(_stringOf(node['description'])),
|
||||
prepTimeMinutes: _parseIsoDurationMinutes(node['prepTime']),
|
||||
cookTimeMinutes: _parseIsoDurationMinutes(node['cookTime']),
|
||||
servings: _parseServings(node['recipeYield']) ?? defaultServings,
|
||||
ingredients: _parseIngredients(node['recipeIngredient']),
|
||||
instructions: _normalizeInstructions(node['recipeInstructions']),
|
||||
macrosPerServing: MacroProfile(
|
||||
calories: _parseMacroNumber(nutrition['calories'])?.round() ?? 0,
|
||||
proteinGrams: _parseMacroNumber(nutrition['proteinContent']) ?? 0,
|
||||
carbsGrams: _parseMacroNumber(nutrition['carbohydrateContent']) ?? 0,
|
||||
fatGrams: _parseMacroNumber(nutrition['fatContent']) ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal OpenGraph fallback for pages without JSON-LD: requires
|
||||
/// `og:title`; `og:image` and `og:description` are optional.
|
||||
Recipe? _parseOpenGraphFallback(
|
||||
dom.Document document, {
|
||||
required String sourceUrl,
|
||||
}) {
|
||||
String? meta(String property) {
|
||||
final element = document.querySelector('meta[property="$property"]') ??
|
||||
document.querySelector('meta[name="$property"]');
|
||||
return _normalizedOrNull(element?.attributes['content']);
|
||||
}
|
||||
|
||||
final title = meta('og:title');
|
||||
if (title == null) return null;
|
||||
|
||||
return Recipe(
|
||||
id: generateUuid(),
|
||||
title: title,
|
||||
sourceUrl: sourceUrl,
|
||||
imageUrl: meta('og:image'),
|
||||
description: meta('og:description'),
|
||||
prepTimeMinutes: 0,
|
||||
cookTimeMinutes: 0,
|
||||
servings: defaultServings,
|
||||
ingredients: const [],
|
||||
instructions: const [],
|
||||
macrosPerServing: const MacroProfile(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalizes `recipeIngredient` (string or list) into parsed ingredients.
|
||||
List<RecipeIngredient> _parseIngredients(Object? value) {
|
||||
final lines = <String>[];
|
||||
if (value is String) {
|
||||
lines.add(value);
|
||||
} else if (value is List) {
|
||||
for (final item in value) {
|
||||
final line = _stringOf(item)?.trim();
|
||||
if (line != null && line.isNotEmpty) lines.add(line);
|
||||
}
|
||||
}
|
||||
return lines.map(RecipeIngredient.parse).toList(growable: false);
|
||||
}
|
||||
|
||||
/// Normalizes `recipeInstructions` into a flat step list, unwrapping
|
||||
/// `HowToStep` / `HowToSection` objects (recursively via
|
||||
/// `itemListElement`) and collapsing plain strings.
|
||||
List<String> _normalizeInstructions(Object? value) {
|
||||
final steps = <String>[];
|
||||
|
||||
void walk(Object? node) {
|
||||
if (node == null) return;
|
||||
if (node is String) {
|
||||
final trimmed = node.trim();
|
||||
if (trimmed.isNotEmpty) steps.add(trimmed);
|
||||
return;
|
||||
}
|
||||
if (node is List) {
|
||||
for (final item in node) {
|
||||
walk(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node is Map) {
|
||||
// Sections carry their steps in `itemListElement`; recurse instead
|
||||
// of using the section's own name as a pseudo-step.
|
||||
if (node['itemListElement'] != null) {
|
||||
walk(node['itemListElement']);
|
||||
return;
|
||||
}
|
||||
final text = _stringOf(node['text']) ?? _stringOf(node['name']);
|
||||
if (text != null && text.trim().isNotEmpty) {
|
||||
steps.add(text.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(value);
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// Extracts the first URL from the `image` property, which may be a plain
|
||||
/// string, a list of strings/objects, or an object with a `url` key.
|
||||
String? _parseImage(Object? value) {
|
||||
if (value is String) return _normalizedOrNull(value);
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final url = _parseImage(item);
|
||||
if (url != null) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value is Map) return _parseImage(value['url']);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parses `recipeYield` (`4`, `"4 servings"`, `["4 servings"]`, ...).
|
||||
int? _parseServings(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final servings = value.toInt();
|
||||
return servings > 0 ? servings : null;
|
||||
}
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final parsed = _parseServings(item);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final match = RegExp(r'\d+').firstMatch(value.toString());
|
||||
final servings = int.tryParse(match?.group(0) ?? '');
|
||||
return (servings != null && servings > 0) ? servings : null;
|
||||
}
|
||||
|
||||
/// Parses ISO-8601 durations (`PT1H30M`) or plain minute counts (`30`).
|
||||
int _parseIsoDurationMinutes(Object? value) {
|
||||
if (value == null) return 0;
|
||||
final text = value.toString().trim();
|
||||
if (text.isEmpty) return 0;
|
||||
|
||||
if (!text.startsWith('P')) {
|
||||
final plain = RegExp(r'\d+').firstMatch(text);
|
||||
return int.tryParse(plain?.group(0) ?? '') ?? 0;
|
||||
}
|
||||
|
||||
final match = RegExp(
|
||||
r'^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$',
|
||||
).firstMatch(text);
|
||||
if (match == null) return 0;
|
||||
|
||||
final days = int.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final hours = int.tryParse(match.group(2) ?? '') ?? 0;
|
||||
final minutes = int.tryParse(match.group(3) ?? '') ?? 0;
|
||||
final seconds = int.tryParse(match.group(4) ?? '') ?? 0;
|
||||
return days * Duration.minutesPerDay +
|
||||
hours * Duration.minutesPerHour +
|
||||
minutes +
|
||||
(seconds >= 30 ? 1 : 0);
|
||||
}
|
||||
|
||||
/// Parses a macro quantity such as `"420 kcal"`, `"38 g"`,
|
||||
/// `"1,300 kcal"`, or a bare number into a double.
|
||||
double? _parseMacroNumber(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) return value.toDouble();
|
||||
final match = RegExp(r'\d[\d,]*\.?\d*').firstMatch(value.toString());
|
||||
if (match == null) return null;
|
||||
return double.tryParse(match.group(0)!.replaceAll(',', ''));
|
||||
}
|
||||
|
||||
String? _stringOf(Object? value) => value?.toString();
|
||||
|
||||
String? _normalizedOrNull(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../../../core/utils/date_time_utils.dart';
|
||||
import '../../../../core/utils/uuid.dart';
|
||||
import '../../../nutrition/domain/entities/meal_type.dart';
|
||||
import '../../../nutrition/presentation/controllers/nutrition_controller.dart';
|
||||
import '../../data/repositories/drift_meal_plan_repository.dart';
|
||||
import '../../domain/entities/meal_plan_slot.dart';
|
||||
import '../../domain/entities/recipe.dart';
|
||||
import '../../domain/repositories/meal_plan_repository.dart';
|
||||
import 'recipe_import_controller.dart';
|
||||
|
||||
/// Application-wide meal-plan repository (drift-backed).
|
||||
///
|
||||
/// Overridable in widget/unit tests with an in-memory fake.
|
||||
final mealPlanRepositoryProvider = Provider<IMealPlanRepository>(
|
||||
(ref) => DriftMealPlanRepository(ref.watch(appDatabaseProvider)),
|
||||
);
|
||||
|
||||
/// Immutable snapshot of the weekly meal-plan calendar: the active
|
||||
/// Monday–Sunday window, its slots, the saved recipe catalog needed to
|
||||
/// resolve protein rollups, and loading/error flags.
|
||||
class MealPlanCalendarState {
|
||||
const MealPlanCalendarState({
|
||||
required this.windowStart,
|
||||
this.slots = const [],
|
||||
this.recipes = const [],
|
||||
this.dailyProteinTargetGrams =
|
||||
NutritionController.defaultProteinTargetGrams,
|
||||
this.isLoading = true,
|
||||
this.hasLoaded = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
||||
static const Object _keep = Object();
|
||||
|
||||
/// First day (Monday) of the visible 7-day window at local start-of-day.
|
||||
final DateTime windowStart;
|
||||
|
||||
/// Slots within `[windowStart, windowStart + 7 days)`.
|
||||
final List<MealPlanSlot> slots;
|
||||
|
||||
/// Saved recipe catalog snapshot (used to resolve slot → protein).
|
||||
final List<Recipe> recipes;
|
||||
|
||||
/// Daily lean-mass goal; defaults to the nutrition feature's target.
|
||||
final double dailyProteinTargetGrams;
|
||||
|
||||
/// `true` while a window load/refresh is in flight.
|
||||
final bool isLoading;
|
||||
|
||||
/// `true` once the first load completed (drives the initial spinner).
|
||||
final bool hasLoaded;
|
||||
|
||||
/// Last load/persist failure message.
|
||||
final String? errorMessage;
|
||||
|
||||
/// The seven days of the window, Monday through Sunday.
|
||||
List<DateTime> get days => List<DateTime>.unmodifiable(
|
||||
List.generate(
|
||||
7,
|
||||
(index) => DateTime(windowStart.year, windowStart.month, windowStart.day + index),
|
||||
),
|
||||
);
|
||||
|
||||
/// Slot assigned to [day]'s [mealType], or `null` when empty.
|
||||
MealPlanSlot? slotFor(DateTime day, MealType mealType) {
|
||||
final target = startOfDay(day);
|
||||
for (final slot in slots) {
|
||||
if (slot.mealType == mealType && startOfDay(slot.date) == target) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Resolved recipe referenced by [slot], or `null` for custom entries.
|
||||
Recipe? recipeFor(MealPlanSlot slot) {
|
||||
final recipeId = slot.recipeId;
|
||||
if (recipeId == null) return null;
|
||||
for (final recipe in recipes) {
|
||||
if (recipe.id == recipeId) return recipe;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Total protein (g) scheduled for [day] by resolving every assigned
|
||||
/// recipe's per-serving macros; custom text entries contribute 0 g.
|
||||
double proteinForDay(DateTime day) {
|
||||
final target = startOfDay(day);
|
||||
var total = 0.0;
|
||||
for (final slot in slots) {
|
||||
if (startOfDay(slot.date) != target) continue;
|
||||
total += recipeFor(slot)?.macrosPerServing.proteinGrams ?? 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// [proteinForDay] progress against [dailyProteinTargetGrams] in `0..1+`.
|
||||
double proteinProgressForDay(DateTime day) =>
|
||||
dailyProteinTargetGrams <= 0
|
||||
? 0
|
||||
: proteinForDay(day) / dailyProteinTargetGrams;
|
||||
|
||||
/// Human-readable label for the visible window, e.g.
|
||||
/// `Oct 12 – Oct 18, 2026`.
|
||||
String get weekLabel {
|
||||
final start = windowStart;
|
||||
final end = days.last;
|
||||
return '${formatMonthDay(start)} – ${formatMonthDay(end)}, ${end.year}';
|
||||
}
|
||||
|
||||
MealPlanCalendarState copyWith({
|
||||
DateTime? windowStart,
|
||||
List<MealPlanSlot>? slots,
|
||||
List<Recipe>? recipes,
|
||||
double? dailyProteinTargetGrams,
|
||||
bool? isLoading,
|
||||
bool? hasLoaded,
|
||||
Object? errorMessage = _keep,
|
||||
}) =>
|
||||
MealPlanCalendarState(
|
||||
windowStart: windowStart ?? this.windowStart,
|
||||
slots: slots ?? this.slots,
|
||||
recipes: recipes ?? this.recipes,
|
||||
dailyProteinTargetGrams:
|
||||
dailyProteinTargetGrams ?? this.dailyProteinTargetGrams,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
hasLoaded: hasLoaded ?? this.hasLoaded,
|
||||
errorMessage: identical(errorMessage, _keep)
|
||||
? this.errorMessage
|
||||
: errorMessage as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Orchestrates the weekly meal-plan calendar: maintains the active
|
||||
/// Monday–Sunday window, loads/assigns/clears slots for each day's meal
|
||||
/// type, and derives the per-day protein rollup used by the daily
|
||||
/// indicators.
|
||||
class MealPlanCalendarController extends Notifier<MealPlanCalendarState> {
|
||||
@override
|
||||
MealPlanCalendarState build() {
|
||||
// Kick off the first window load without blocking the initial build.
|
||||
Future<void>.microtask(refresh);
|
||||
return MealPlanCalendarState(windowStart: startOfWeek(DateTime.now()));
|
||||
}
|
||||
|
||||
/// Reloads the slots of the active window and the recipe catalog.
|
||||
Future<void> refresh() async {
|
||||
final windowStart = state.windowStart;
|
||||
|
||||
state = state.copyWith(isLoading: true);
|
||||
try {
|
||||
final slots = await ref.read(mealPlanRepositoryProvider).getSlots(
|
||||
from: windowStart,
|
||||
to: windowStart.add(const Duration(days: 7)),
|
||||
);
|
||||
final recipes = await ref.read(recipeRepositoryProvider).getRecipes();
|
||||
|
||||
// The window may have changed while awaiting; discard stale results.
|
||||
if (state.windowStart != windowStart) return;
|
||||
|
||||
final recipeIds = recipes.map((recipe) => recipe.id).toSet();
|
||||
// Normalize slots whose recipe vanished (defense in depth when the
|
||||
// database was written before foreign-key enforcement was active).
|
||||
final normalized = slots
|
||||
.map(
|
||||
(slot) => slot.recipeId != null && !recipeIds.contains(slot.recipeId)
|
||||
? MealPlanSlot(
|
||||
id: slot.id,
|
||||
date: slot.date,
|
||||
mealType: slot.mealType,
|
||||
recipeId: null,
|
||||
customDescription: slot.customDescription,
|
||||
isPrepared: slot.isPrepared,
|
||||
)
|
||||
: slot,
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
state = state.copyWith(
|
||||
slots: normalized,
|
||||
recipes: recipes,
|
||||
isLoading: false,
|
||||
hasLoaded: true,
|
||||
errorMessage: null,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: 'meal plan refresh failed');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'Could not load the meal plan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the visible window forward by one week.
|
||||
Future<void> nextWeek() async {
|
||||
state = state.copyWith(
|
||||
windowStart: state.windowStart.add(const Duration(days: 7)),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Moves the visible window back by one week.
|
||||
Future<void> previousWeek() async {
|
||||
state = state.copyWith(
|
||||
windowStart: state.windowStart.subtract(const Duration(days: 7)),
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Jumps the visible window to the week containing today.
|
||||
Future<void> goToToday() async {
|
||||
final today = startOfWeek(DateTime.now());
|
||||
if (today == state.windowStart) return;
|
||||
state = state.copyWith(windowStart: today);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/// Assigns [recipeId] to [day]'s [mealType] slot, creating the slot when
|
||||
/// absent. An optional [note] is stored alongside the recipe.
|
||||
Future<bool> assignRecipe({
|
||||
required DateTime day,
|
||||
required MealType mealType,
|
||||
required String recipeId,
|
||||
String? note,
|
||||
}) async {
|
||||
final existing = state.slotFor(day, mealType);
|
||||
final trimmedNote = (note ?? '').trim();
|
||||
final slot = MealPlanSlot(
|
||||
id: existing?.id ?? generateUuid(),
|
||||
date: startOfDay(day),
|
||||
mealType: mealType,
|
||||
recipeId: recipeId,
|
||||
customDescription: trimmedNote.isNotEmpty
|
||||
? trimmedNote
|
||||
: existing?.customDescription,
|
||||
isPrepared: existing?.isPrepared ?? false,
|
||||
);
|
||||
return _persist(slot, 'assign the recipe');
|
||||
}
|
||||
|
||||
/// Saves a free-text custom entry (no recipe) for [day]'s [mealType].
|
||||
Future<bool> saveCustomEntry({
|
||||
required DateTime day,
|
||||
required MealType mealType,
|
||||
required String description,
|
||||
}) async {
|
||||
final trimmed = description.trim();
|
||||
if (trimmed.isEmpty) return false;
|
||||
|
||||
final existing = state.slotFor(day, mealType);
|
||||
final slot = MealPlanSlot(
|
||||
id: existing?.id ?? generateUuid(),
|
||||
date: startOfDay(day),
|
||||
mealType: mealType,
|
||||
recipeId: null,
|
||||
customDescription: trimmed,
|
||||
isPrepared: existing?.isPrepared ?? false,
|
||||
);
|
||||
return _persist(slot, 'save the entry');
|
||||
}
|
||||
|
||||
/// Flips the prepared/completed flag of [slot].
|
||||
Future<bool> togglePrepared(MealPlanSlot slot) => _persist(
|
||||
slot.copyWith(isPrepared: !slot.isPrepared),
|
||||
'update the slot',
|
||||
);
|
||||
|
||||
/// Removes [slot] from the plan.
|
||||
Future<bool> clearSlot(MealPlanSlot slot) async {
|
||||
try {
|
||||
await ref.read(mealPlanRepositoryProvider).deleteSlot(slot.id);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: 'meal slot delete failed');
|
||||
state = state.copyWith(errorMessage: 'Could not update the plan.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [slot] and reloads the window.
|
||||
Future<bool> _persist(MealPlanSlot slot, String action) async {
|
||||
try {
|
||||
await ref.read(mealPlanRepositoryProvider).saveSlot(slot);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: 'meal slot save failed');
|
||||
state = state.copyWith(errorMessage: 'Could not $action.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes the [MealPlanCalendarController] to the widget tree.
|
||||
final mealPlanCalendarControllerProvider =
|
||||
NotifierProvider<MealPlanCalendarController, MealPlanCalendarState>(
|
||||
MealPlanCalendarController.new,
|
||||
);
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/database/app_database.dart';
|
||||
import '../../data/repositories/drift_recipe_repository.dart';
|
||||
import '../../domain/entities/recipe.dart';
|
||||
import '../../domain/repositories/recipe_repository.dart';
|
||||
import '../../domain/usecases/scrape_recipe_from_url.dart';
|
||||
|
||||
/// Application-wide recipe repository (drift-backed).
|
||||
///
|
||||
/// Overridable in widget/unit tests with an in-memory fake.
|
||||
final recipeRepositoryProvider = Provider<IRecipeRepository>(
|
||||
(ref) => DriftRecipeRepository(ref.watch(appDatabaseProvider)),
|
||||
);
|
||||
|
||||
/// Application-wide recipe scraper use case.
|
||||
///
|
||||
/// The default instance downloads via `package:http`; tests inject a fake
|
||||
/// fetcher through the provider override.
|
||||
final scrapeRecipeFromUrlProvider = Provider<ScrapeRecipeFromUrl>(
|
||||
(ref) => ScrapeRecipeFromUrl(),
|
||||
);
|
||||
|
||||
/// Immutable snapshot of the recipe-import flow: URL submission, loading,
|
||||
/// parse-preview modal, and the live saved-recipe catalog.
|
||||
class RecipeImportState {
|
||||
const RecipeImportState({
|
||||
this.isImporting = false,
|
||||
this.errorMessage,
|
||||
this.statusMessage,
|
||||
this.previewRecipe,
|
||||
this.savedRecipes = const [],
|
||||
});
|
||||
|
||||
/// Sentinel allowing `copyWith` to explicitly reset a field to `null`.
|
||||
static const Object _keep = Object();
|
||||
|
||||
/// `true` while a URL download/parse is running.
|
||||
final bool isImporting;
|
||||
|
||||
/// Last import/save failure, surfaced inline under the import bar.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Last successful action message (e.g. recipe saved), surfaced as a
|
||||
/// snackbar.
|
||||
final String? statusMessage;
|
||||
|
||||
/// Parsed recipe awaiting user confirmation in the preview dialog.
|
||||
final Recipe? previewRecipe;
|
||||
|
||||
/// Saved recipe catalog (newest first), kept live by the repository
|
||||
/// watch stream.
|
||||
final List<Recipe> savedRecipes;
|
||||
|
||||
bool get hasPreview => previewRecipe != null;
|
||||
|
||||
RecipeImportState copyWith({
|
||||
bool? isImporting,
|
||||
Object? errorMessage = _keep,
|
||||
Object? statusMessage = _keep,
|
||||
Object? previewRecipe = _keep,
|
||||
Object? savedRecipes = _keep,
|
||||
}) =>
|
||||
RecipeImportState(
|
||||
isImporting: isImporting ?? this.isImporting,
|
||||
errorMessage: identical(errorMessage, _keep)
|
||||
? this.errorMessage
|
||||
: errorMessage as String?,
|
||||
statusMessage: identical(statusMessage, _keep)
|
||||
? this.statusMessage
|
||||
: statusMessage as String?,
|
||||
previewRecipe: identical(previewRecipe, _keep)
|
||||
? this.previewRecipe
|
||||
: previewRecipe as Recipe?,
|
||||
savedRecipes: identical(savedRecipes, _keep)
|
||||
? this.savedRecipes
|
||||
: savedRecipes as List<Recipe>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Orchestrates the recipe-import feature: submits URLs to the
|
||||
/// [ScrapeRecipeFromUrl] use case, holds the parse result for the preview
|
||||
/// dialog, persists confirmed imports (URL-deduplicated), and streams the
|
||||
/// saved catalog for the recipes tray.
|
||||
class RecipeImportController extends Notifier<RecipeImportState> {
|
||||
@override
|
||||
RecipeImportState build() {
|
||||
final subscription = ref
|
||||
.read(recipeRepositoryProvider)
|
||||
.watchRecipes()
|
||||
.listen(
|
||||
(recipes) => state = state.copyWith(savedRecipes: recipes),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'recipe catalog watch failed',
|
||||
);
|
||||
},
|
||||
);
|
||||
ref.onDispose(subscription.cancel);
|
||||
return const RecipeImportState();
|
||||
}
|
||||
|
||||
/// Runs the scraper for [url]; on success the parsed recipe is parked in
|
||||
/// [RecipeImportState.previewRecipe] for user confirmation.
|
||||
Future<void> submitUrl(String url) async {
|
||||
if (url.trim().isEmpty) {
|
||||
state = state.copyWith(
|
||||
isImporting: false,
|
||||
errorMessage: 'Paste a recipe URL first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isImporting: true,
|
||||
errorMessage: null,
|
||||
statusMessage: null,
|
||||
previewRecipe: null,
|
||||
);
|
||||
|
||||
try {
|
||||
final recipe = await ref.read(scrapeRecipeFromUrlProvider)(url);
|
||||
state = state.copyWith(isImporting: false, previewRecipe: recipe);
|
||||
} on RecipeScrapeException catch (error) {
|
||||
state = state.copyWith(isImporting: false, errorMessage: error.message);
|
||||
} catch (error) {
|
||||
state = state.copyWith(
|
||||
isImporting: false,
|
||||
errorMessage: 'Import failed: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the previewed recipe (deduplicated by source URL) and closes
|
||||
/// the preview. Returns whether the save succeeded.
|
||||
Future<bool> confirmSavePreview() async {
|
||||
final preview = state.previewRecipe;
|
||||
if (preview == null) return false;
|
||||
|
||||
try {
|
||||
final saved =
|
||||
await ref.read(recipeRepositoryProvider).importRecipe(preview);
|
||||
state = state.copyWith(
|
||||
previewRecipe: null,
|
||||
errorMessage: null,
|
||||
statusMessage: 'Saved "${saved.title}" to your recipes.',
|
||||
);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: 'recipe import save failed',
|
||||
);
|
||||
state = state.copyWith(errorMessage: 'Could not save the recipe: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the preview dialog without saving (cancel / dismiss).
|
||||
void dismissPreview() {
|
||||
state = state.copyWith(previewRecipe: null);
|
||||
}
|
||||
|
||||
/// Removes a saved recipe; assigned meal-plan slots fall back to empty.
|
||||
Future<bool> deleteRecipe(String id) async {
|
||||
try {
|
||||
await ref.read(recipeRepositoryProvider).deleteRecipe(id);
|
||||
state = state.copyWith(statusMessage: 'Recipe removed.');
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace, label: 'recipe delete failed');
|
||||
state = state.copyWith(
|
||||
errorMessage: 'Could not delete the recipe: $error',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the transient status/error banners.
|
||||
void clearMessages() {
|
||||
state = state.copyWith(statusMessage: null, errorMessage: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes the [RecipeImportController] to the widget tree.
|
||||
final recipeImportControllerProvider =
|
||||
NotifierProvider<RecipeImportController, RecipeImportState>(
|
||||
RecipeImportController.new,
|
||||
);
|
||||
|
|
@ -1,47 +1,341 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class MealPlannerScreen extends StatelessWidget {
|
||||
import '../../../nutrition/domain/entities/meal_type.dart';
|
||||
import '../../domain/entities/recipe.dart';
|
||||
import '../controllers/meal_plan_calendar_controller.dart';
|
||||
import '../controllers/recipe_import_controller.dart';
|
||||
import '../widgets/assign_slot_dialog.dart';
|
||||
import '../widgets/recipe_preview_dialog.dart';
|
||||
import '../widgets/saved_recipes_tray.dart';
|
||||
import '../widgets/weekly_calendar_view.dart';
|
||||
|
||||
/// GLP-1 meal planner: recipe URL import bar, saved-recipes tray, and the
|
||||
/// responsive weekly calendar with drag-and-drop assignment and per-day
|
||||
/// protein rollups.
|
||||
class MealPlannerScreen extends ConsumerStatefulWidget {
|
||||
const MealPlannerScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<MealPlannerScreen> createState() => _MealPlannerScreenState();
|
||||
}
|
||||
|
||||
class _MealPlannerScreenState extends ConsumerState<MealPlannerScreen> {
|
||||
/// Body height below which the page collapses into a single scrollable
|
||||
/// column: fixed top sections (~340 dp) plus a minimal usable calendar
|
||||
/// area. Below this, a plain `Column` would overflow and paint the
|
||||
/// yellow/black hatched stripes.
|
||||
static const double _kFullScrollThreshold = 520;
|
||||
|
||||
/// Calendar height used in the fully-scrollable fallback layout; tall
|
||||
/// enough for the day header plus four meal slots.
|
||||
static const double _kCalendarScrollHeight = 560;
|
||||
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
bool _previewDialogVisible = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final importState = ref.watch(recipeImportControllerProvider);
|
||||
final calendarState = ref.watch(mealPlanCalendarControllerProvider);
|
||||
|
||||
// Open the preview modal when a scrape completes; surface messages as
|
||||
// snackbars.
|
||||
ref.listen(recipeImportControllerProvider, (previous, next) {
|
||||
if (next.previewRecipe != null &&
|
||||
previous?.previewRecipe == null &&
|
||||
!_previewDialogVisible) {
|
||||
_showPreview(next.previewRecipe!);
|
||||
}
|
||||
if (next.statusMessage != null &&
|
||||
next.statusMessage != previous?.statusMessage) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(next.statusMessage!)));
|
||||
}
|
||||
if (next.errorMessage != null &&
|
||||
next.errorMessage != previous?.errorMessage &&
|
||||
next.previewRecipe == null) {
|
||||
_showErrorSnack(next.errorMessage!);
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen(mealPlanCalendarControllerProvider, (previous, next) {
|
||||
if (next.errorMessage != null &&
|
||||
next.errorMessage != previous?.errorMessage) {
|
||||
_showErrorSnack(next.errorMessage!);
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Meal Planner & Import')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Paste Recipe URL to import',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () {},
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Short windows (e.g. resized desktop windows) can no longer fit
|
||||
// the fixed top sections plus a useful calendar area; collapse the
|
||||
// whole page into a single scroll view instead of overflowing.
|
||||
if (constraints.maxHeight < _kFullScrollThreshold) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildTopSections(importState, calendarState),
|
||||
SizedBox(
|
||||
height: _kCalendarScrollHeight,
|
||||
child: _buildCalendar(calendarState),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildTopSections(importState, calendarState),
|
||||
Expanded(child: _buildCalendar(calendarState)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Import bar + saved recipes tray + week navigation header.
|
||||
Widget _buildTopSections(
|
||||
RecipeImportState importState,
|
||||
MealPlanCalendarState calendarState,
|
||||
) =>
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_RecipeImportBar(
|
||||
controller: _urlController,
|
||||
isLoading: importState.isImporting,
|
||||
onSubmit: _submitUrl,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SavedRecipesTray(
|
||||
recipes: importState.savedRecipes,
|
||||
onAssignPressed: _openTrayAssign,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_WeekNavigationHeader(state: calendarState),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
|
||||
/// Weekly calendar grid (or the initial loading spinner).
|
||||
Widget _buildCalendar(MealPlanCalendarState calendarState) =>
|
||||
!calendarState.hasLoaded
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: WeeklyCalendarView(
|
||||
state: calendarState,
|
||||
onRecipeDropped: _onRecipeDropped,
|
||||
onSlotTapped: _onSlotTapped,
|
||||
onTogglePrepared: (slot) => ref
|
||||
.read(mealPlanCalendarControllerProvider.notifier)
|
||||
.togglePrepared(slot),
|
||||
onClearSlot: (slot) => ref
|
||||
.read(mealPlanCalendarControllerProvider.notifier)
|
||||
.clearSlot(slot),
|
||||
);
|
||||
|
||||
void _showErrorSnack(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitUrl() async {
|
||||
final url = _urlController.text;
|
||||
final notifier = ref.read(recipeImportControllerProvider.notifier);
|
||||
await notifier.submitUrl(url);
|
||||
if (mounted) _urlController.clear();
|
||||
}
|
||||
|
||||
Future<void> _showPreview(Recipe recipe) async {
|
||||
_previewDialogVisible = true;
|
||||
final notifier = ref.read(recipeImportControllerProvider.notifier);
|
||||
final saved = await showRecipePreviewDialog(context, recipe: recipe);
|
||||
_previewDialogVisible = false;
|
||||
// Cancelling (or abandoning a failed save) clears the parked preview so
|
||||
// the modal never re-opens on the next rebuild.
|
||||
if (saved != true) notifier.dismissPreview();
|
||||
}
|
||||
|
||||
void _onRecipeDropped(Recipe recipe, DateTime day, MealType mealType) {
|
||||
ref.read(mealPlanCalendarControllerProvider.notifier).assignRecipe(
|
||||
day: day,
|
||||
mealType: mealType,
|
||||
recipeId: recipe.id,
|
||||
);
|
||||
}
|
||||
|
||||
void _onSlotTapped(DateTime day, MealType mealType) {
|
||||
final calendarState = ref.read(mealPlanCalendarControllerProvider);
|
||||
final importState = ref.read(recipeImportControllerProvider);
|
||||
showAssignSlotDialog(
|
||||
context,
|
||||
day: day,
|
||||
mealType: mealType,
|
||||
savedRecipes: importState.savedRecipes,
|
||||
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(height: 24),
|
||||
const Text(
|
||||
'This Week\'s Plan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Card(
|
||||
child: ListTile(
|
||||
title: Text('Monday'),
|
||||
subtitle: Text('Grilled Lemon Herb Chicken & Steamed Greens'),
|
||||
trailing: Text('42g Protein'),
|
||||
),
|
||||
),
|
||||
const Card(
|
||||
child: ListTile(
|
||||
title: Text('Tuesday'),
|
||||
subtitle: Text('Greek Yogurt Bowl with Chia & Berries'),
|
||||
trailing: Text('30g Protein'),
|
||||
),
|
||||
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
|
||||
/// "Today" shortcut.
|
||||
class _WeekNavigationHeader extends StatelessWidget {
|
||||
const _WeekNavigationHeader({required this.state});
|
||||
|
||||
final MealPlanCalendarState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Previous week',
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => _readController(context).previousWeek(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.weekLabel,
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'Monday – Sunday',
|
||||
style: theme.textTheme.labelSmall
|
||||
?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => _readController(context).goToToday(),
|
||||
child: const Text('Today'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Next week',
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: state.isLoading
|
||||
? null
|
||||
: () => _readController(context).nextWeek(),
|
||||
),
|
||||
if (state.isLoading) ...[
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
MealPlanCalendarController _readController(BuildContext context) =>
|
||||
ProviderScope.containerOf(context, listen: false)
|
||||
.read(mealPlanCalendarControllerProvider.notifier);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/entities/recipe.dart';
|
||||
import '../controllers/recipe_import_controller.dart';
|
||||
|
||||
/// Opens the recipe preview dialog for the currently previewed recipe.
|
||||
///
|
||||
/// Resolves to `true` when the user saves the recipe and `false` when the
|
||||
/// dialog is cancelled; both outcomes are reconciled with the import
|
||||
/// controller state by the caller (the meal planner screen).
|
||||
Future<bool?> showRecipePreviewDialog(
|
||||
BuildContext context, {
|
||||
required Recipe recipe,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => RecipePreviewDialog(recipe: recipe),
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirmation modal shown after a successful scrape: extracted macros,
|
||||
/// ingredients, instructions, timing, and yield — reviewed before the
|
||||
/// recipe is persisted via the import controller.
|
||||
class RecipePreviewDialog extends ConsumerStatefulWidget {
|
||||
const RecipePreviewDialog({super.key, required this.recipe});
|
||||
|
||||
/// Snapshot of the parsed recipe; the dialog never re-reads it from state
|
||||
/// so controller updates cannot collapse the modal mid-animation.
|
||||
final Recipe recipe;
|
||||
|
||||
@override
|
||||
ConsumerState<RecipePreviewDialog> createState() =>
|
||||
_RecipePreviewDialogState();
|
||||
}
|
||||
|
||||
class _RecipePreviewDialogState extends ConsumerState<RecipePreviewDialog> {
|
||||
bool _isSaving = false;
|
||||
String? _saveError;
|
||||
|
||||
Recipe get recipe => widget.recipe;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final macros = recipe.macrosPerServing;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(recipe.title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (recipe.imageUrl != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
recipe.imageUrl!,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
height: 140,
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_outlined, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_MetaChip(
|
||||
icon: Icons.restaurant,
|
||||
label: '${recipe.servings} servings',
|
||||
),
|
||||
_MetaChip(
|
||||
icon: Icons.timer_outlined,
|
||||
label: 'Prep ${recipe.prepTimeMinutes} min',
|
||||
),
|
||||
_MetaChip(
|
||||
icon: Icons.local_fire_department_outlined,
|
||||
label: 'Cook ${recipe.cookTimeMinutes} min',
|
||||
),
|
||||
],
|
||||
),
|
||||
if (recipe.description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(recipe.description!, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const _SectionText('Macros per serving'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_MacroTile(label: 'kcal', value: '${macros.calories}'),
|
||||
_MacroTile(
|
||||
label: 'Protein',
|
||||
value: '${_fmt(macros.proteinGrams)} g',
|
||||
),
|
||||
_MacroTile(
|
||||
label: 'Carbs',
|
||||
value: '${_fmt(macros.carbsGrams)} g',
|
||||
),
|
||||
_MacroTile(label: 'Fat', value: '${_fmt(macros.fatGrams)} g'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SectionText('Ingredients (${recipe.ingredients.length})'),
|
||||
const SizedBox(height: 4),
|
||||
if (recipe.ingredients.isEmpty)
|
||||
const _EmptyHint('No ingredient list found.')
|
||||
else
|
||||
for (final ingredient in recipe.ingredients)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
'• ${ingredient.rawText}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SectionText('Steps (${recipe.instructions.length})'),
|
||||
const SizedBox(height: 4),
|
||||
if (recipe.instructions.isEmpty)
|
||||
const _EmptyHint('No instructions found.')
|
||||
else
|
||||
for (var index = 0; index < recipe.instructions.length; index++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
'${index + 1}. ${recipe.instructions[index]}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (_saveError != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_saveError!,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : () => Navigator.of(context).pop(false),
|
||||
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.bookmark_add_outlined),
|
||||
label: const Text('Save Recipe'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _fmt(double value) {
|
||||
final rounded = value.roundToDouble();
|
||||
return rounded == value ? rounded.toString() : value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
_saveError = null;
|
||||
});
|
||||
|
||||
final saved = await ref
|
||||
.read(recipeImportControllerProvider.notifier)
|
||||
.confirmSavePreview();
|
||||
if (!mounted) return;
|
||||
if (saved) {
|
||||
Navigator.of(context).pop(true);
|
||||
} else {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
_saveError = ref.read(recipeImportControllerProvider).errorMessage ??
|
||||
'Could not save the recipe.';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold sub-heading used for dialog sections.
|
||||
class _SectionText extends StatelessWidget {
|
||||
const _SectionText(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Italic hint shown when a recipe section could not be extracted.
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact chip used for yield / timing metadata.
|
||||
class _MetaChip extends StatelessWidget {
|
||||
const _MetaChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
avatar: Icon(icon, size: 16),
|
||||
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single macro tile (kcal / protein / carbs / fat).
|
||||
class _MacroTile extends StatelessWidget {
|
||||
const _MacroTile({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(label, style: theme.textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,455 @@
|
|||
import 'package:flutter/material.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';
|
||||
|
||||
/// Responsive 7-day meal-plan grid.
|
||||
///
|
||||
/// On wide layouts (>= 900 dp) all seven day columns are visible at once;
|
||||
/// compact widths fall back to a tabbed pager with one day per tab. Each
|
||||
/// column header shows the day's scheduled-protein rollup against the daily
|
||||
/// target, and every meal slot is a drop target for the saved-recipe tray
|
||||
/// (`Draggable`/`DragTarget`) as well as tappable for the quick-assign
|
||||
/// modal.
|
||||
class WeeklyCalendarView extends StatelessWidget {
|
||||
const WeeklyCalendarView({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onRecipeDropped,
|
||||
required this.onSlotTapped,
|
||||
required this.onTogglePrepared,
|
||||
required this.onClearSlot,
|
||||
});
|
||||
|
||||
final MealPlanCalendarState state;
|
||||
|
||||
/// A tray recipe was dropped onto [day]'s [mealType] slot.
|
||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
||||
onRecipeDropped;
|
||||
|
||||
/// The slot was tapped — open the quick-assign modal.
|
||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||
|
||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||
final void Function(MealPlanSlot slot) onClearSlot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final days = state.days;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 900;
|
||||
if (wide) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final day in days)
|
||||
SizedBox(
|
||||
width: constraints.maxWidth / 7,
|
||||
// Each column scrolls vertically so short windows never
|
||||
// trigger RenderFlex overflow stripes.
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
child: _DayColumn(
|
||||
day: day,
|
||||
state: state,
|
||||
onRecipeDropped: onRecipeDropped,
|
||||
onSlotTapped: onSlotTapped,
|
||||
onTogglePrepared: onTogglePrepared,
|
||||
onClearSlot: onClearSlot,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _CompactWeekPager(
|
||||
days: days,
|
||||
state: state,
|
||||
onRecipeDropped: onRecipeDropped,
|
||||
onSlotTapped: onSlotTapped,
|
||||
onTogglePrepared: onTogglePrepared,
|
||||
onClearSlot: onClearSlot,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabbed pager used below the 900 dp breakpoint: one tab per day.
|
||||
class _CompactWeekPager extends StatefulWidget {
|
||||
const _CompactWeekPager({
|
||||
required this.days,
|
||||
required this.state,
|
||||
required this.onRecipeDropped,
|
||||
required this.onSlotTapped,
|
||||
required this.onTogglePrepared,
|
||||
required this.onClearSlot,
|
||||
});
|
||||
|
||||
final List<DateTime> days;
|
||||
final MealPlanCalendarState state;
|
||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
||||
onRecipeDropped;
|
||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||
final void Function(MealPlanSlot slot) onClearSlot;
|
||||
|
||||
@override
|
||||
State<_CompactWeekPager> createState() => _CompactWeekPagerState();
|
||||
}
|
||||
|
||||
class _CompactWeekPagerState extends State<_CompactWeekPager>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController =
|
||||
TabController(length: widget.days.length, vsync: this);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
static const List<String> _weekdayLabels = [
|
||||
'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: widget.days.length,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [
|
||||
for (final day in widget.days)
|
||||
Tab(text: '${_weekdayLabels[day.weekday - 1]} ${day.day}'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
for (final day in widget.days)
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: _DayColumn(
|
||||
day: day,
|
||||
state: widget.state,
|
||||
onRecipeDropped: widget.onRecipeDropped,
|
||||
onSlotTapped: widget.onSlotTapped,
|
||||
onTogglePrepared: widget.onTogglePrepared,
|
||||
onClearSlot: widget.onClearSlot,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One day of the weekly grid: header (date + protein rollup indicator)
|
||||
/// followed by the four meal slots.
|
||||
class _DayColumn extends StatelessWidget {
|
||||
const _DayColumn({
|
||||
required this.day,
|
||||
required this.state,
|
||||
required this.onRecipeDropped,
|
||||
required this.onSlotTapped,
|
||||
required this.onTogglePrepared,
|
||||
required this.onClearSlot,
|
||||
});
|
||||
|
||||
final DateTime day;
|
||||
final MealPlanCalendarState state;
|
||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
||||
onRecipeDropped;
|
||||
final void Function(DateTime day, MealType mealType) onSlotTapped;
|
||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||
final void Function(MealPlanSlot slot) onClearSlot;
|
||||
|
||||
static const List<String> _weekdayLabels = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final protein = state.proteinForDay(day);
|
||||
final target = state.dailyProteinTargetGrams;
|
||||
final progress = state.proteinProgressForDay(day).clamp(0.0, 1.0);
|
||||
final reachedGoal = protein >= target;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${_weekdayLabels[day.weekday - 1]} ${day.day}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.fitness_center,
|
||||
size: 14,
|
||||
color: reachedGoal ? Colors.green : theme.colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${protein.toStringAsFixed(0)}/'
|
||||
'${target.toStringAsFixed(0)} g',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: reachedGoal
|
||||
? Colors.green.shade800
|
||||
: theme.colorScheme.outline,
|
||||
fontWeight:
|
||||
reachedGoal ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
||||
color: reachedGoal ? Colors.green : theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 12),
|
||||
for (final mealType in MealType.values) ...[
|
||||
_DaySlotCard(
|
||||
day: day,
|
||||
mealType: mealType,
|
||||
slot: state.slotFor(day, mealType),
|
||||
recipe: _recipeForSlot(state.slotFor(day, mealType)),
|
||||
onRecipeDropped: onRecipeDropped,
|
||||
onTap: () => onSlotTapped(day, mealType),
|
||||
onTogglePrepared: onTogglePrepared,
|
||||
onClearSlot: onClearSlot,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Recipe? _recipeForSlot(MealPlanSlot? slot) =>
|
||||
slot == null ? null : state.recipeFor(slot);
|
||||
}
|
||||
|
||||
/// A single meal slot: drop target for tray drags and tap target for the
|
||||
/// quick-assign modal; shows the assigned recipe (with protein badge) or a
|
||||
/// free-text note, a prepared checkbox, and a clear action.
|
||||
class _DaySlotCard extends StatelessWidget {
|
||||
const _DaySlotCard({
|
||||
required this.day,
|
||||
required this.mealType,
|
||||
required this.slot,
|
||||
required this.recipe,
|
||||
required this.onRecipeDropped,
|
||||
required this.onTap,
|
||||
required this.onTogglePrepared,
|
||||
required this.onClearSlot,
|
||||
});
|
||||
|
||||
final DateTime day;
|
||||
final MealType mealType;
|
||||
final MealPlanSlot? slot;
|
||||
final Recipe? recipe;
|
||||
final void Function(Recipe recipe, DateTime day, MealType mealType)
|
||||
onRecipeDropped;
|
||||
final VoidCallback onTap;
|
||||
final void Function(MealPlanSlot slot) onTogglePrepared;
|
||||
final void Function(MealPlanSlot slot) onClearSlot;
|
||||
|
||||
static IconData _iconFor(MealType type) {
|
||||
switch (type) {
|
||||
case MealType.breakfast:
|
||||
return Icons.free_breakfast_outlined;
|
||||
case MealType.lunch:
|
||||
return Icons.lunch_dining_outlined;
|
||||
case MealType.dinner:
|
||||
return Icons.dinner_dining_outlined;
|
||||
case MealType.snack:
|
||||
return Icons.cookie_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final currentSlot = slot;
|
||||
final filled = currentSlot != null && !currentSlot.isEmpty;
|
||||
|
||||
return DragTarget<Recipe>(
|
||||
onAcceptWithDetails: (details) =>
|
||||
onRecipeDropped(details.data, day, mealType),
|
||||
builder: (context, candidateRecipes, rejectedRecipes) {
|
||||
final highlighted = candidateRecipes.isNotEmpty;
|
||||
return Material(
|
||||
color: highlighted
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: _buildContent(theme, currentSlot, filled),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(
|
||||
ThemeData theme,
|
||||
MealPlanSlot? currentSlot,
|
||||
bool filled,
|
||||
) {
|
||||
final assignedRecipe = recipe;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_iconFor(mealType), size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
mealType.displayName,
|
||||
style: theme.textTheme.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (currentSlot != null && filled) ...[
|
||||
Checkbox(
|
||||
value: currentSlot.isPrepared,
|
||||
onChanged: (_) => onTogglePrepared(currentSlot),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (value) {
|
||||
if (value == 'clear') onClearSlot(currentSlot);
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'clear', child: Text('Clear slot')),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Icon(Icons.add, size: 18, color: theme.colorScheme.outline),
|
||||
],
|
||||
),
|
||||
if (currentSlot != null && filled) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
assignedRecipe?.title ?? currentSlot.customDescription ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: currentSlot.isPrepared
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (assignedRecipe != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_MiniProteinBadge(
|
||||
proteinGrams: assignedRecipe.macrosPerServing.proteinGrams,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${assignedRecipe.macrosPerServing.calories} kcal',
|
||||
style: theme.textTheme.labelSmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
] else
|
||||
Text(
|
||||
'Tap or drop a recipe',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny protein badge used inside calendar slots.
|
||||
class _MiniProteinBadge extends StatelessWidget {
|
||||
const _MiniProteinBadge({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: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green.shade800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
import 'package:drift/native.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/utils/date_time_utils.dart';
|
||||
import 'package:glp1_companion/core/utils/uuid.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_meal_plan_repository.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/data/repositories/drift_recipe_repository.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/macro_profile.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/meal_plan_slot.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/entities/recipe_ingredient.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/presentation/controllers/meal_plan_calendar_controller.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/presentation/controllers/recipe_import_controller.dart';
|
||||
import 'package:glp1_companion/features/nutrition/domain/entities/meal_type.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase database;
|
||||
late DriftRecipeRepository recipeRepository;
|
||||
late DriftMealPlanRepository mealPlanRepository;
|
||||
late ProviderContainer container;
|
||||
|
||||
final weekMonday = DateTime(2026, 10, 12); // A Monday.
|
||||
final tuesday = DateTime(2026, 10, 13);
|
||||
|
||||
Recipe makeRecipe({
|
||||
String? id,
|
||||
String title = 'Greek Yogurt Power Bowl',
|
||||
String sourceUrl = 'https://recipes.test/yogurt-bowl',
|
||||
double proteinGrams = 30,
|
||||
}) =>
|
||||
Recipe(
|
||||
id: id ?? generateUuid(),
|
||||
title: title,
|
||||
sourceUrl: sourceUrl,
|
||||
imageUrl: 'https://recipes.test/bowl.jpg',
|
||||
description: 'High-protein breakfast.',
|
||||
prepTimeMinutes: 10,
|
||||
cookTimeMinutes: 0,
|
||||
servings: 2,
|
||||
ingredients: const [
|
||||
RecipeIngredient(
|
||||
rawText: '1 cup greek yogurt',
|
||||
parsedItemName: 'greek yogurt',
|
||||
quantity: 1,
|
||||
unit: 'cup',
|
||||
),
|
||||
],
|
||||
instructions: const ['Scoop, top, and eat.'],
|
||||
macrosPerServing: MacroProfile(
|
||||
calories: 320,
|
||||
proteinGrams: proteinGrams,
|
||||
carbsGrams: 24,
|
||||
fatGrams: 9,
|
||||
),
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
database = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
recipeRepository = DriftRecipeRepository(database);
|
||||
mealPlanRepository = DriftMealPlanRepository(database);
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
appDatabaseProvider.overrideWithValue(database),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
container.dispose();
|
||||
database.close();
|
||||
});
|
||||
|
||||
group('startOfWeek', () {
|
||||
test('snaps any day back to its Monday', () {
|
||||
expect(startOfWeek(DateTime(2026, 10, 16, 14, 30)), weekMonday);
|
||||
expect(startOfWeek(weekMonday), weekMonday);
|
||||
expect(startOfWeek(DateTime(2026, 10, 18, 23, 59)), weekMonday);
|
||||
});
|
||||
});
|
||||
|
||||
group('DriftRecipeRepository', () {
|
||||
test('saveRecipe round-trips all entity fields', () async {
|
||||
final saved = await recipeRepository.saveRecipe(makeRecipe());
|
||||
final fetched = await recipeRepository.getRecipe(saved.id);
|
||||
|
||||
expect(fetched, isNotNull);
|
||||
expect(fetched!.title, 'Greek Yogurt Power Bowl');
|
||||
expect(fetched.imageUrl, 'https://recipes.test/bowl.jpg');
|
||||
expect(fetched.description, 'High-protein breakfast.');
|
||||
expect(fetched.prepTimeMinutes, 10);
|
||||
expect(fetched.cookTimeMinutes, 0);
|
||||
expect(fetched.servings, 2);
|
||||
expect(fetched.ingredients, hasLength(1));
|
||||
expect(fetched.ingredients.single.unit, 'cup');
|
||||
expect(fetched.instructions, ['Scoop, top, and eat.']);
|
||||
expect(fetched.macrosPerServing.proteinGrams, 30);
|
||||
expect(fetched.macrosPerServing.calories, 320);
|
||||
});
|
||||
|
||||
test('importRecipe de-duplicates by source URL', () async {
|
||||
final first = await recipeRepository
|
||||
.importRecipe(makeRecipe(title: 'Version One'));
|
||||
final second = await recipeRepository.importRecipe(
|
||||
makeRecipe(title: 'Version Two'),
|
||||
);
|
||||
|
||||
expect(second.id, first.id);
|
||||
final catalog = await recipeRepository.getRecipes();
|
||||
expect(catalog, hasLength(1));
|
||||
expect(catalog.single.title, 'Version Two');
|
||||
});
|
||||
|
||||
test('watchRecipes emits on every write', () async {
|
||||
final events = <List<Recipe>>[];
|
||||
final subscription = recipeRepository.watchRecipes().listen(events.add);
|
||||
|
||||
await recipeRepository.saveRecipe(makeRecipe());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(events, isNotEmpty);
|
||||
expect(events.last, hasLength(1));
|
||||
await subscription.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
group('DriftMealPlanRepository', () {
|
||||
test('slots persist within the half-open window only', () async {
|
||||
final recipe = await recipeRepository.saveRecipe(makeRecipe());
|
||||
await mealPlanRepository.saveSlot(
|
||||
MealPlanSlot(
|
||||
id: generateUuid(),
|
||||
date: tuesday,
|
||||
mealType: MealType.lunch,
|
||||
recipeId: recipe.id,
|
||||
),
|
||||
);
|
||||
await mealPlanRepository.saveSlot(
|
||||
MealPlanSlot(
|
||||
id: generateUuid(),
|
||||
date: DateTime(2026, 10, 20), // Next week.
|
||||
mealType: MealType.dinner,
|
||||
customDescription: 'Outside the window',
|
||||
),
|
||||
);
|
||||
|
||||
final inWindow = await mealPlanRepository.getSlots(
|
||||
from: weekMonday,
|
||||
to: weekMonday.add(const Duration(days: 7)),
|
||||
);
|
||||
expect(inWindow, hasLength(1));
|
||||
expect(inWindow.single.mealType, MealType.lunch);
|
||||
expect(inWindow.single.recipeId, recipe.id);
|
||||
});
|
||||
|
||||
test('saveSlot upserts by id and clearSlot deletes', () async {
|
||||
final slotId = generateUuid();
|
||||
await mealPlanRepository.saveSlot(
|
||||
MealPlanSlot(
|
||||
id: slotId,
|
||||
date: tuesday,
|
||||
mealType: MealType.dinner,
|
||||
customDescription: 'Salmon & greens',
|
||||
),
|
||||
);
|
||||
await mealPlanRepository.saveSlot(
|
||||
MealPlanSlot(
|
||||
id: slotId,
|
||||
date: tuesday,
|
||||
mealType: MealType.dinner,
|
||||
customDescription: 'Salmon & greens',
|
||||
isPrepared: true,
|
||||
),
|
||||
);
|
||||
|
||||
var slots = await mealPlanRepository.getSlots(
|
||||
from: weekMonday,
|
||||
to: weekMonday.add(const Duration(days: 7)),
|
||||
);
|
||||
expect(slots, hasLength(1));
|
||||
expect(slots.single.isPrepared, isTrue);
|
||||
|
||||
await mealPlanRepository.deleteSlot(slotId);
|
||||
slots = await mealPlanRepository.getSlots(
|
||||
from: weekMonday,
|
||||
to: weekMonday.add(const Duration(days: 7)),
|
||||
);
|
||||
expect(slots, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('MealPlanCalendarController', () {
|
||||
test('window starts on Monday and slots + protein roll up per day',
|
||||
() async {
|
||||
final recipeA = await recipeRepository.saveRecipe(
|
||||
makeRecipe(title: 'A', sourceUrl: 'https://recipes.test/a'),
|
||||
);
|
||||
final recipeB = await recipeRepository.saveRecipe(
|
||||
makeRecipe(
|
||||
title: 'B',
|
||||
sourceUrl: 'https://recipes.test/b',
|
||||
proteinGrams: 25,
|
||||
),
|
||||
);
|
||||
|
||||
final controller =
|
||||
container.read(mealPlanCalendarControllerProvider.notifier);
|
||||
await controller.refresh();
|
||||
|
||||
final start =
|
||||
container.read(mealPlanCalendarControllerProvider).windowStart;
|
||||
expect(start.weekday, DateTime.monday);
|
||||
expect(start.hour, 0);
|
||||
|
||||
await controller.assignRecipe(
|
||||
day: start,
|
||||
mealType: MealType.breakfast,
|
||||
recipeId: recipeA.id,
|
||||
);
|
||||
await controller.assignRecipe(
|
||||
day: tuesday,
|
||||
mealType: MealType.dinner,
|
||||
recipeId: recipeB.id,
|
||||
);
|
||||
|
||||
final state = container.read(mealPlanCalendarControllerProvider);
|
||||
expect(state.slots, hasLength(2));
|
||||
expect(state.proteinForDay(start), 30);
|
||||
expect(state.proteinForDay(tuesday), 25);
|
||||
expect(state.proteinForDay(DateTime(2026, 10, 14)), 0);
|
||||
expect(state.slotFor(tuesday, MealType.dinner)?.recipeId, recipeB.id);
|
||||
expect(state.slotFor(tuesday, MealType.breakfast), isNull);
|
||||
});
|
||||
|
||||
test('assigning to an occupied slot reuses its row id', () async {
|
||||
final recipeA = await recipeRepository.saveRecipe(
|
||||
makeRecipe(title: 'A', sourceUrl: 'https://recipes.test/a'),
|
||||
);
|
||||
final recipeB = await recipeRepository.saveRecipe(
|
||||
makeRecipe(title: 'B', sourceUrl: 'https://recipes.test/b'),
|
||||
);
|
||||
|
||||
final controller =
|
||||
container.read(mealPlanCalendarControllerProvider.notifier);
|
||||
await controller.refresh();
|
||||
|
||||
await controller.assignRecipe(
|
||||
day: weekMonday,
|
||||
mealType: MealType.lunch,
|
||||
recipeId: recipeA.id,
|
||||
);
|
||||
final firstId = container
|
||||
.read(mealPlanCalendarControllerProvider)
|
||||
.slotFor(weekMonday, MealType.lunch)!
|
||||
.id;
|
||||
|
||||
await controller.assignRecipe(
|
||||
day: weekMonday,
|
||||
mealType: MealType.lunch,
|
||||
recipeId: recipeB.id,
|
||||
);
|
||||
final updated = container
|
||||
.read(mealPlanCalendarControllerProvider)
|
||||
.slotFor(weekMonday, MealType.lunch)!;
|
||||
|
||||
expect(updated.id, firstId);
|
||||
expect(updated.recipeId, recipeB.id);
|
||||
});
|
||||
|
||||
test('deleting a recipe clears its assigned slots on refresh', () async {
|
||||
final recipe = await recipeRepository.saveRecipe(makeRecipe());
|
||||
|
||||
final controller =
|
||||
container.read(mealPlanCalendarControllerProvider.notifier);
|
||||
await controller.refresh();
|
||||
await controller.assignRecipe(
|
||||
day: weekMonday,
|
||||
mealType: MealType.breakfast,
|
||||
recipeId: recipe.id,
|
||||
);
|
||||
|
||||
await recipeRepository.deleteRecipe(recipe.id);
|
||||
await controller.refresh();
|
||||
|
||||
final state = container.read(mealPlanCalendarControllerProvider);
|
||||
expect(state.slotFor(weekMonday, MealType.breakfast)?.recipeId, isNull);
|
||||
});
|
||||
|
||||
test('nextWeek/previousWeek/goToToday move the window', () async {
|
||||
final controller =
|
||||
container.read(mealPlanCalendarControllerProvider.notifier);
|
||||
await controller.refresh();
|
||||
final initial =
|
||||
container.read(mealPlanCalendarControllerProvider).windowStart;
|
||||
|
||||
await controller.nextWeek();
|
||||
expect(
|
||||
container.read(mealPlanCalendarControllerProvider).windowStart,
|
||||
initial.add(const Duration(days: 7)),
|
||||
);
|
||||
|
||||
await controller.previousWeek();
|
||||
expect(
|
||||
container.read(mealPlanCalendarControllerProvider).windowStart,
|
||||
initial,
|
||||
);
|
||||
|
||||
await controller.nextWeek();
|
||||
await controller.goToToday();
|
||||
expect(
|
||||
container.read(mealPlanCalendarControllerProvider).windowStart,
|
||||
startOfWeek(DateTime.now()),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom entries and prepared toggles persist', () async {
|
||||
final controller =
|
||||
container.read(mealPlanCalendarControllerProvider.notifier);
|
||||
await controller.refresh();
|
||||
|
||||
await controller.saveCustomEntry(
|
||||
day: weekMonday,
|
||||
mealType: MealType.snack,
|
||||
description: 'Cottage cheese & pineapple',
|
||||
);
|
||||
var slot = container
|
||||
.read(mealPlanCalendarControllerProvider)
|
||||
.slotFor(weekMonday, MealType.snack)!;
|
||||
expect(slot.customDescription, 'Cottage cheese & pineapple');
|
||||
expect(slot.recipeId, isNull);
|
||||
expect(slot.isEmpty, isFalse);
|
||||
|
||||
await controller.togglePrepared(slot);
|
||||
slot = container
|
||||
.read(mealPlanCalendarControllerProvider)
|
||||
.slotFor(weekMonday, MealType.snack)!;
|
||||
expect(slot.isPrepared, isTrue);
|
||||
|
||||
await controller.clearSlot(slot);
|
||||
expect(
|
||||
container
|
||||
.read(mealPlanCalendarControllerProvider)
|
||||
.slotFor(weekMonday, MealType.snack),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('RecipeImportController', () {
|
||||
test('submitUrl with an empty URL sets an error', () async {
|
||||
// Force construction of the notifier (build() subscribes to the
|
||||
// recipe watch stream).
|
||||
final importController =
|
||||
container.read(recipeImportControllerProvider.notifier);
|
||||
|
||||
await importController.submitUrl(' ');
|
||||
|
||||
final state = container.read(recipeImportControllerProvider);
|
||||
expect(state.isImporting, isFalse);
|
||||
expect(state.errorMessage, 'Paste a recipe URL first.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
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/features/meal_planner/presentation/screens/meal_planner_screen.dart';
|
||||
|
||||
/// Regression tests for the desktop resize overflow (yellow/black hatched
|
||||
/// stripes): the meal planner must reflow into scrollable layouts at short
|
||||
/// window heights instead of overflowing a rigid `Column`.
|
||||
///
|
||||
/// 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> pumpScreen(
|
||||
WidgetTester tester, {
|
||||
required Size logicalSize,
|
||||
}) 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)],
|
||||
child: const MaterialApp(home: MealPlannerScreen()),
|
||||
),
|
||||
);
|
||||
// Let the initial drift loads (watch stream + microtask refresh) settle.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
/// Unmounts the tree and flushes the zero-duration timer drift schedules
|
||||
/// when its query streams are cancelled during container disposal
|
||||
/// (`StreamQueryStore.markAsClosed`), which would otherwise trip the test
|
||||
/// framework's pending-timers invariant.
|
||||
Future<void> unmountAndFlushTimers(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'short compact window collapses into a scrollable page without overflow',
|
||||
(tester) async {
|
||||
await pumpScreen(tester, logicalSize: const Size(480, 400));
|
||||
|
||||
expect(find.byType(MealPlannerScreen), findsOneWidget);
|
||||
expect(find.text('Saved Recipes'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// The whole page scrolls: drag upward and verify the content moved up.
|
||||
final before = tester.getTopLeft(find.text('Saved Recipes')).dy;
|
||||
await tester.drag(find.text('Saved Recipes'), const Offset(0, -200));
|
||||
await tester.pump();
|
||||
final after = tester.getTopLeft(find.text('Saved Recipes')).dy;
|
||||
|
||||
expect(after, lessThan(before),
|
||||
reason: 'the page should scroll vertically when the window is short');
|
||||
expect(tester.takeException(), isNull);
|
||||
await unmountAndFlushTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'short wide window keeps the 7-day grid with scrollable day columns',
|
||||
(tester) async {
|
||||
await pumpScreen(tester, logicalSize: const Size(1100, 420));
|
||||
|
||||
// Wide breakpoint: all seven weekday headers render at once.
|
||||
for (final label in const [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
||||
]) {
|
||||
expect(find.textContaining(label), findsWidgets);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
// The outer page scrolls so the calendar area is reachable when the
|
||||
// window is shorter than the fixed top sections + calendar.
|
||||
final before = tester.getTopLeft(find.text('Saved Recipes')).dy;
|
||||
await tester.drag(find.text('Saved Recipes'), const Offset(0, -200));
|
||||
await tester.pump();
|
||||
expect(tester.getTopLeft(find.text('Saved Recipes')).dy, lessThan(before));
|
||||
expect(tester.takeException(), isNull);
|
||||
await unmountAndFlushTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('tall window uses the pinned layout without overflow',
|
||||
(tester) async {
|
||||
await pumpScreen(tester, logicalSize: const Size(1000, 900));
|
||||
|
||||
expect(find.text('Saved Recipes'), findsOneWidget);
|
||||
for (final label in const [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday',
|
||||
]) {
|
||||
expect(find.textContaining(label), findsWidgets);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
await unmountAndFlushTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('extremely short window still renders without overflow',
|
||||
(tester) async {
|
||||
await pumpScreen(tester, logicalSize: const Size(1280, 320));
|
||||
|
||||
expect(find.byType(MealPlannerScreen), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await unmountAndFlushTimers(tester);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:glp1_companion/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart';
|
||||
|
||||
/// Rich schema.org/JSON-LD fixture: `@graph` container, mixed
|
||||
/// `recipeInstructions` shapes (string, `HowToStep`, nested `HowToSection`),
|
||||
/// ISO-8601 durations, list-valued `image`, and string-quantity nutrition.
|
||||
const String _jsonLdHtml = '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>High-Protein Chicken Bowl - Example Kitchen</title>
|
||||
<meta property="og:title" content="OG Fallback Title" />
|
||||
<meta property="og:image" content="https://example.com/og.jpg" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{ "@type": "WebSite", "name": "Example Kitchen" },
|
||||
{
|
||||
"@type": "Recipe",
|
||||
"name": "High-Protein Chicken Bowl",
|
||||
"description": "A quick post-workout bowl.",
|
||||
"image": [
|
||||
"https://example.com/chicken-1.jpg",
|
||||
{ "@type": "ImageObject", "url": "https://example.com/chicken-2.jpg" }
|
||||
],
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT35M",
|
||||
"recipeYield": "4 servings",
|
||||
"recipeIngredient": [
|
||||
"2 chicken breasts",
|
||||
"1 1/2 cups cooked rice",
|
||||
"1/4 tsp salt",
|
||||
"2 tbsp olive oil"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
"Season the chicken.",
|
||||
{ "@type": "HowToStep", "text": "Grill for 6 minutes per side." },
|
||||
{
|
||||
"@type": "HowToSection",
|
||||
"name": "Finish",
|
||||
"itemListElement": [
|
||||
{ "@type": "HowToStep", "text": "Rest, then slice and serve." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "420 kcal",
|
||||
"proteinContent": "38 g",
|
||||
"carbohydrateContent": "45 g",
|
||||
"fatContent": "12 g"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
''';
|
||||
|
||||
void main() {
|
||||
late ScrapeRecipeFromUrl useCase;
|
||||
|
||||
setUp(() => useCase = ScrapeRecipeFromUrl());
|
||||
|
||||
group('JSON-LD recipe extraction', () {
|
||||
test('parses a full @graph-hosted schema.org Recipe', () {
|
||||
final recipe = useCase.parseHtml(
|
||||
_jsonLdHtml,
|
||||
sourceUrl: 'https://example.com/chicken-bowl',
|
||||
);
|
||||
|
||||
expect(recipe, isNotNull);
|
||||
expect(recipe!.id, isNotEmpty);
|
||||
expect(recipe.title, 'High-Protein Chicken Bowl');
|
||||
expect(recipe.sourceUrl, 'https://example.com/chicken-bowl');
|
||||
expect(recipe.description, 'A quick post-workout bowl.');
|
||||
expect(recipe.imageUrl, 'https://example.com/chicken-1.jpg');
|
||||
expect(recipe.prepTimeMinutes, 15);
|
||||
expect(recipe.cookTimeMinutes, 35);
|
||||
expect(recipe.servings, 4);
|
||||
});
|
||||
|
||||
test('extracts macros from the nutrition block', () {
|
||||
final recipe = useCase.parseHtml(
|
||||
_jsonLdHtml,
|
||||
sourceUrl: 'https://example.com/chicken-bowl',
|
||||
)!;
|
||||
|
||||
expect(recipe.macrosPerServing.calories, 420);
|
||||
expect(recipe.macrosPerServing.proteinGrams, 38);
|
||||
expect(recipe.macrosPerServing.carbsGrams, 45);
|
||||
expect(recipe.macrosPerServing.fatGrams, 12);
|
||||
});
|
||||
|
||||
test('parses ingredient quantities, units, and item names', () {
|
||||
final recipe = useCase.parseHtml(
|
||||
_jsonLdHtml,
|
||||
sourceUrl: 'https://example.com/chicken-bowl',
|
||||
)!;
|
||||
|
||||
expect(recipe.ingredients, hasLength(4));
|
||||
|
||||
final chicken = recipe.ingredients[0];
|
||||
expect(chicken.rawText, '2 chicken breasts');
|
||||
expect(chicken.quantity, 2);
|
||||
expect(chicken.unit, isNull); // "breasts" is not a known unit
|
||||
expect(chicken.parsedItemName, 'chicken breasts');
|
||||
|
||||
final rice = recipe.ingredients[1];
|
||||
expect(rice.quantity, 1.5); // mixed number "1 1/2"
|
||||
expect(rice.unit, 'cups');
|
||||
expect(rice.parsedItemName, 'cooked rice');
|
||||
|
||||
final salt = recipe.ingredients[2];
|
||||
expect(salt.quantity, 0.25);
|
||||
expect(salt.unit, 'tsp');
|
||||
expect(salt.parsedItemName, 'salt');
|
||||
|
||||
final oil = recipe.ingredients[3];
|
||||
expect(oil.quantity, 2);
|
||||
expect(oil.unit, 'tbsp');
|
||||
expect(oil.parsedItemName, 'olive oil');
|
||||
});
|
||||
|
||||
test('flattens strings, HowToStep objects, and HowToSections', () {
|
||||
final recipe = useCase.parseHtml(
|
||||
_jsonLdHtml,
|
||||
sourceUrl: 'https://example.com/chicken-bowl',
|
||||
)!;
|
||||
|
||||
expect(recipe.instructions, [
|
||||
'Season the chicken.',
|
||||
'Grill for 6 minutes per side.',
|
||||
'Rest, then slice and serve.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('JSON-LD navigation variants', () {
|
||||
test('finds a root-level Recipe object without @graph', () {
|
||||
const html = '''
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Root Recipe",
|
||||
"recipeIngredient": ["1 cup oats"],
|
||||
"recipeInstructions": "Mix and eat.",
|
||||
"nutrition": { "calories": 300 }
|
||||
}
|
||||
</script>
|
||||
</head><body></body></html>
|
||||
''';
|
||||
|
||||
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/a');
|
||||
expect(recipe, isNotNull);
|
||||
expect(recipe!.title, 'Root Recipe');
|
||||
expect(recipe.macrosPerServing.calories, 300);
|
||||
expect(recipe.instructions, ['Mix and eat.']);
|
||||
// No recipeYield → default servings.
|
||||
expect(recipe.servings, useCase.defaultServings);
|
||||
});
|
||||
|
||||
test('finds a Recipe inside a top-level JSON array with list @type', () {
|
||||
const html = '''
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
[
|
||||
{ "@type": "WebSite", "name": "Other" },
|
||||
{
|
||||
"@type": ["Recipe", "CreativeWork"],
|
||||
"name": "Array Recipe",
|
||||
"recipeIngredient": []
|
||||
}
|
||||
]
|
||||
</script>
|
||||
</head><body></body></html>
|
||||
''';
|
||||
|
||||
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/b');
|
||||
expect(recipe, isNotNull);
|
||||
expect(recipe!.title, 'Array Recipe');
|
||||
});
|
||||
|
||||
test('skips malformed JSON-LD and falls through to OpenGraph', () {
|
||||
const html = '''
|
||||
<html><head>
|
||||
<script type="application/ld+json">{ not valid json</script>
|
||||
<meta property="og:title" content="OG Rescued Title" />
|
||||
<meta property="og:description" content="A description." />
|
||||
<meta property="og:image" content="https://x.test/img.png" />
|
||||
</head><body></body></html>
|
||||
''';
|
||||
|
||||
final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/c');
|
||||
expect(recipe, isNotNull);
|
||||
expect(recipe!.title, 'OG Rescued Title');
|
||||
expect(recipe.description, 'A description.');
|
||||
expect(recipe.imageUrl, 'https://x.test/img.png');
|
||||
expect(recipe.ingredients, isEmpty);
|
||||
expect(recipe.macrosPerServing.isEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('OpenGraph fallback', () {
|
||||
test('uses og tags when JSON-LD is absent', () {
|
||||
const html = '''
|
||||
<html><head>
|
||||
<meta property="og:title" content="Simple OG Soup" />
|
||||
<meta property="og:image" content="https://soup.test/soup.jpg" />
|
||||
<meta property="og:description" content="Warm and cozy." />
|
||||
</head><body></body></html>
|
||||
''';
|
||||
|
||||
final recipe = useCase.parseHtml(html, sourceUrl: 'https://soup.test/p');
|
||||
expect(recipe, isNotNull);
|
||||
expect(recipe!.title, 'Simple OG Soup');
|
||||
expect(recipe.imageUrl, 'https://soup.test/soup.jpg');
|
||||
expect(recipe.description, 'Warm and cozy.');
|
||||
expect(recipe.ingredients, isEmpty);
|
||||
expect(recipe.instructions, isEmpty);
|
||||
expect(recipe.macrosPerServing.isEmpty, isTrue);
|
||||
expect(recipe.prepTimeMinutes, 0);
|
||||
expect(recipe.cookTimeMinutes, 0);
|
||||
});
|
||||
|
||||
test('returns null when neither JSON-LD nor og:title exist', () {
|
||||
const html = '<html><head><title>Just a page</title></head>'
|
||||
'<body><p>hello</p></body></html>';
|
||||
expect(
|
||||
useCase.parseHtml(html, sourceUrl: 'https://none.test/'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('execute()', () {
|
||||
test('downloads via the injected fetcher and parses', () async {
|
||||
var requestedUrl = Uri.parse('about:blank');
|
||||
final scraping = ScrapeRecipeFromUrl(
|
||||
fetcher: (url) async {
|
||||
requestedUrl = url;
|
||||
return _jsonLdHtml;
|
||||
},
|
||||
);
|
||||
|
||||
final recipe = await scraping('https://example.com/chicken-bowl');
|
||||
|
||||
expect(requestedUrl.toString(), 'https://example.com/chicken-bowl');
|
||||
expect(recipe.title, 'High-Protein Chicken Bowl');
|
||||
expect(recipe.macrosPerServing.proteinGrams, 38);
|
||||
});
|
||||
|
||||
test('rejects non-http(s) and malformed URLs', () async {
|
||||
final scraping = ScrapeRecipeFromUrl(
|
||||
fetcher: (_) async => _jsonLdHtml,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
scraping('ftp://example.com/file'),
|
||||
throwsA(isA<RecipeScrapeException>()),
|
||||
);
|
||||
await expectLater(
|
||||
scraping('not a url'),
|
||||
throwsA(isA<RecipeScrapeException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when no recipe metadata is present', () async {
|
||||
final scraping = ScrapeRecipeFromUrl(
|
||||
fetcher: (_) async =>
|
||||
'<html><head><title>Nothing here</title></head></html>',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
scraping('https://example.com/blank'),
|
||||
throwsA(
|
||||
isA<RecipeScrapeException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('No schema.org Recipe'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('propagates fetcher failures as RecipeScrapeException', () async {
|
||||
final scraping = ScrapeRecipeFromUrl(
|
||||
fetcher: (_) async => throw Exception('connection reset'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
scraping('https://example.com/flaky'),
|
||||
throwsA(isA<RecipeScrapeException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue