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:
Mike Kell 2026-10-16 09:07:41 -04:00
parent 094cf08f25
commit bdee2de35f
24 changed files with 5870 additions and 37 deletions

View File

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

View File

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

View File

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

25
lib/core/utils/uuid.dart Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
);

View File

@ -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,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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