From bdee2de35f11403d2115011395ca9d5f4c509e46 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Fri, 16 Oct 2026 09:07:41 -0400 Subject: [PATCH] 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 --- BUILD_STATE.md | 20 +- lib/core/database/app_database.dart | 186 +- lib/core/database/app_database.g.dart | 1870 ++++++++++++++++- lib/core/utils/date_time_utils.dart | 16 + lib/core/utils/uuid.dart | 25 + .../drift_meal_plan_repository.dart | 75 + .../repositories/drift_recipe_repository.dart | 139 ++ .../domain/entities/macro_profile.dart | 63 + .../domain/entities/meal_plan_slot.dart | 63 + .../meal_planner/domain/entities/recipe.dart | 93 + .../domain/entities/recipe_ingredient.dart | 143 ++ .../repositories/meal_plan_repository.dart | 30 + .../repositories/recipe_repository.dart | 31 + .../usecases/scrape_recipe_from_url.dart | 368 ++++ .../meal_plan_calendar_controller.dart | 305 +++ .../controllers/recipe_import_controller.dart | 191 ++ .../screens/meal_planner_screen.dart | 354 +++- .../widgets/assign_slot_dialog.dart | 188 ++ .../widgets/recipe_preview_dialog.dart | 281 +++ .../widgets/saved_recipes_tray.dart | 227 ++ .../widgets/weekly_calendar_view.dart | 455 ++++ .../meal_plan_repository_test.dart | 363 ++++ .../meal_planner_screen_test.dart | 119 ++ .../meal_planner/recipe_scraper_test.dart | 302 +++ 24 files changed, 5870 insertions(+), 37 deletions(-) create mode 100644 lib/core/utils/uuid.dart create mode 100644 lib/features/meal_planner/data/repositories/drift_meal_plan_repository.dart create mode 100644 lib/features/meal_planner/data/repositories/drift_recipe_repository.dart create mode 100644 lib/features/meal_planner/domain/entities/macro_profile.dart create mode 100644 lib/features/meal_planner/domain/entities/meal_plan_slot.dart create mode 100644 lib/features/meal_planner/domain/entities/recipe.dart create mode 100644 lib/features/meal_planner/domain/entities/recipe_ingredient.dart create mode 100644 lib/features/meal_planner/domain/repositories/meal_plan_repository.dart create mode 100644 lib/features/meal_planner/domain/repositories/recipe_repository.dart create mode 100644 lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart create mode 100644 lib/features/meal_planner/presentation/controllers/meal_plan_calendar_controller.dart create mode 100644 lib/features/meal_planner/presentation/controllers/recipe_import_controller.dart create mode 100644 lib/features/meal_planner/presentation/widgets/assign_slot_dialog.dart create mode 100644 lib/features/meal_planner/presentation/widgets/recipe_preview_dialog.dart create mode 100644 lib/features/meal_planner/presentation/widgets/saved_recipes_tray.dart create mode 100644 lib/features/meal_planner/presentation/widgets/weekly_calendar_view.dart create mode 100644 test/features/meal_planner/meal_plan_repository_test.dart create mode 100644 test/features/meal_planner/meal_planner_screen_test.dart create mode 100644 test/features/meal_planner/recipe_scraper_test.dart diff --git a/BUILD_STATE.md b/BUILD_STATE.md index bc73080..8195cba 100644 --- a/BUILD_STATE.md +++ b/BUILD_STATE.md @@ -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) \ No newline at end of file diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 766b119..c874fb0 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -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` of ingredient records. + TextColumn get rawIngredientsJson => text()(); + + /// JSON-encoded `List` 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? 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? 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> watchRecipes() => + (_selectRecipes()).watch(); + + /// One-shot snapshot of the saved recipe catalog (newest first). + Future> getRecipes() => _selectRecipes().get(); + + /// The recipe row with [id], or `null` when absent. + Future 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 getRecipeBySourceUrl(String sourceUrl) => + (select(recipesTable) + ..where((t) => t.sourceUrl.equals(sourceUrl)) + ..limit(1)) + .getSingleOrNull(); + + /// Inserts a new recipe row. + Future 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 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 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> 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> 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 upsertMealPlanSlot(MealPlanSlotsTableCompanion slot) => + into(mealPlanSlotsTable).insertOnConflictUpdate(slot); + + /// Deletes the meal-plan slot with [id], if present. + Future 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. diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index 096c177..bda9870 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -1416,6 +1416,1141 @@ class BiometricLogsTableCompanion extends UpdateCompanion { } } +class $RecipesTableTable extends RecipesTable + with TableInfo<$RecipesTableTable, RecipeRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RecipesTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 256), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _sourceUrlMeta = + const VerificationMeta('sourceUrl'); + @override + late final GeneratedColumn sourceUrl = GeneratedColumn( + 'source_url', aliasedName, false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, maxTextLength: 1024), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _imageUrlMeta = + const VerificationMeta('imageUrl'); + @override + late final GeneratedColumn imageUrl = GeneratedColumn( + 'image_url', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _descriptionMeta = + const VerificationMeta('description'); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _prepTimeMinutesMeta = + const VerificationMeta('prepTimeMinutes'); + @override + late final GeneratedColumn prepTimeMinutes = GeneratedColumn( + 'prep_time_minutes', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _cookTimeMinutesMeta = + const VerificationMeta('cookTimeMinutes'); + @override + late final GeneratedColumn cookTimeMinutes = GeneratedColumn( + 'cook_time_minutes', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _servingsMeta = + const VerificationMeta('servings'); + @override + late final GeneratedColumn servings = GeneratedColumn( + 'servings', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1)); + static const VerificationMeta _rawIngredientsJsonMeta = + const VerificationMeta('rawIngredientsJson'); + @override + late final GeneratedColumn rawIngredientsJson = + GeneratedColumn('raw_ingredients_json', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _instructionsJsonMeta = + const VerificationMeta('instructionsJson'); + @override + late final GeneratedColumn instructionsJson = GeneratedColumn( + 'instructions_json', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _caloriesMeta = + const VerificationMeta('calories'); + @override + late final GeneratedColumn calories = GeneratedColumn( + 'calories', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0)); + static const VerificationMeta _proteinGramsMeta = + const VerificationMeta('proteinGrams'); + @override + late final GeneratedColumn proteinGrams = GeneratedColumn( + 'protein_grams', aliasedName, false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(0)); + static const VerificationMeta _carbsGramsMeta = + const VerificationMeta('carbsGrams'); + @override + late final GeneratedColumn carbsGrams = GeneratedColumn( + 'carbs_grams', aliasedName, false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(0)); + static const VerificationMeta _fatGramsMeta = + const VerificationMeta('fatGrams'); + @override + late final GeneratedColumn fatGrams = GeneratedColumn( + 'fat_grams', aliasedName, false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(0)); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [ + id, + title, + sourceUrl, + imageUrl, + description, + prepTimeMinutes, + cookTimeMinutes, + servings, + rawIngredientsJson, + instructionsJson, + calories, + proteinGrams, + carbsGrams, + fatGrams, + createdAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'recipes_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta)); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('source_url')) { + context.handle(_sourceUrlMeta, + sourceUrl.isAcceptableOrUnknown(data['source_url']!, _sourceUrlMeta)); + } else if (isInserting) { + context.missing(_sourceUrlMeta); + } + if (data.containsKey('image_url')) { + context.handle(_imageUrlMeta, + imageUrl.isAcceptableOrUnknown(data['image_url']!, _imageUrlMeta)); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, _descriptionMeta)); + } + if (data.containsKey('prep_time_minutes')) { + context.handle( + _prepTimeMinutesMeta, + prepTimeMinutes.isAcceptableOrUnknown( + data['prep_time_minutes']!, _prepTimeMinutesMeta)); + } + if (data.containsKey('cook_time_minutes')) { + context.handle( + _cookTimeMinutesMeta, + cookTimeMinutes.isAcceptableOrUnknown( + data['cook_time_minutes']!, _cookTimeMinutesMeta)); + } + if (data.containsKey('servings')) { + context.handle(_servingsMeta, + servings.isAcceptableOrUnknown(data['servings']!, _servingsMeta)); + } + if (data.containsKey('raw_ingredients_json')) { + context.handle( + _rawIngredientsJsonMeta, + rawIngredientsJson.isAcceptableOrUnknown( + data['raw_ingredients_json']!, _rawIngredientsJsonMeta)); + } else if (isInserting) { + context.missing(_rawIngredientsJsonMeta); + } + if (data.containsKey('instructions_json')) { + context.handle( + _instructionsJsonMeta, + instructionsJson.isAcceptableOrUnknown( + data['instructions_json']!, _instructionsJsonMeta)); + } else if (isInserting) { + context.missing(_instructionsJsonMeta); + } + if (data.containsKey('calories')) { + context.handle(_caloriesMeta, + calories.isAcceptableOrUnknown(data['calories']!, _caloriesMeta)); + } + if (data.containsKey('protein_grams')) { + context.handle( + _proteinGramsMeta, + proteinGrams.isAcceptableOrUnknown( + data['protein_grams']!, _proteinGramsMeta)); + } + if (data.containsKey('carbs_grams')) { + context.handle( + _carbsGramsMeta, + carbsGrams.isAcceptableOrUnknown( + data['carbs_grams']!, _carbsGramsMeta)); + } + if (data.containsKey('fat_grams')) { + context.handle(_fatGramsMeta, + fatGrams.isAcceptableOrUnknown(data['fat_grams']!, _fatGramsMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + RecipeRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RecipeRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + sourceUrl: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}source_url'])!, + imageUrl: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}image_url']), + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description']), + prepTimeMinutes: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}prep_time_minutes']), + cookTimeMinutes: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}cook_time_minutes']), + servings: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}servings'])!, + rawIngredientsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}raw_ingredients_json'])!, + instructionsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}instructions_json'])!, + calories: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}calories'])!, + proteinGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}protein_grams'])!, + carbsGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}carbs_grams'])!, + fatGrams: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}fat_grams'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $RecipesTableTable createAlias(String alias) { + return $RecipesTableTable(attachedDatabase, alias); + } +} + +class RecipeRecord extends DataClass implements Insertable { + /// UUID primary key (see `core/utils/uuid.dart`). + final String id; + final String title; + + /// Canonical URL the recipe was imported from; used to de-duplicate + /// re-imports. + final String sourceUrl; + final String? imageUrl; + + /// Short summary (schema.org `description` / `og:description`). + final String? description; + + /// Preparation time in minutes; `null` when unknown. + final int? prepTimeMinutes; + + /// Cook time in minutes; `null` when unknown. + final int? cookTimeMinutes; + final int servings; + + /// JSON-encoded `List` of ingredient records. + final String rawIngredientsJson; + + /// JSON-encoded `List` of instruction steps. + final String instructionsJson; + final int calories; + final double proteinGrams; + final double carbsGrams; + final double fatGrams; + final DateTime createdAt; + const RecipeRecord( + {required this.id, + required this.title, + required this.sourceUrl, + this.imageUrl, + this.description, + this.prepTimeMinutes, + this.cookTimeMinutes, + required this.servings, + required this.rawIngredientsJson, + required this.instructionsJson, + required this.calories, + required this.proteinGrams, + required this.carbsGrams, + required this.fatGrams, + required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['source_url'] = Variable(sourceUrl); + if (!nullToAbsent || imageUrl != null) { + map['image_url'] = Variable(imageUrl); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || prepTimeMinutes != null) { + map['prep_time_minutes'] = Variable(prepTimeMinutes); + } + if (!nullToAbsent || cookTimeMinutes != null) { + map['cook_time_minutes'] = Variable(cookTimeMinutes); + } + map['servings'] = Variable(servings); + map['raw_ingredients_json'] = Variable(rawIngredientsJson); + map['instructions_json'] = Variable(instructionsJson); + map['calories'] = Variable(calories); + map['protein_grams'] = Variable(proteinGrams); + map['carbs_grams'] = Variable(carbsGrams); + map['fat_grams'] = Variable(fatGrams); + map['created_at'] = Variable(createdAt); + return map; + } + + RecipesTableCompanion toCompanion(bool nullToAbsent) { + return RecipesTableCompanion( + id: Value(id), + title: Value(title), + sourceUrl: Value(sourceUrl), + imageUrl: imageUrl == null && nullToAbsent + ? const Value.absent() + : Value(imageUrl), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + prepTimeMinutes: prepTimeMinutes == null && nullToAbsent + ? const Value.absent() + : Value(prepTimeMinutes), + cookTimeMinutes: cookTimeMinutes == null && nullToAbsent + ? const Value.absent() + : Value(cookTimeMinutes), + servings: Value(servings), + rawIngredientsJson: Value(rawIngredientsJson), + instructionsJson: Value(instructionsJson), + calories: Value(calories), + proteinGrams: Value(proteinGrams), + carbsGrams: Value(carbsGrams), + fatGrams: Value(fatGrams), + createdAt: Value(createdAt), + ); + } + + factory RecipeRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RecipeRecord( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + sourceUrl: serializer.fromJson(json['sourceUrl']), + imageUrl: serializer.fromJson(json['imageUrl']), + description: serializer.fromJson(json['description']), + prepTimeMinutes: serializer.fromJson(json['prepTimeMinutes']), + cookTimeMinutes: serializer.fromJson(json['cookTimeMinutes']), + servings: serializer.fromJson(json['servings']), + rawIngredientsJson: + serializer.fromJson(json['rawIngredientsJson']), + instructionsJson: serializer.fromJson(json['instructionsJson']), + calories: serializer.fromJson(json['calories']), + proteinGrams: serializer.fromJson(json['proteinGrams']), + carbsGrams: serializer.fromJson(json['carbsGrams']), + fatGrams: serializer.fromJson(json['fatGrams']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'sourceUrl': serializer.toJson(sourceUrl), + 'imageUrl': serializer.toJson(imageUrl), + 'description': serializer.toJson(description), + 'prepTimeMinutes': serializer.toJson(prepTimeMinutes), + 'cookTimeMinutes': serializer.toJson(cookTimeMinutes), + 'servings': serializer.toJson(servings), + 'rawIngredientsJson': serializer.toJson(rawIngredientsJson), + 'instructionsJson': serializer.toJson(instructionsJson), + 'calories': serializer.toJson(calories), + 'proteinGrams': serializer.toJson(proteinGrams), + 'carbsGrams': serializer.toJson(carbsGrams), + 'fatGrams': serializer.toJson(fatGrams), + 'createdAt': serializer.toJson(createdAt), + }; + } + + RecipeRecord copyWith( + {String? id, + String? title, + String? sourceUrl, + Value imageUrl = const Value.absent(), + Value description = const Value.absent(), + Value prepTimeMinutes = const Value.absent(), + Value cookTimeMinutes = const Value.absent(), + int? servings, + String? rawIngredientsJson, + String? instructionsJson, + int? calories, + double? proteinGrams, + double? carbsGrams, + double? fatGrams, + DateTime? createdAt}) => + RecipeRecord( + id: id ?? this.id, + title: title ?? this.title, + sourceUrl: sourceUrl ?? this.sourceUrl, + imageUrl: imageUrl.present ? imageUrl.value : this.imageUrl, + description: description.present ? description.value : this.description, + prepTimeMinutes: prepTimeMinutes.present + ? prepTimeMinutes.value + : this.prepTimeMinutes, + cookTimeMinutes: cookTimeMinutes.present + ? cookTimeMinutes.value + : this.cookTimeMinutes, + servings: servings ?? this.servings, + rawIngredientsJson: rawIngredientsJson ?? this.rawIngredientsJson, + instructionsJson: instructionsJson ?? this.instructionsJson, + calories: calories ?? this.calories, + proteinGrams: proteinGrams ?? this.proteinGrams, + carbsGrams: carbsGrams ?? this.carbsGrams, + fatGrams: fatGrams ?? this.fatGrams, + createdAt: createdAt ?? this.createdAt, + ); + RecipeRecord copyWithCompanion(RecipesTableCompanion data) { + return RecipeRecord( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + sourceUrl: data.sourceUrl.present ? data.sourceUrl.value : this.sourceUrl, + imageUrl: data.imageUrl.present ? data.imageUrl.value : this.imageUrl, + description: + data.description.present ? data.description.value : this.description, + prepTimeMinutes: data.prepTimeMinutes.present + ? data.prepTimeMinutes.value + : this.prepTimeMinutes, + cookTimeMinutes: data.cookTimeMinutes.present + ? data.cookTimeMinutes.value + : this.cookTimeMinutes, + servings: data.servings.present ? data.servings.value : this.servings, + rawIngredientsJson: data.rawIngredientsJson.present + ? data.rawIngredientsJson.value + : this.rawIngredientsJson, + instructionsJson: data.instructionsJson.present + ? data.instructionsJson.value + : this.instructionsJson, + calories: data.calories.present ? data.calories.value : this.calories, + proteinGrams: data.proteinGrams.present + ? data.proteinGrams.value + : this.proteinGrams, + carbsGrams: + data.carbsGrams.present ? data.carbsGrams.value : this.carbsGrams, + fatGrams: data.fatGrams.present ? data.fatGrams.value : this.fatGrams, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('RecipeRecord(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('imageUrl: $imageUrl, ') + ..write('description: $description, ') + ..write('prepTimeMinutes: $prepTimeMinutes, ') + ..write('cookTimeMinutes: $cookTimeMinutes, ') + ..write('servings: $servings, ') + ..write('rawIngredientsJson: $rawIngredientsJson, ') + ..write('instructionsJson: $instructionsJson, ') + ..write('calories: $calories, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + sourceUrl, + imageUrl, + description, + prepTimeMinutes, + cookTimeMinutes, + servings, + rawIngredientsJson, + instructionsJson, + calories, + proteinGrams, + carbsGrams, + fatGrams, + createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RecipeRecord && + other.id == this.id && + other.title == this.title && + other.sourceUrl == this.sourceUrl && + other.imageUrl == this.imageUrl && + other.description == this.description && + other.prepTimeMinutes == this.prepTimeMinutes && + other.cookTimeMinutes == this.cookTimeMinutes && + other.servings == this.servings && + other.rawIngredientsJson == this.rawIngredientsJson && + other.instructionsJson == this.instructionsJson && + other.calories == this.calories && + other.proteinGrams == this.proteinGrams && + other.carbsGrams == this.carbsGrams && + other.fatGrams == this.fatGrams && + other.createdAt == this.createdAt); +} + +class RecipesTableCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value sourceUrl; + final Value imageUrl; + final Value description; + final Value prepTimeMinutes; + final Value cookTimeMinutes; + final Value servings; + final Value rawIngredientsJson; + final Value instructionsJson; + final Value calories; + final Value proteinGrams; + final Value carbsGrams; + final Value fatGrams; + final Value createdAt; + final Value rowid; + const RecipesTableCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.sourceUrl = const Value.absent(), + this.imageUrl = const Value.absent(), + this.description = const Value.absent(), + this.prepTimeMinutes = const Value.absent(), + this.cookTimeMinutes = const Value.absent(), + this.servings = const Value.absent(), + this.rawIngredientsJson = const Value.absent(), + this.instructionsJson = const Value.absent(), + this.calories = const Value.absent(), + this.proteinGrams = const Value.absent(), + this.carbsGrams = const Value.absent(), + this.fatGrams = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + RecipesTableCompanion.insert({ + required String id, + required String title, + required String sourceUrl, + this.imageUrl = const Value.absent(), + this.description = const Value.absent(), + this.prepTimeMinutes = const Value.absent(), + this.cookTimeMinutes = const Value.absent(), + this.servings = const Value.absent(), + required String rawIngredientsJson, + required String instructionsJson, + this.calories = const Value.absent(), + this.proteinGrams = const Value.absent(), + this.carbsGrams = const Value.absent(), + this.fatGrams = const Value.absent(), + required DateTime createdAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + title = Value(title), + sourceUrl = Value(sourceUrl), + rawIngredientsJson = Value(rawIngredientsJson), + instructionsJson = Value(instructionsJson), + createdAt = Value(createdAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? sourceUrl, + Expression? imageUrl, + Expression? description, + Expression? prepTimeMinutes, + Expression? cookTimeMinutes, + Expression? servings, + Expression? rawIngredientsJson, + Expression? instructionsJson, + Expression? calories, + Expression? proteinGrams, + Expression? carbsGrams, + Expression? fatGrams, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (sourceUrl != null) 'source_url': sourceUrl, + if (imageUrl != null) 'image_url': imageUrl, + if (description != null) 'description': description, + if (prepTimeMinutes != null) 'prep_time_minutes': prepTimeMinutes, + if (cookTimeMinutes != null) 'cook_time_minutes': cookTimeMinutes, + if (servings != null) 'servings': servings, + if (rawIngredientsJson != null) + 'raw_ingredients_json': rawIngredientsJson, + if (instructionsJson != null) 'instructions_json': instructionsJson, + if (calories != null) 'calories': calories, + if (proteinGrams != null) 'protein_grams': proteinGrams, + if (carbsGrams != null) 'carbs_grams': carbsGrams, + if (fatGrams != null) 'fat_grams': fatGrams, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + RecipesTableCompanion copyWith( + {Value? id, + Value? title, + Value? sourceUrl, + Value? imageUrl, + Value? description, + Value? prepTimeMinutes, + Value? cookTimeMinutes, + Value? servings, + Value? rawIngredientsJson, + Value? instructionsJson, + Value? calories, + Value? proteinGrams, + Value? carbsGrams, + Value? fatGrams, + Value? createdAt, + Value? rowid}) { + return RecipesTableCompanion( + 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, + rawIngredientsJson: rawIngredientsJson ?? this.rawIngredientsJson, + instructionsJson: instructionsJson ?? this.instructionsJson, + calories: calories ?? this.calories, + proteinGrams: proteinGrams ?? this.proteinGrams, + carbsGrams: carbsGrams ?? this.carbsGrams, + fatGrams: fatGrams ?? this.fatGrams, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (sourceUrl.present) { + map['source_url'] = Variable(sourceUrl.value); + } + if (imageUrl.present) { + map['image_url'] = Variable(imageUrl.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (prepTimeMinutes.present) { + map['prep_time_minutes'] = Variable(prepTimeMinutes.value); + } + if (cookTimeMinutes.present) { + map['cook_time_minutes'] = Variable(cookTimeMinutes.value); + } + if (servings.present) { + map['servings'] = Variable(servings.value); + } + if (rawIngredientsJson.present) { + map['raw_ingredients_json'] = Variable(rawIngredientsJson.value); + } + if (instructionsJson.present) { + map['instructions_json'] = Variable(instructionsJson.value); + } + if (calories.present) { + map['calories'] = Variable(calories.value); + } + if (proteinGrams.present) { + map['protein_grams'] = Variable(proteinGrams.value); + } + if (carbsGrams.present) { + map['carbs_grams'] = Variable(carbsGrams.value); + } + if (fatGrams.present) { + map['fat_grams'] = Variable(fatGrams.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RecipesTableCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('sourceUrl: $sourceUrl, ') + ..write('imageUrl: $imageUrl, ') + ..write('description: $description, ') + ..write('prepTimeMinutes: $prepTimeMinutes, ') + ..write('cookTimeMinutes: $cookTimeMinutes, ') + ..write('servings: $servings, ') + ..write('rawIngredientsJson: $rawIngredientsJson, ') + ..write('instructionsJson: $instructionsJson, ') + ..write('calories: $calories, ') + ..write('proteinGrams: $proteinGrams, ') + ..write('carbsGrams: $carbsGrams, ') + ..write('fatGrams: $fatGrams, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $MealPlanSlotsTableTable extends MealPlanSlotsTable + with TableInfo<$MealPlanSlotsTableTable, MealPlanSlotRecord> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MealPlanSlotsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _plannedDateMeta = + const VerificationMeta('plannedDate'); + @override + late final GeneratedColumn plannedDate = GeneratedColumn( + 'planned_date', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + static const VerificationMeta _mealTypeMeta = + const VerificationMeta('mealType'); + @override + late final GeneratedColumn mealType = GeneratedColumn( + 'meal_type', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 1, maxTextLength: 32), + type: DriftSqlType.string, + requiredDuringInsert: true); + static const VerificationMeta _recipeIdMeta = + const VerificationMeta('recipeId'); + @override + late final GeneratedColumn recipeId = GeneratedColumn( + 'recipe_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES recipes_table (id) ON DELETE SET NULL')); + static const VerificationMeta _customNotesMeta = + const VerificationMeta('customNotes'); + @override + late final GeneratedColumn customNotes = GeneratedColumn( + 'custom_notes', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _isCompletedMeta = + const VerificationMeta('isCompleted'); + @override + late final GeneratedColumn isCompleted = GeneratedColumn( + 'is_completed', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_completed" IN (0, 1))'), + defaultValue: const Constant(false)); + @override + List get $columns => + [id, plannedDate, mealType, recipeId, customNotes, isCompleted]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'meal_plan_slots_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('planned_date')) { + context.handle( + _plannedDateMeta, + plannedDate.isAcceptableOrUnknown( + data['planned_date']!, _plannedDateMeta)); + } else if (isInserting) { + context.missing(_plannedDateMeta); + } + if (data.containsKey('meal_type')) { + context.handle(_mealTypeMeta, + mealType.isAcceptableOrUnknown(data['meal_type']!, _mealTypeMeta)); + } else if (isInserting) { + context.missing(_mealTypeMeta); + } + if (data.containsKey('recipe_id')) { + context.handle(_recipeIdMeta, + recipeId.isAcceptableOrUnknown(data['recipe_id']!, _recipeIdMeta)); + } + if (data.containsKey('custom_notes')) { + context.handle( + _customNotesMeta, + customNotes.isAcceptableOrUnknown( + data['custom_notes']!, _customNotesMeta)); + } + if (data.containsKey('is_completed')) { + context.handle( + _isCompletedMeta, + isCompleted.isAcceptableOrUnknown( + data['is_completed']!, _isCompletedMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + MealPlanSlotRecord map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MealPlanSlotRecord( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + plannedDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}planned_date'])!, + mealType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}meal_type'])!, + recipeId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}recipe_id']), + customNotes: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}custom_notes']), + isCompleted: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_completed'])!, + ); + } + + @override + $MealPlanSlotsTableTable createAlias(String alias) { + return $MealPlanSlotsTableTable(attachedDatabase, alias); + } +} + +class MealPlanSlotRecord extends DataClass + implements Insertable { + /// UUID primary key. + final String id; + + /// Calendar day the slot belongs to (local start-of-day). + final DateTime plannedDate; + + /// Meal slot identifier (e.g. `breakfast`) — the `MealType.name` key of + /// the nutrition domain enum. + final String mealType; + + /// Assigned recipe; falls back to `null` when the recipe is deleted. + final String? recipeId; + + /// Free-text meal description for custom entries / notes. + final String? customNotes; + final bool isCompleted; + const MealPlanSlotRecord( + {required this.id, + required this.plannedDate, + required this.mealType, + this.recipeId, + this.customNotes, + required this.isCompleted}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['planned_date'] = Variable(plannedDate); + map['meal_type'] = Variable(mealType); + if (!nullToAbsent || recipeId != null) { + map['recipe_id'] = Variable(recipeId); + } + if (!nullToAbsent || customNotes != null) { + map['custom_notes'] = Variable(customNotes); + } + map['is_completed'] = Variable(isCompleted); + return map; + } + + MealPlanSlotsTableCompanion toCompanion(bool nullToAbsent) { + return MealPlanSlotsTableCompanion( + id: Value(id), + plannedDate: Value(plannedDate), + mealType: Value(mealType), + recipeId: recipeId == null && nullToAbsent + ? const Value.absent() + : Value(recipeId), + customNotes: customNotes == null && nullToAbsent + ? const Value.absent() + : Value(customNotes), + isCompleted: Value(isCompleted), + ); + } + + factory MealPlanSlotRecord.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MealPlanSlotRecord( + id: serializer.fromJson(json['id']), + plannedDate: serializer.fromJson(json['plannedDate']), + mealType: serializer.fromJson(json['mealType']), + recipeId: serializer.fromJson(json['recipeId']), + customNotes: serializer.fromJson(json['customNotes']), + isCompleted: serializer.fromJson(json['isCompleted']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'plannedDate': serializer.toJson(plannedDate), + 'mealType': serializer.toJson(mealType), + 'recipeId': serializer.toJson(recipeId), + 'customNotes': serializer.toJson(customNotes), + 'isCompleted': serializer.toJson(isCompleted), + }; + } + + MealPlanSlotRecord copyWith( + {String? id, + DateTime? plannedDate, + String? mealType, + Value recipeId = const Value.absent(), + Value customNotes = const Value.absent(), + bool? isCompleted}) => + MealPlanSlotRecord( + id: id ?? this.id, + plannedDate: plannedDate ?? this.plannedDate, + mealType: mealType ?? this.mealType, + recipeId: recipeId.present ? recipeId.value : this.recipeId, + customNotes: customNotes.present ? customNotes.value : this.customNotes, + isCompleted: isCompleted ?? this.isCompleted, + ); + MealPlanSlotRecord copyWithCompanion(MealPlanSlotsTableCompanion data) { + return MealPlanSlotRecord( + id: data.id.present ? data.id.value : this.id, + plannedDate: + data.plannedDate.present ? data.plannedDate.value : this.plannedDate, + mealType: data.mealType.present ? data.mealType.value : this.mealType, + recipeId: data.recipeId.present ? data.recipeId.value : this.recipeId, + customNotes: + data.customNotes.present ? data.customNotes.value : this.customNotes, + isCompleted: + data.isCompleted.present ? data.isCompleted.value : this.isCompleted, + ); + } + + @override + String toString() { + return (StringBuffer('MealPlanSlotRecord(') + ..write('id: $id, ') + ..write('plannedDate: $plannedDate, ') + ..write('mealType: $mealType, ') + ..write('recipeId: $recipeId, ') + ..write('customNotes: $customNotes, ') + ..write('isCompleted: $isCompleted') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, plannedDate, mealType, recipeId, customNotes, isCompleted); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MealPlanSlotRecord && + other.id == this.id && + other.plannedDate == this.plannedDate && + other.mealType == this.mealType && + other.recipeId == this.recipeId && + other.customNotes == this.customNotes && + other.isCompleted == this.isCompleted); +} + +class MealPlanSlotsTableCompanion extends UpdateCompanion { + final Value id; + final Value plannedDate; + final Value mealType; + final Value recipeId; + final Value customNotes; + final Value isCompleted; + final Value rowid; + const MealPlanSlotsTableCompanion({ + this.id = const Value.absent(), + this.plannedDate = const Value.absent(), + this.mealType = const Value.absent(), + this.recipeId = const Value.absent(), + this.customNotes = const Value.absent(), + this.isCompleted = const Value.absent(), + this.rowid = const Value.absent(), + }); + MealPlanSlotsTableCompanion.insert({ + required String id, + required DateTime plannedDate, + required String mealType, + this.recipeId = const Value.absent(), + this.customNotes = const Value.absent(), + this.isCompleted = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + plannedDate = Value(plannedDate), + mealType = Value(mealType); + static Insertable custom({ + Expression? id, + Expression? plannedDate, + Expression? mealType, + Expression? recipeId, + Expression? customNotes, + Expression? isCompleted, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (plannedDate != null) 'planned_date': plannedDate, + if (mealType != null) 'meal_type': mealType, + if (recipeId != null) 'recipe_id': recipeId, + if (customNotes != null) 'custom_notes': customNotes, + if (isCompleted != null) 'is_completed': isCompleted, + if (rowid != null) 'rowid': rowid, + }); + } + + MealPlanSlotsTableCompanion copyWith( + {Value? id, + Value? plannedDate, + Value? mealType, + Value? recipeId, + Value? customNotes, + Value? isCompleted, + Value? rowid}) { + return MealPlanSlotsTableCompanion( + id: id ?? this.id, + plannedDate: plannedDate ?? this.plannedDate, + mealType: mealType ?? this.mealType, + recipeId: recipeId ?? this.recipeId, + customNotes: customNotes ?? this.customNotes, + isCompleted: isCompleted ?? this.isCompleted, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (plannedDate.present) { + map['planned_date'] = Variable(plannedDate.value); + } + if (mealType.present) { + map['meal_type'] = Variable(mealType.value); + } + if (recipeId.present) { + map['recipe_id'] = Variable(recipeId.value); + } + if (customNotes.present) { + map['custom_notes'] = Variable(customNotes.value); + } + if (isCompleted.present) { + map['is_completed'] = Variable(isCompleted.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MealPlanSlotsTableCompanion(') + ..write('id: $id, ') + ..write('plannedDate: $plannedDate, ') + ..write('mealType: $mealType, ') + ..write('recipeId: $recipeId, ') + ..write('customNotes: $customNotes, ') + ..write('isCompleted: $isCompleted, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -1426,6 +2561,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { $HydrationLogsTableTable(this); late final $BiometricLogsTableTable biometricLogsTable = $BiometricLogsTableTable(this); + late final $RecipesTableTable recipesTable = $RecipesTableTable(this); + late final $MealPlanSlotsTableTable mealPlanSlotsTable = + $MealPlanSlotsTableTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -1434,8 +2572,22 @@ abstract class _$AppDatabase extends GeneratedDatabase { medicationDosesTable, foodLogsTable, hydrationLogsTable, - biometricLogsTable + biometricLogsTable, + recipesTable, + mealPlanSlotsTable ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( + [ + WritePropagation( + on: TableUpdateQuery.onTableName('recipes_table', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('meal_plan_slots_table', kind: UpdateKind.update), + ], + ), + ], + ); } typedef $$MedicationDosesTableTableCreateCompanionBuilder @@ -2192,6 +3344,718 @@ typedef $$BiometricLogsTableTableProcessedTableManager = ProcessedTableManager< ), BiometricLogRecord, PrefetchHooks Function()>; +typedef $$RecipesTableTableCreateCompanionBuilder = RecipesTableCompanion + Function({ + required String id, + required String title, + required String sourceUrl, + Value imageUrl, + Value description, + Value prepTimeMinutes, + Value cookTimeMinutes, + Value servings, + required String rawIngredientsJson, + required String instructionsJson, + Value calories, + Value proteinGrams, + Value carbsGrams, + Value fatGrams, + required DateTime createdAt, + Value rowid, +}); +typedef $$RecipesTableTableUpdateCompanionBuilder = RecipesTableCompanion + Function({ + Value id, + Value title, + Value sourceUrl, + Value imageUrl, + Value description, + Value prepTimeMinutes, + Value cookTimeMinutes, + Value servings, + Value rawIngredientsJson, + Value instructionsJson, + Value calories, + Value proteinGrams, + Value carbsGrams, + Value fatGrams, + Value createdAt, + Value rowid, +}); + +final class $$RecipesTableTableReferences + extends BaseReferences<_$AppDatabase, $RecipesTableTable, RecipeRecord> { + $$RecipesTableTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$MealPlanSlotsTableTable, List> + _mealPlanSlotsTableRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable(db.mealPlanSlotsTable, + aliasName: 'recipes_table__id__meal_plan_slots_table__recipe_id'); + + $$MealPlanSlotsTableTableProcessedTableManager get mealPlanSlotsTableRefs { + final manager = $$MealPlanSlotsTableTableTableManager( + $_db, $_db.mealPlanSlotsTable) + .filter((f) => f.recipeId.id.sqlEquals($_itemColumn('id')!)); + + final cache = + $_typedResult.readTableOrNull(_mealPlanSlotsTableRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$RecipesTableTableFilterComposer + extends Composer<_$AppDatabase, $RecipesTableTable> { + $$RecipesTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnFilters(column)); + + ColumnFilters get sourceUrl => $composableBuilder( + column: $table.sourceUrl, builder: (column) => ColumnFilters(column)); + + ColumnFilters get imageUrl => $composableBuilder( + column: $table.imageUrl, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); + + ColumnFilters get prepTimeMinutes => $composableBuilder( + column: $table.prepTimeMinutes, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get cookTimeMinutes => $composableBuilder( + column: $table.cookTimeMinutes, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get servings => $composableBuilder( + column: $table.servings, builder: (column) => ColumnFilters(column)); + + ColumnFilters get rawIngredientsJson => $composableBuilder( + column: $table.rawIngredientsJson, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get instructionsJson => $composableBuilder( + column: $table.instructionsJson, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnFilters(column)); + + ColumnFilters get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + Expression mealPlanSlotsTableRefs( + Expression Function($$MealPlanSlotsTableTableFilterComposer f) f) { + final $$MealPlanSlotsTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.mealPlanSlotsTable, + getReferencedColumn: (t) => t.recipeId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$MealPlanSlotsTableTableFilterComposer( + $db: $db, + $table: $db.mealPlanSlotsTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$RecipesTableTableOrderingComposer + extends Composer<_$AppDatabase, $RecipesTableTable> { + $$RecipesTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sourceUrl => $composableBuilder( + column: $table.sourceUrl, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get imageUrl => $composableBuilder( + column: $table.imageUrl, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get prepTimeMinutes => $composableBuilder( + column: $table.prepTimeMinutes, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get cookTimeMinutes => $composableBuilder( + column: $table.cookTimeMinutes, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get servings => $composableBuilder( + column: $table.servings, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get rawIngredientsJson => $composableBuilder( + column: $table.rawIngredientsJson, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get instructionsJson => $composableBuilder( + column: $table.instructionsJson, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get calories => $composableBuilder( + column: $table.calories, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get proteinGrams => $composableBuilder( + column: $table.proteinGrams, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get fatGrams => $composableBuilder( + column: $table.fatGrams, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); +} + +class $$RecipesTableTableAnnotationComposer + extends Composer<_$AppDatabase, $RecipesTableTable> { + $$RecipesTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get sourceUrl => + $composableBuilder(column: $table.sourceUrl, builder: (column) => column); + + GeneratedColumn get imageUrl => + $composableBuilder(column: $table.imageUrl, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); + + GeneratedColumn get prepTimeMinutes => $composableBuilder( + column: $table.prepTimeMinutes, builder: (column) => column); + + GeneratedColumn get cookTimeMinutes => $composableBuilder( + column: $table.cookTimeMinutes, builder: (column) => column); + + GeneratedColumn get servings => + $composableBuilder(column: $table.servings, builder: (column) => column); + + GeneratedColumn get rawIngredientsJson => $composableBuilder( + column: $table.rawIngredientsJson, builder: (column) => column); + + GeneratedColumn get instructionsJson => $composableBuilder( + column: $table.instructionsJson, builder: (column) => column); + + GeneratedColumn get calories => + $composableBuilder(column: $table.calories, builder: (column) => column); + + GeneratedColumn get proteinGrams => $composableBuilder( + column: $table.proteinGrams, builder: (column) => column); + + GeneratedColumn get carbsGrams => $composableBuilder( + column: $table.carbsGrams, builder: (column) => column); + + GeneratedColumn get fatGrams => + $composableBuilder(column: $table.fatGrams, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + Expression mealPlanSlotsTableRefs( + Expression Function($$MealPlanSlotsTableTableAnnotationComposer a) f) { + final $$MealPlanSlotsTableTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.mealPlanSlotsTable, + getReferencedColumn: (t) => t.recipeId, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$MealPlanSlotsTableTableAnnotationComposer( + $db: $db, + $table: $db.mealPlanSlotsTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return f(composer); + } +} + +class $$RecipesTableTableTableManager extends RootTableManager< + _$AppDatabase, + $RecipesTableTable, + RecipeRecord, + $$RecipesTableTableFilterComposer, + $$RecipesTableTableOrderingComposer, + $$RecipesTableTableAnnotationComposer, + $$RecipesTableTableCreateCompanionBuilder, + $$RecipesTableTableUpdateCompanionBuilder, + (RecipeRecord, $$RecipesTableTableReferences), + RecipeRecord, + PrefetchHooks Function({bool mealPlanSlotsTableRefs})> { + $$RecipesTableTableTableManager(_$AppDatabase db, $RecipesTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RecipesTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$RecipesTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$RecipesTableTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value sourceUrl = const Value.absent(), + Value imageUrl = const Value.absent(), + Value description = const Value.absent(), + Value prepTimeMinutes = const Value.absent(), + Value cookTimeMinutes = const Value.absent(), + Value servings = const Value.absent(), + Value rawIngredientsJson = const Value.absent(), + Value instructionsJson = const Value.absent(), + Value calories = const Value.absent(), + Value proteinGrams = const Value.absent(), + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + RecipesTableCompanion( + id: id, + title: title, + sourceUrl: sourceUrl, + imageUrl: imageUrl, + description: description, + prepTimeMinutes: prepTimeMinutes, + cookTimeMinutes: cookTimeMinutes, + servings: servings, + rawIngredientsJson: rawIngredientsJson, + instructionsJson: instructionsJson, + calories: calories, + proteinGrams: proteinGrams, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required String title, + required String sourceUrl, + Value imageUrl = const Value.absent(), + Value description = const Value.absent(), + Value prepTimeMinutes = const Value.absent(), + Value cookTimeMinutes = const Value.absent(), + Value servings = const Value.absent(), + required String rawIngredientsJson, + required String instructionsJson, + Value calories = const Value.absent(), + Value proteinGrams = const Value.absent(), + Value carbsGrams = const Value.absent(), + Value fatGrams = const Value.absent(), + required DateTime createdAt, + Value rowid = const Value.absent(), + }) => + RecipesTableCompanion.insert( + id: id, + title: title, + sourceUrl: sourceUrl, + imageUrl: imageUrl, + description: description, + prepTimeMinutes: prepTimeMinutes, + cookTimeMinutes: cookTimeMinutes, + servings: servings, + rawIngredientsJson: rawIngredientsJson, + instructionsJson: instructionsJson, + calories: calories, + proteinGrams: proteinGrams, + carbsGrams: carbsGrams, + fatGrams: fatGrams, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$RecipesTableTable, RecipeRecord>(table), + $$RecipesTableTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({mealPlanSlotsTableRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (mealPlanSlotsTableRefs) db.mealPlanSlotsTable + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (mealPlanSlotsTableRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$RecipesTableTableReferences + ._mealPlanSlotsTableRefsTable(db), + managerFromTypedResult: (p0) => + $$RecipesTableTableReferences(db, table, p0) + .mealPlanSlotsTableRefs, + referencedItemsForCurrentItem: (item, + referencedItems) => + referencedItems.where((e) => e.recipeId == item.id), + typedResults: items) + ]; + }, + ); + }, + )); +} + +typedef $$RecipesTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $RecipesTableTable, + RecipeRecord, + $$RecipesTableTableFilterComposer, + $$RecipesTableTableOrderingComposer, + $$RecipesTableTableAnnotationComposer, + $$RecipesTableTableCreateCompanionBuilder, + $$RecipesTableTableUpdateCompanionBuilder, + (RecipeRecord, $$RecipesTableTableReferences), + RecipeRecord, + PrefetchHooks Function({bool mealPlanSlotsTableRefs})>; +typedef $$MealPlanSlotsTableTableCreateCompanionBuilder + = MealPlanSlotsTableCompanion Function({ + required String id, + required DateTime plannedDate, + required String mealType, + Value recipeId, + Value customNotes, + Value isCompleted, + Value rowid, +}); +typedef $$MealPlanSlotsTableTableUpdateCompanionBuilder + = MealPlanSlotsTableCompanion Function({ + Value id, + Value plannedDate, + Value mealType, + Value recipeId, + Value customNotes, + Value isCompleted, + Value rowid, +}); + +final class $$MealPlanSlotsTableTableReferences extends BaseReferences< + _$AppDatabase, $MealPlanSlotsTableTable, MealPlanSlotRecord> { + $$MealPlanSlotsTableTableReferences( + super.$_db, super.$_table, super.$_typedResult); + + static $RecipesTableTable _recipeIdTable(_$AppDatabase db) => db.recipesTable + .createAlias('meal_plan_slots_table__recipe_id__recipes_table__id'); + + $$RecipesTableTableProcessedTableManager? get recipeId { + final $_column = $_itemColumn('recipe_id'); + if ($_column == null) return null; + final manager = $$RecipesTableTableTableManager($_db, $_db.recipesTable) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_recipeIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$MealPlanSlotsTableTableFilterComposer + extends Composer<_$AppDatabase, $MealPlanSlotsTableTable> { + $$MealPlanSlotsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get plannedDate => $composableBuilder( + column: $table.plannedDate, builder: (column) => ColumnFilters(column)); + + ColumnFilters get mealType => $composableBuilder( + column: $table.mealType, builder: (column) => ColumnFilters(column)); + + ColumnFilters get customNotes => $composableBuilder( + column: $table.customNotes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get isCompleted => $composableBuilder( + column: $table.isCompleted, builder: (column) => ColumnFilters(column)); + + $$RecipesTableTableFilterComposer get recipeId { + final $$RecipesTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipesTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$RecipesTableTableFilterComposer( + $db: $db, + $table: $db.recipesTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MealPlanSlotsTableTableOrderingComposer + extends Composer<_$AppDatabase, $MealPlanSlotsTableTable> { + $$MealPlanSlotsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get plannedDate => $composableBuilder( + column: $table.plannedDate, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get mealType => $composableBuilder( + column: $table.mealType, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get customNotes => $composableBuilder( + column: $table.customNotes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get isCompleted => $composableBuilder( + column: $table.isCompleted, builder: (column) => ColumnOrderings(column)); + + $$RecipesTableTableOrderingComposer get recipeId { + final $$RecipesTableTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipesTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$RecipesTableTableOrderingComposer( + $db: $db, + $table: $db.recipesTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MealPlanSlotsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $MealPlanSlotsTableTable> { + $$MealPlanSlotsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get plannedDate => $composableBuilder( + column: $table.plannedDate, builder: (column) => column); + + GeneratedColumn get mealType => + $composableBuilder(column: $table.mealType, builder: (column) => column); + + GeneratedColumn get customNotes => $composableBuilder( + column: $table.customNotes, builder: (column) => column); + + GeneratedColumn get isCompleted => $composableBuilder( + column: $table.isCompleted, builder: (column) => column); + + $$RecipesTableTableAnnotationComposer get recipeId { + final $$RecipesTableTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipesTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + $$RecipesTableTableAnnotationComposer( + $db: $db, + $table: $db.recipesTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$MealPlanSlotsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $MealPlanSlotsTableTable, + MealPlanSlotRecord, + $$MealPlanSlotsTableTableFilterComposer, + $$MealPlanSlotsTableTableOrderingComposer, + $$MealPlanSlotsTableTableAnnotationComposer, + $$MealPlanSlotsTableTableCreateCompanionBuilder, + $$MealPlanSlotsTableTableUpdateCompanionBuilder, + (MealPlanSlotRecord, $$MealPlanSlotsTableTableReferences), + MealPlanSlotRecord, + PrefetchHooks Function({bool recipeId})> { + $$MealPlanSlotsTableTableTableManager( + _$AppDatabase db, $MealPlanSlotsTableTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MealPlanSlotsTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MealPlanSlotsTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MealPlanSlotsTableTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value plannedDate = const Value.absent(), + Value mealType = const Value.absent(), + Value recipeId = const Value.absent(), + Value customNotes = const Value.absent(), + Value isCompleted = const Value.absent(), + Value rowid = const Value.absent(), + }) => + MealPlanSlotsTableCompanion( + id: id, + plannedDate: plannedDate, + mealType: mealType, + recipeId: recipeId, + customNotes: customNotes, + isCompleted: isCompleted, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + required DateTime plannedDate, + required String mealType, + Value recipeId = const Value.absent(), + Value customNotes = const Value.absent(), + Value isCompleted = const Value.absent(), + Value rowid = const Value.absent(), + }) => + MealPlanSlotsTableCompanion.insert( + id: id, + plannedDate: plannedDate, + mealType: mealType, + recipeId: recipeId, + customNotes: customNotes, + isCompleted: isCompleted, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable<$MealPlanSlotsTableTable, MealPlanSlotRecord>( + table), + $$MealPlanSlotsTableTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({recipeId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (recipeId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.recipeId, + referencedTable: + $$MealPlanSlotsTableTableReferences._recipeIdTable(db), + referencedColumn: $$MealPlanSlotsTableTableReferences + ._recipeIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$MealPlanSlotsTableTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $MealPlanSlotsTableTable, + MealPlanSlotRecord, + $$MealPlanSlotsTableTableFilterComposer, + $$MealPlanSlotsTableTableOrderingComposer, + $$MealPlanSlotsTableTableAnnotationComposer, + $$MealPlanSlotsTableTableCreateCompanionBuilder, + $$MealPlanSlotsTableTableUpdateCompanionBuilder, + (MealPlanSlotRecord, $$MealPlanSlotsTableTableReferences), + MealPlanSlotRecord, + PrefetchHooks Function({bool recipeId})>; class $AppDatabaseManager { final _$AppDatabase _db; @@ -2204,4 +4068,8 @@ class $AppDatabaseManager { $$HydrationLogsTableTableTableManager(_db, _db.hydrationLogsTable); $$BiometricLogsTableTableTableManager get biometricLogsTable => $$BiometricLogsTableTableTableManager(_db, _db.biometricLogsTable); + $$RecipesTableTableTableManager get recipesTable => + $$RecipesTableTableTableManager(_db, _db.recipesTable); + $$MealPlanSlotsTableTableTableManager get mealPlanSlotsTable => + $$MealPlanSlotsTableTableTableManager(_db, _db.mealPlanSlotsTable); } diff --git a/lib/core/utils/date_time_utils.dart b/lib/core/utils/date_time_utils.dart index 6cce9dc..f8be44f 100644 --- a/lib/core/utils/date_time_utils.dart +++ b/lib/core/utils/date_time_utils.dart @@ -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}) { diff --git a/lib/core/utils/uuid.dart b/lib/core/utils/uuid.dart new file mode 100644 index 0000000..361a47b --- /dev/null +++ b/lib/core/utils/uuid.dart @@ -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.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)}'; +} diff --git a/lib/features/meal_planner/data/repositories/drift_meal_plan_repository.dart b/lib/features/meal_planner/data/repositories/drift_meal_plan_repository.dart new file mode 100644 index 0000000..d6c04dd --- /dev/null +++ b/lib/features/meal_planner/data/repositories/drift_meal_plan_repository.dart @@ -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> watchSlots({ + required DateTime from, + required DateTime to, + }) => + _database + .watchMealPlanSlots(from: from, to: to) + .map((records) => records.map(_mapSlot).toList(growable: false)); + + @override + Future> getSlots({ + required DateTime from, + required DateTime to, + }) async => + (await _database.getMealPlanSlots(from: from, to: to)) + .map(_mapSlot) + .toList(growable: false); + + @override + Future saveSlot(MealPlanSlot slot) async { + await _database.upsertMealPlanSlot(_toCompanion(slot)); + return slot; + } + + @override + Future 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), + ); +} diff --git a/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart b/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart new file mode 100644 index 0000000..330c535 --- /dev/null +++ b/lib/features/meal_planner/data/repositories/drift_recipe_repository.dart @@ -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> watchRecipes() => _database + .watchRecipes() + .map((records) => records.map(_mapRecipe).toList(growable: false)); + + @override + Future> getRecipes() async => + (await _database.getRecipes()).map(_mapRecipe).toList(growable: false); + + @override + Future getRecipe(String id) async { + final record = await _database.getRecipeById(id); + return record == null ? null : _mapRecipe(record); + } + + @override + Future 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 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 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 _decodeIngredients(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded + .whereType() + .map( + (map) => + RecipeIngredient.fromMap(Map.from(map)), + ) + .toList(growable: false); + } on FormatException { + return const []; + } + } + + List _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 []; + } + } +} diff --git a/lib/features/meal_planner/domain/entities/macro_profile.dart b/lib/features/meal_planner/domain/entities/macro_profile.dart new file mode 100644 index 0000000..92e5ca0 --- /dev/null +++ b/lib/features/meal_planner/domain/entities/macro_profile.dart @@ -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)'; +} diff --git a/lib/features/meal_planner/domain/entities/meal_plan_slot.dart b/lib/features/meal_planner/domain/entities/meal_plan_slot.dart new file mode 100644 index 0000000..b78b690 --- /dev/null +++ b/lib/features/meal_planner/domain/entities/meal_plan_slot.dart @@ -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 ?? '-'})'; +} diff --git a/lib/features/meal_planner/domain/entities/recipe.dart b/lib/features/meal_planner/domain/entities/recipe.dart new file mode 100644 index 0000000..8ee9f4e --- /dev/null +++ b/lib/features/meal_planner/domain/entities/recipe.dart @@ -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 ingredients; + + /// Instruction steps with `HowToStep` / `HowToSection` wrappers flattened. + final List 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? ingredients, + List? 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)'; +} diff --git a/lib/features/meal_planner/domain/entities/recipe_ingredient.dart b/lib/features/meal_planner/domain/entities/recipe_ingredient.dart new file mode 100644 index 0000000..58abdb8 --- /dev/null +++ b/lib/features/meal_planner/domain/entities/recipe_ingredient.dart @@ -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 _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 toMap() => { + '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 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)'; +} diff --git a/lib/features/meal_planner/domain/repositories/meal_plan_repository.dart b/lib/features/meal_planner/domain/repositories/meal_plan_repository.dart new file mode 100644 index 0000000..f847e03 --- /dev/null +++ b/lib/features/meal_planner/domain/repositories/meal_plan_repository.dart @@ -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> 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> getSlots({ + required DateTime from, + required DateTime to, + }); + + /// Inserts or updates the slot identified by [MealPlanSlot.id] and returns + /// the stored slot. + Future saveSlot(MealPlanSlot slot); + + /// Removes the slot with [id], if present. + Future deleteSlot(String id); +} diff --git a/lib/features/meal_planner/domain/repositories/recipe_repository.dart b/lib/features/meal_planner/domain/repositories/recipe_repository.dart new file mode 100644 index 0000000..e20e73a --- /dev/null +++ b/lib/features/meal_planner/domain/repositories/recipe_repository.dart @@ -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> watchRecipes(); + + /// One-shot snapshot of the saved recipe catalog (newest first). + Future> getRecipes(); + + /// The recipe with [id], or `null` when it does not exist. + Future getRecipe(String id); + + /// Persists [recipe]; inserts a new row when [Recipe.id] is unknown, + /// otherwise updates the existing row. Returns the stored recipe. + Future 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 importRecipe(Recipe recipe); + + /// Removes the recipe with [id]. Assigned meal-plan slots fall back to + /// empty slots (nullable foreign key). + Future deleteRecipe(String id); +} diff --git a/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart b/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart new file mode 100644 index 0000000..4abd164 --- /dev/null +++ b/lib/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart @@ -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 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 ` + + + +'''; + +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 = ''' + + + + '''; + + 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 = ''' + + + + '''; + + 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 = ''' + + + + + + + '''; + + 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 = ''' + + + + + + '''; + + 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 = 'Just a page' + '

hello

'; + 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()), + ); + await expectLater( + scraping('not a url'), + throwsA(isA()), + ); + }); + + test('throws when no recipe metadata is present', () async { + final scraping = ScrapeRecipeFromUrl( + fetcher: (_) async => + 'Nothing here', + ); + + await expectLater( + scraping('https://example.com/blank'), + throwsA( + isA().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()), + ); + }); + }); +}