diff --git a/backend/prisma/migrations/20260506143000_recipe_ingredient_raw_fields/migration.sql b/backend/prisma/migrations/20260506143000_recipe_ingredient_raw_fields/migration.sql new file mode 100644 index 00000000..ce608273 --- /dev/null +++ b/backend/prisma/migrations/20260506143000_recipe_ingredient_raw_fields/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE `RecipeIngredient` + MODIFY `productId` INTEGER NULL, + MODIFY `quantity` DECIMAL(10, 2) NULL, + MODIFY `unit` VARCHAR(191) NULL, + ADD COLUMN `rawName` VARCHAR(191) NOT NULL DEFAULT '', + ADD COLUMN `rawLine` TEXT NULL, + ADD COLUMN `matchConfidence` DOUBLE NULL, + ADD COLUMN `matchSource` VARCHAR(191) NULL, + ADD COLUMN `analysisStatus` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b8fe74d2..9fe4c4c0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,237 +1,242 @@ -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "mysql" - url = env("DATABASE_URL") -} - -model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - firstName String? - lastName String? - passwordHash String - role String @default("user") - isPremium Boolean @default(false) - canShareRecipes Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - userProducts UserProduct[] - ownedRecipes Recipe[] @relation("RecipeOwner") - sharedRecipes RecipeShare[] - ownedProducts Product[] - pantryItems PantryItem[] - mealPlanEntries MealPlanEntry[] - receiptAliases ReceiptAlias[] -} - -model Product { - id Int @id @default(autoincrement()) - name String - normalizedName String @unique - category String? - canonicalName String? - isActive Boolean @default(true) - status String @default("active") - deletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - inventoryItems InventoryItem[] - recipeIngredients RecipeIngredient[] - pantryItems PantryItem[] - receiptAliases ReceiptAlias[] - tags ProductTag[] - nutrition Nutrition? - ownerId Int - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - userProducts UserProduct[] - categoryId Int? - categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - isPrivate Boolean @default(false) -} - -model Category { - id Int @id @default(autoincrement()) - name String - parentId Int? - parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) - children Category[] @relation("CategoryTree") - products Product[] - - @@unique([name, parentId]) - @@index([parentId]) -} - -model UserProduct { - id Int @id @default(autoincrement()) - userId Int - productId Int - note String? @db.Text - preferredBrand String? - preferredStore String? - isPrivate Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - - @@unique([userId, productId]) - @@index([userId]) - @@index([productId]) -} - -model InventoryItem { - id Int @id @default(autoincrement()) - productId Int - quantity Decimal @db.Decimal(10, 2) - unit String - brand String? - origin String? - receiptName String? - location String? - purchaseDate DateTime? - opened Boolean? - suitableFor String? - bestBeforeDate DateTime? - comment String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - consumptions InventoryConsumption[] - - @@index([productId]) -} - -model InventoryConsumption { - id Int @id @default(autoincrement()) - inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id]) - inventoryItemId Int - amountUsed Decimal @db.Decimal(10, 2) - comment String? - createdAt DateTime @default(now()) -} - -model Recipe { - id Int @id @default(autoincrement()) - name String - description String? @db.Text - instructions String? @db.Text - imageUrl String? - servings Int? - isPublic Boolean @default(false) - ownerId Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull) - ingredients RecipeIngredient[] - mealPlanEntries MealPlanEntry[] - shares RecipeShare[] -} - -model RecipeShare { - recipeId Int - userId Int - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@id([recipeId, userId]) - @@index([userId]) -} - -model RecipeIngredient { - id Int @id @default(autoincrement()) - recipe Recipe @relation(fields: [recipeId], references: [id]) - recipeId Int - product Product @relation(fields: [productId], references: [id]) - productId Int - quantity Decimal @db.Decimal(10, 2) - unit String - note String? - alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous") - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model PantryItem { - id Int @id @default(autoincrement()) - userId Int - productId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, productId]) - @@index([userId]) -} - -model ReceiptAlias { - id Int @id @default(autoincrement()) - receiptName String // normaliserat kvittonamn (lowercase, trim) - ownerId Int? - owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) - isGlobal Boolean @default(false) - productId Int - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - - @@unique([receiptName, ownerId, isGlobal]) - @@index([ownerId]) - @@index([isGlobal]) -} - -model MealPlanEntry { - id Int @id @default(autoincrement()) - userId Int - date DateTime @db.Date - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) - recipeId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - servings Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Bara ett recept per dag och anvandare. - @@unique([userId, date]) - @@index([userId]) - @@index([date]) -} - -model Tag { - id Int @id @default(autoincrement()) - name String @unique - products ProductTag[] -} - -model ProductTag { - productId Int - tagId Int - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) - - @@id([productId, tagId]) - @@index([tagId]) -} - -model Nutrition { - id Int @id @default(autoincrement()) - productId Int @unique - calories Float? - protein Float? - fat Float? - carbohydrates Float? - salt Float? - sugar Float? - fiber Float? - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) -} \ No newline at end of file +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + username String @unique + email String @unique + firstName String? + lastName String? + passwordHash String + role String @default("user") + isPremium Boolean @default(false) + canShareRecipes Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userProducts UserProduct[] + ownedRecipes Recipe[] @relation("RecipeOwner") + sharedRecipes RecipeShare[] + ownedProducts Product[] + pantryItems PantryItem[] + mealPlanEntries MealPlanEntry[] + receiptAliases ReceiptAlias[] +} + +model Product { + id Int @id @default(autoincrement()) + name String + normalizedName String @unique + category String? + canonicalName String? + isActive Boolean @default(true) + status String @default("active") + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + inventoryItems InventoryItem[] + recipeIngredients RecipeIngredient[] + pantryItems PantryItem[] + receiptAliases ReceiptAlias[] + tags ProductTag[] + nutrition Nutrition? + ownerId Int + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + userProducts UserProduct[] + categoryId Int? + categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + isPrivate Boolean @default(false) +} + +model Category { + id Int @id @default(autoincrement()) + name String + parentId Int? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) + children Category[] @relation("CategoryTree") + products Product[] + + @@unique([name, parentId]) + @@index([parentId]) +} + +model UserProduct { + id Int @id @default(autoincrement()) + userId Int + productId Int + note String? @db.Text + preferredBrand String? + preferredStore String? + isPrivate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@unique([userId, productId]) + @@index([userId]) + @@index([productId]) +} + +model InventoryItem { + id Int @id @default(autoincrement()) + productId Int + quantity Decimal @db.Decimal(10, 2) + unit String + brand String? + origin String? + receiptName String? + location String? + purchaseDate DateTime? + opened Boolean? + suitableFor String? + bestBeforeDate DateTime? + comment String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + consumptions InventoryConsumption[] + + @@index([productId]) +} + +model InventoryConsumption { + id Int @id @default(autoincrement()) + inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id]) + inventoryItemId Int + amountUsed Decimal @db.Decimal(10, 2) + comment String? + createdAt DateTime @default(now()) +} + +model Recipe { + id Int @id @default(autoincrement()) + name String + description String? @db.Text + instructions String? @db.Text + imageUrl String? + servings Int? + isPublic Boolean @default(false) + ownerId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull) + ingredients RecipeIngredient[] + mealPlanEntries MealPlanEntry[] + shares RecipeShare[] +} + +model RecipeShare { + recipeId Int + userId Int + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([recipeId, userId]) + @@index([userId]) +} + +model RecipeIngredient { + id Int @id @default(autoincrement()) + recipe Recipe @relation(fields: [recipeId], references: [id]) + recipeId Int + product Product? @relation(fields: [productId], references: [id]) + productId Int? + rawName String @default("") + rawLine String? @db.Text + quantity Decimal? @db.Decimal(10, 2) + unit String? + note String? + alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous") + matchConfidence Float? + matchSource String? + analysisStatus String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PantryItem { + id Int @id @default(autoincrement()) + userId Int + productId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, productId]) + @@index([userId]) +} + +model ReceiptAlias { + id Int @id @default(autoincrement()) + receiptName String // normaliserat kvittonamn (lowercase, trim) + ownerId Int? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + isGlobal Boolean @default(false) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([receiptName, ownerId, isGlobal]) + @@index([ownerId]) + @@index([isGlobal]) +} + +model MealPlanEntry { + id Int @id @default(autoincrement()) + userId Int + date DateTime @db.Date + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + recipeId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + servings Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Bara ett recept per dag och anvandare. + @@unique([userId, date]) + @@index([userId]) + @@index([date]) +} + +model Tag { + id Int @id @default(autoincrement()) + name String @unique + products ProductTag[] +} + +model ProductTag { + productId Int + tagId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([productId, tagId]) + @@index([tagId]) +} + +model Nutrition { + id Int @id @default(autoincrement()) + productId Int @unique + calories Float? + protein Float? + fat Float? + carbohydrates Float? + salt Float? + sugar Float? + fiber Float? + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) +} diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index 8f1b1906..668a62bf 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -12,20 +12,39 @@ import { import { Type } from 'class-transformer'; class CreateRecipeIngredientDto { + @IsOptional() @IsInt() - productId!: number; - - @IsNumber() - @Min(0) - quantity!: number; + productId?: number; @IsString() - unit!: string; + rawName!: string; + + @IsOptional() + @IsString() + rawLine?: string; + + @IsOptional() + @IsNumber() + @Min(0) + quantity?: number; + + @IsOptional() + @IsString() + unit?: string; @IsOptional() @IsString() note?: string; + @IsOptional() + @IsNumber() + @Min(0) + matchConfidence?: number; + + @IsOptional() + @IsString() + matchSource?: string; + @IsOptional() @IsArray() @IsInt({ each: true }) diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 1dd8cbd4..458aca4e 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -113,6 +113,24 @@ export class RecipesService { const ingredientPreviews = await Promise.all( recipe.ingredients.map(async (ingredient: any) => { + if (!ingredient.productId || !ingredient.product) { + return { + ingredientId: ingredient.id, + productId: null, + productName: ingredient.rawName || 'Okänd ingrediens', + requiredQuantity: Number(ingredient.quantity ?? 0), + requiredUnit: ingredient.unit || '', + note: ingredient.note, + availableQuantity: 0, + availableUnit: ingredient.unit || '', + matchingInventoryItems: [], + otherInventoryItems: [], + status: 'missing' as const, + fromPantry: false, + missingQuantity: Number(ingredient.quantity ?? 0), + }; + } + // Täcks ingrediensen av pantry (inkl. alternativ)? const coveredByPantry = pantryProductIds.has(ingredient.productId) || @@ -313,7 +331,11 @@ export class RecipesService { await this.assertAndClaimRecipeOwner(existingRecipe, userId); // Validera att alla produkter är aktiva - await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId)); + await this.assertProductsActive( + updateRecipeDto.ingredients + .map((i) => i.productId) + .filter((id): id is number => typeof id === 'number'), + ); // Transaktionsblock: ta bort gamla + skapa nya ingredienser atomärt const recipe = await this.prisma.$transaction(async (tx) => { @@ -329,11 +351,15 @@ export class RecipesService { ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), ingredients: { create: updateRecipeDto.ingredients.map((ingredient) => ({ - productId: ingredient.productId, - quantity: ingredient.quantity, - unit: ingredient.unit, + productId: ingredient.productId ?? null, + rawName: ingredient.rawName, + rawLine: ingredient.rawLine ?? null, + quantity: ingredient.quantity ?? null, + unit: ingredient.unit?.trim() ? ingredient.unit : null, note: ingredient.note || null, alternativeProductIds: ingredient.alternativeProductIds ?? [], + matchConfidence: ingredient.matchConfidence ?? null, + matchSource: ingredient.matchSource ?? null, })), }, }, @@ -462,7 +488,11 @@ export class RecipesService { async create(createRecipeDto: CreateRecipeDto, userId: number) { // Validera att alla produkter är aktiva - await this.assertProductsActive(createRecipeDto.ingredients.map((i) => i.productId)); + await this.assertProductsActive( + createRecipeDto.ingredients + .map((i) => i.productId) + .filter((id): id is number => typeof id === 'number'), + ); this.logger.log( `[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`, @@ -497,11 +527,15 @@ export class RecipesService { isPublic: false, ingredients: { create: createRecipeDto.ingredients.map((ingredient) => ({ - productId: ingredient.productId, - quantity: ingredient.quantity, - unit: ingredient.unit, + productId: ingredient.productId ?? null, + rawName: ingredient.rawName, + rawLine: ingredient.rawLine ?? null, + quantity: ingredient.quantity ?? null, + unit: ingredient.unit?.trim() ? ingredient.unit : null, note: ingredient.note || null, alternativeProductIds: ingredient.alternativeProductIds ?? [], + matchConfidence: ingredient.matchConfidence ?? null, + matchSource: ingredient.matchSource ?? null, })), }, }, @@ -745,6 +779,7 @@ Regler: return { rawName: ingredient.rawName, + rawLine: ingredient.rawName, alternatives: ingredient.alternatives ?? [], quantity: ingredient.quantity, unit: ingredient.unit, diff --git a/flutter/lib/features/recipes/domain/inventory_preview.dart b/flutter/lib/features/recipes/domain/inventory_preview.dart index edee97d9..d6aa0532 100644 --- a/flutter/lib/features/recipes/domain/inventory_preview.dart +++ b/flutter/lib/features/recipes/domain/inventory_preview.dart @@ -2,7 +2,7 @@ enum IngredientStatus { enough, missing, unitMismatch } class IngredientPreview { final int ingredientId; - final int productId; + final int? productId; final String productName; final double requiredQuantity; final String requiredUnit; @@ -14,7 +14,7 @@ class IngredientPreview { const IngredientPreview({ required this.ingredientId, - required this.productId, + this.productId, required this.productName, required this.requiredQuantity, required this.requiredUnit, @@ -34,8 +34,8 @@ class IngredientPreview { }; return IngredientPreview( ingredientId: json['ingredientId'] as int, - productId: json['productId'] as int, - productName: json['productName'] as String, + productId: (json['productId'] as num?)?.toInt(), + productName: (json['productName'] as String?) ?? (json['rawName'] as String? ?? ''), requiredQuantity: (json['requiredQuantity'] as num).toDouble(), requiredUnit: json['requiredUnit'] as String? ?? '', note: json['note'] as String?, diff --git a/flutter/lib/features/recipes/domain/parsed_recipe.dart b/flutter/lib/features/recipes/domain/parsed_recipe.dart index 898b56a0..c90d9614 100644 --- a/flutter/lib/features/recipes/domain/parsed_recipe.dart +++ b/flutter/lib/features/recipes/domain/parsed_recipe.dart @@ -19,6 +19,7 @@ class IngredientSuggestion { class ParsedIngredient { final String rawName; + final String? rawLine; final double quantity; final String unit; final String? note; @@ -27,6 +28,7 @@ class ParsedIngredient { const ParsedIngredient({ required this.rawName, + this.rawLine, required this.quantity, required this.unit, this.note, @@ -38,6 +40,7 @@ class ParsedIngredient { final rawSuggestions = json['suggestions'] as List? ?? []; return ParsedIngredient( rawName: json['rawName'] as String? ?? '', + rawLine: json['rawLine'] as String?, quantity: (json['quantity'] as num? ?? 0).toDouble(), unit: json['unit'] as String? ?? '', note: json['note'] as String?, diff --git a/flutter/lib/features/recipes/domain/recipe_ingredient.dart b/flutter/lib/features/recipes/domain/recipe_ingredient.dart index 9e6cd008..88976bae 100644 --- a/flutter/lib/features/recipes/domain/recipe_ingredient.dart +++ b/flutter/lib/features/recipes/domain/recipe_ingredient.dart @@ -1,15 +1,19 @@ class RecipeIngredient { final int id; - final int productId; - final String productName; + final int? productId; + final String? productName; + final String rawName; + final String? rawLine; final double quantity; final String unit; final String? note; const RecipeIngredient({ required this.id, - required this.productId, - required this.productName, + this.productId, + this.productName, + required this.rawName, + this.rawLine, required this.quantity, required this.unit, this.note, @@ -20,8 +24,10 @@ class RecipeIngredient { final rawQty = json['quantity']; return RecipeIngredient( id: (json['id'] as num).toInt(), - productId: (json['productId'] as num).toInt(), - productName: product?['name'] as String? ?? '', + productId: (json['productId'] as num?)?.toInt(), + productName: product?['canonicalName'] as String? ?? product?['name'] as String?, + rawName: json['rawName'] as String? ?? '', + rawLine: json['rawLine'] as String?, quantity: rawQty is num ? rawQty.toDouble() : double.tryParse(rawQty?.toString() ?? '') ?? 0, diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart index 07f62c1c..15d56b8e 100644 --- a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -191,7 +191,6 @@ class _CreateRecipeScreenState extends ConsumerState { for (var i = 0; i < _parsed!.ingredients.length; i++) { if (!_included[i]) continue; final productId = _selectedProductIds[i]; - if (productId == null) continue; final qty = double.tryParse( _qtyControllers[i]!.text.trim().replaceAll(',', '.'), ) ?? @@ -207,9 +206,11 @@ class _CreateRecipeScreenState extends ConsumerState { .toList() : []; ingredients.add({ - 'productId': productId, - 'quantity': qty, - 'unit': unit, + 'rawName': ing.rawName, + if ((ing.rawLine ?? '').trim().isNotEmpty) 'rawLine': ing.rawLine, + if (productId != null) 'productId': productId, + if (qty > 0) 'quantity': qty, + if (unit.isNotEmpty) 'unit': unit, if (note.isNotEmpty) 'note': note, if (alternativeProductIds.isNotEmpty) 'alternativeProductIds': alternativeProductIds, diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart index 33f44655..b0424d4c 100644 --- a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -396,6 +396,10 @@ class _RecipeBody extends StatelessWidget { const SizedBox(height: 12), ...recipe.ingredients.map((ing) { final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity); + final ingredientLabel = (ing.rawName.trim().isNotEmpty + ? ing.rawName + : (ing.productName ?? '').trim()) + .trim(); final measureParts = [ if (qtyStr.isNotEmpty) qtyStr, if (ing.unit.isNotEmpty) ing.unit, @@ -430,8 +434,8 @@ class _RecipeBody extends StatelessWidget { Expanded( child: Text( ing.note != null - ? '${ing.productName} (${ing.note})' - : ing.productName, + ? '$ingredientLabel (${ing.note})' + : ingredientLabel, style: theme.textTheme.bodyMedium, ), ),