feat: implement recipe analysis service and data models
Test Suite / test (24.15.0) (push) Has been cancelled

- Added RecipeAnalysisService to handle recipe ingredient analysis, including methods for checking ingredient availability and calculating quantities.
- Introduced new TypeScript definitions for recipe analysis results, including ingredient status and summary.
- Created corresponding Dart models for recipe analysis, including RecipeIngredientAnalysis, RecipeAnalysisSummary, and RecipeShoppingCandidate.
- Updated Flutter UI to reflect changes in ingredient availability status.
- Fixed color opacity issue in recipe image card.
This commit is contained in:
Nils-Johan Gynther
2026-05-06 07:54:03 +02:00
parent 969dafdbc6
commit 9fe85a719c
23 changed files with 1271 additions and 693 deletions
+50
View File
@@ -0,0 +1,50 @@
import { PrismaService } from '../prisma/prisma.service';
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
export declare class RecipeAnalysisService {
private readonly prisma;
constructor(prisma: PrismaService);
private getAccessibleRecipe;
private calculateAvailableQuantity;
analyzeRecipeIngredients(id: number, userId: number): Promise<{
recipeId: number;
ingredients: ({
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
note: any;
status: AnalysisStatus;
matchedProductId: any;
matchedProductName: any;
source: string;
availableQuantity: number;
missingQuantity: number;
} | {
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
note: any;
status: AnalysisStatus;
matchedProductId: any;
matchedProductName: any;
source: null;
availableQuantity: number;
missingQuantity: number;
})[];
summary: {
exactCount: number;
pantryCount: number;
substituteCount: number;
missingCount: number;
};
shoppingListCandidates: {
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
missingQuantity: number;
}[];
}>;
}
export {};
+217
View File
@@ -0,0 +1,217 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RecipeAnalysisService = void 0;
const common_1 = require("@nestjs/common");
const prisma_service_1 = require("../prisma/prisma.service");
const units_1 = require("../common/utils/units");
let RecipeAnalysisService = class RecipeAnalysisService {
constructor(prisma) {
this.prisma = prisma;
}
async getAccessibleRecipe(id, userId) {
const recipe = await this.prisma.recipe.findFirst({
where: {
id,
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: {
ingredients: {
include: {
product: true,
},
orderBy: { id: 'asc' },
},
},
});
if (!recipe) {
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
}
return recipe;
}
calculateAvailableQuantity(inventoryItems, requiredUnit) {
if (!requiredUnit) {
return inventoryItems.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
}
const normalizedRequiredUnit = requiredUnit.trim().toLowerCase();
const sameUnit = inventoryItems
.filter((item) => item.unit.trim().toLowerCase() === normalizedRequiredUnit)
.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
const converted = inventoryItems
.filter((item) => item.unit.trim().toLowerCase() !== normalizedRequiredUnit)
.reduce((sum, item) => {
if (!(0, units_1.canConvert)(item.unit, requiredUnit))
return sum;
try {
return sum + (0, units_1.convertUnit)(Number(item.quantity ?? 0), item.unit, requiredUnit);
}
catch {
return sum;
}
}, 0);
return sameUnit + converted;
}
async analyzeRecipeIngredients(id, userId) {
const recipe = await this.getAccessibleRecipe(id, userId);
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const ingredients = await Promise.all(recipe.ingredients.map(async (ingredient) => {
const requiredQuantity = Number(ingredient.quantity ?? 0);
const requiredUnit = (ingredient.unit ?? '').trim();
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
if (!ingredient.productId || !ingredient.product) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'missing',
matchedProductId: null,
matchedProductName: null,
source: null,
availableQuantity: 0,
missingQuantity: requiredQuantity,
};
}
if (pantryProductIds.has(ingredient.productId)) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'covered_by_pantry',
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: 'pantry',
availableQuantity: requiredQuantity,
missingQuantity: 0,
};
}
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: { productId: ingredient.productId },
select: { quantity: true, unit: true },
});
const availableQuantity = this.calculateAvailableQuantity(inventoryItems, requiredUnit);
if (availableQuantity >= requiredQuantity) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'exact_match',
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: 'inventory',
availableQuantity,
missingQuantity: 0,
};
}
const alternativeProductIds = Array.isArray(ingredient.alternativeProductIds)
? ingredient.alternativeProductIds.filter((id) => typeof id === 'number')
: [];
for (const altProductId of alternativeProductIds) {
if (pantryProductIds.has(altProductId)) {
const altProduct = await this.prisma.product.findUnique({
where: { id: altProductId },
select: { id: true, name: true, canonicalName: true },
});
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable',
matchedProductId: altProduct?.id ?? altProductId,
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
source: 'pantry_substitute',
availableQuantity: requiredQuantity,
missingQuantity: 0,
};
}
const altInventoryItems = await this.prisma.inventoryItem.findMany({
where: { productId: altProductId },
select: { quantity: true, unit: true },
});
const altAvailable = this.calculateAvailableQuantity(altInventoryItems, requiredUnit);
if (altAvailable > 0) {
const altProduct = await this.prisma.product.findUnique({
where: { id: altProductId },
select: { id: true, name: true, canonicalName: true },
});
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable',
matchedProductId: altProduct?.id ?? altProductId,
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
source: 'inventory_substitute',
availableQuantity: altAvailable,
missingQuantity: Math.max(0, requiredQuantity - altAvailable),
};
}
}
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'missing',
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: null,
availableQuantity,
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
};
}));
const summary = {
exactCount: ingredients.filter((i) => i.status === 'exact_match').length,
pantryCount: ingredients.filter((i) => i.status === 'covered_by_pantry').length,
substituteCount: ingredients.filter((i) => i.status === 'substitutable').length,
missingCount: ingredients.filter((i) => i.status === 'missing').length,
};
const shoppingListCandidates = ingredients
.filter((i) => i.status === 'missing')
.map((i) => ({
ingredientId: i.ingredientId,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
missingQuantity: i.missingQuantity,
}));
return {
recipeId: recipe.id,
ingredients,
summary,
shoppingListCandidates,
};
}
};
exports.RecipeAnalysisService = RecipeAnalysisService;
exports.RecipeAnalysisService = RecipeAnalysisService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], RecipeAnalysisService);
//# sourceMappingURL=recipe-analysis.service.js.map
File diff suppressed because one or more lines are too long
+221 -176
View File
@@ -4,12 +4,14 @@ import { CreateIngredientDto } from './dto/create-ingredient.dto';
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { ShareRecipeDto } from './dto/share-recipe.dto';
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
import { RecipeAnalysisService } from './recipe-analysis.service';
declare class UpdateImageDto {
sourceUrl: string;
}
export declare class RecipesController {
private readonly recipesService;
constructor(recipesService: RecipesService);
private readonly recipeAnalysisService;
constructor(recipesService: RecipesService, recipeAnalysisService: RecipeAnalysisService);
parseMarkdown(dto: ParseMarkdownDto): Promise<{
name: string;
description: string;
@@ -43,6 +45,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -50,54 +54,52 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
})[]>;
getInventoryPreview(id: number, user: {
userId: number;
@@ -145,6 +147,49 @@ export declare class RecipesController {
pantryCount: number;
};
}>;
getRecipeAnalysis(id: number, user: {
userId: number;
}): Promise<{
recipeId: number;
ingredients: ({
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
note: any;
status: "missing" | "exact_match" | "covered_by_pantry" | "substitutable";
matchedProductId: any;
matchedProductName: any;
source: string;
availableQuantity: number;
missingQuantity: number;
} | {
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
note: any;
status: "missing" | "exact_match" | "covered_by_pantry" | "substitutable";
matchedProductId: any;
matchedProductName: any;
source: null;
availableQuantity: number;
missingQuantity: number;
})[];
summary: {
exactCount: number;
pantryCount: number;
substituteCount: number;
missingCount: number;
};
shoppingListCandidates: {
ingredientId: any;
rawName: any;
quantity: number;
unit: any;
missingQuantity: number;
}[];
}>;
findOne(id: number, user: {
userId: number;
}): Promise<{
@@ -155,6 +200,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -162,54 +209,52 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
create(createRecipeDto: CreateRecipeDto, user: {
userId: number;
@@ -217,6 +262,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -224,51 +271,49 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
update(id: number, createRecipeDto: CreateRecipeDto, user: {
userId: number;
@@ -276,6 +321,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -283,51 +330,49 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
remove(id: number, user: {
userId: number;
@@ -342,6 +387,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -349,60 +396,60 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
addIngredient(id: number, ingredient: CreateIngredientDto, user: {
userId: number;
}): Promise<{
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -410,38 +457,36 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
}>;
setVisibility(id: number, dto: SetRecipeVisibilityDto, user: {
@@ -454,6 +499,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -461,54 +508,52 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
shareRecipe(id: number, dto: ShareRecipeDto, user: {
userId: number;
@@ -520,6 +565,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -527,54 +574,52 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
unshareRecipe(id: number, username: string, user: {
userId: number;
@@ -586,6 +631,8 @@ export declare class RecipesController {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -593,54 +640,52 @@ export declare class RecipesController {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: import("@prisma/client/runtime/library").Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
}
export {};
+16 -2
View File
@@ -22,6 +22,7 @@ const parse_markdown_dto_1 = require("./dto/parse-markdown.dto");
const current_user_decorator_1 = require("../auth/decorators/current-user.decorator");
const share_recipe_dto_1 = require("./dto/share-recipe.dto");
const set_recipe_visibility_dto_1 = require("./dto/set-recipe-visibility.dto");
const recipe_analysis_service_1 = require("./recipe-analysis.service");
class UpdateImageDto {
}
__decorate([
@@ -29,8 +30,9 @@ __decorate([
__metadata("design:type", String)
], UpdateImageDto.prototype, "sourceUrl", void 0);
let RecipesController = class RecipesController {
constructor(recipesService) {
constructor(recipesService, recipeAnalysisService) {
this.recipesService = recipesService;
this.recipeAnalysisService = recipeAnalysisService;
}
parseMarkdown(dto) {
return this.recipesService.parseMarkdown(dto);
@@ -44,6 +46,9 @@ let RecipesController = class RecipesController {
getInventoryPreview(id, user) {
return this.recipesService.getInventoryPreview(id, user.userId);
}
getRecipeAnalysis(id, user) {
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
}
findOne(id, user) {
return this.recipesService.findOne(id, user.userId);
}
@@ -102,6 +107,14 @@ __decorate([
__metadata("design:paramtypes", [Number, Object]),
__metadata("design:returntype", void 0)
], RecipesController.prototype, "getInventoryPreview", null);
__decorate([
(0, common_1.Get)(':id/analysis'),
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
__param(1, (0, current_user_decorator_1.CurrentUser)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number, Object]),
__metadata("design:returntype", void 0)
], RecipesController.prototype, "getRecipeAnalysis", null);
__decorate([
(0, common_1.Get)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
@@ -183,6 +196,7 @@ __decorate([
], RecipesController.prototype, "unshareRecipe", null);
exports.RecipesController = RecipesController = __decorate([
(0, common_1.Controller)('recipes'),
__metadata("design:paramtypes", [recipes_service_1.RecipesService])
__metadata("design:paramtypes", [recipes_service_1.RecipesService,
recipe_analysis_service_1.RecipeAnalysisService])
], RecipesController);
//# sourceMappingURL=recipes.controller.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"recipes.controller.js","sourceRoot":"","sources":["../../src/recipes/recipes.controller.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"recipes.controller.js","sourceRoot":"","sources":["../../src/recipes/recipes.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA2G;AAC3G,qDAA2C;AAC3C,uDAAmD;AACnD,+DAA0D;AAC1D,uEAAkE;AAClE,iEAA4D;AAC5D,sFAAwE;AACxE,6DAAwD;AACxD,+EAAyE;AACzE,uEAAkE;AAElE,MAAM,cAAc;CAGnB;AADC;IADC,IAAA,0BAAQ,GAAE;;iDACQ;AAId,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAC5B,YACmB,cAA8B,EAC9B,qBAA4C;QAD5C,mBAAc,GAAd,cAAc,CAAgB;QAC9B,0BAAqB,GAArB,qBAAqB,CAAuB;IAC5D,CAAC;IAGJ,aAAa,CAAS,GAAqB;QACzC,OAAO,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAChD,CAAC;IAGD,gBAAgB,CAAgB,IAAwB;QACtD,OAAO,IAAI,CAAC,cAAc,CAAC,2BAA2B,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACtE,CAAC;IAGD,OAAO,CAAgB,IAAwB;QAC7C,OAAO,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClD,CAAC;IAGD,mBAAmB,CACU,EAAU,EACtB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,mBAAmB,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAClE,CAAC;IAGD,iBAAiB,CACY,EAAU,EACtB,IAAwB;QAEvC,OAAO,IAAI,CAAC,qBAAqB,CAAC,wBAAwB,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9E,CAAC;IAGD,OAAO,CACsB,EAAU,EACtB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACtD,CAAC;IAGK,AAAN,KAAK,CAAC,MAAM,CACF,eAAgC,EACzB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IAClE,CAAC;IAGK,AAAN,KAAK,CAAC,MAAM,CACiB,EAAU,EAC7B,eAAgC,EACzB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,EAAE,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACtE,CAAC;IAIK,AAAN,KAAK,CAAC,MAAM,CACiB,EAAU,EACtB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACrD,CAAC;IAGK,AAAN,KAAK,CAAC,WAAW,CACY,EAAU,EAC7B,GAAmB,EACZ,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,EAAE,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACzE,CAAC;IAGK,AAAN,KAAK,CAAC,aAAa,CACU,EAAU,EAC7B,UAA+B,EACxB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACxE,CAAC;IAGK,AAAN,KAAK,CAAC,aAAa,CACU,EAAU,EAC7B,GAA2B,EACpB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC1E,CAAC;IAGK,AAAN,KAAK,CAAC,WAAW,CACY,EAAU,EAC7B,GAAmB,EACZ,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjF,CAAC;IAGK,AAAN,KAAK,CAAC,aAAa,CACU,EAAU,EAClB,QAAgB,EACpB,IAAwB;QAEvC,OAAO,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,EAAE,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IAC/E,CAAC;CACF,CAAA;AAnHY,8CAAiB;AAO5B;IADC,IAAA,aAAI,EAAC,gBAAgB,CAAC;IACR,WAAA,IAAA,aAAI,GAAE,CAAA;;qCAAM,qCAAgB;;sDAE1C;AAGD;IADC,IAAA,YAAG,EAAC,gBAAgB,CAAC;IACJ,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;yDAE9B;AAGD;IADC,IAAA,YAAG,GAAE;IACG,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;gDAErB;AAGD;IADC,IAAA,YAAG,EAAC,uBAAuB,CAAC;IAE1B,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;4DAGf;AAGD;IADC,IAAA,YAAG,EAAC,cAAc,CAAC;IAEjB,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;0DAGf;AAGD;IADC,IAAA,YAAG,EAAC,KAAK,CAAC;IAER,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;gDAGf;AAGK;IADL,IAAA,aAAI,GAAE;IAEJ,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;qCADW,mCAAe;;+CAIzC;AAGK;IADL,IAAA,cAAK,EAAC,KAAK,CAAC;IAEV,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;6CADW,mCAAe;;+CAIzC;AAIK;IAFL,IAAA,eAAM,EAAC,KAAK,CAAC;IACb,IAAA,iBAAQ,EAAC,GAAG,CAAC;IAEX,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;+CAGf;AAGK;IADL,IAAA,aAAI,EAAC,WAAW,CAAC;IAEf,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;6CADD,cAAc;;oDAI5B;AAGK;IADL,IAAA,aAAI,EAAC,iBAAiB,CAAC;IAErB,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;6CADM,2CAAmB;;sDAIxC;AAGK;IADL,IAAA,cAAK,EAAC,gBAAgB,CAAC;IAErB,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;6CADD,kDAAsB;;sDAIpC;AAGK;IADL,IAAA,aAAI,EAAC,WAAW,CAAC;IAEf,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,aAAI,GAAE,CAAA;IACN,WAAA,IAAA,oCAAW,GAAE,CAAA;;6CADD,iCAAc;;oDAI5B;AAGK;IADL,IAAA,eAAM,EAAC,qBAAqB,CAAC;IAE3B,WAAA,IAAA,cAAK,EAAC,IAAI,EAAE,qBAAY,CAAC,CAAA;IACzB,WAAA,IAAA,cAAK,EAAC,UAAU,CAAC,CAAA;IACjB,WAAA,IAAA,oCAAW,GAAE,CAAA;;;;sDAGf;4BAlHU,iBAAiB;IAD7B,IAAA,mBAAU,EAAC,SAAS,CAAC;qCAGe,gCAAc;QACP,+CAAqB;GAHpD,iBAAiB,CAmH7B"}
+2 -1
View File
@@ -12,6 +12,7 @@ const prisma_module_1 = require("../prisma/prisma.module");
const ai_module_1 = require("../ai/ai.module");
const recipes_controller_1 = require("./recipes.controller");
const recipes_service_1 = require("./recipes.service");
const recipe_analysis_service_1 = require("./recipe-analysis.service");
let RecipesModule = class RecipesModule {
};
exports.RecipesModule = RecipesModule;
@@ -19,7 +20,7 @@ exports.RecipesModule = RecipesModule = __decorate([
(0, common_1.Module)({
imports: [prisma_module_1.PrismaModule, ai_module_1.AiModule],
controllers: [recipes_controller_1.RecipesController],
providers: [recipes_service_1.RecipesService],
providers: [recipes_service_1.RecipesService, recipe_analysis_service_1.RecipeAnalysisService],
})
], RecipesModule);
//# sourceMappingURL=recipes.module.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"recipes.module.js","sourceRoot":"","sources":["../../src/recipes/recipes.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2DAAuD;AACvD,+CAA2C;AAC3C,6DAAyD;AACzD,uDAAmD;AAO5C,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IALzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,4BAAY,EAAE,oBAAQ,CAAC;QACjC,WAAW,EAAE,CAAC,sCAAiB,CAAC;QAChC,SAAS,EAAE,CAAC,gCAAc,CAAC;KAC5B,CAAC;GACW,aAAa,CAAG"}
{"version":3,"file":"recipes.module.js","sourceRoot":"","sources":["../../src/recipes/recipes.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2DAAuD;AACvD,+CAA2C;AAC3C,6DAAyD;AACzD,uDAAmD;AACnD,uEAAkE;AAO3D,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IALzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,4BAAY,EAAE,oBAAQ,CAAC;QACjC,WAAW,EAAE,CAAC,sCAAiB,CAAC;QAChC,SAAS,EAAE,CAAC,gCAAc,EAAE,+CAAqB,CAAC;KACnD,CAAC;GACW,aAAa,CAAG"}
+176 -175
View File
@@ -17,6 +17,7 @@ export declare class RecipesService {
private readonly logger;
constructor(prisma: PrismaService, aiService: AiService);
private throwRecipeNotFound;
private normalizeIngredientName;
private assertProductsActive;
private findRecipeByIdOrThrow;
private assertAndClaimRecipeOwner;
@@ -73,6 +74,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -80,54 +83,52 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
})[]>;
findOne(id: number, userId: number): Promise<{
owner: {
@@ -137,6 +138,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -144,59 +147,59 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
update(id: number, updateRecipeDto: CreateRecipeDto, userId: number): Promise<{
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -204,51 +207,49 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
remove(id: number, userId: number): Promise<void>;
updateImage(id: number, sourceUrl: string, userId: number): Promise<{
@@ -259,6 +260,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -266,54 +269,52 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
setVisibility(id: number, userId: number, isPublic: boolean): Promise<{
owner: {
@@ -323,6 +324,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -330,54 +333,52 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
shareWithUser(id: number, ownerId: number, username: string): Promise<{
owner: {
@@ -387,6 +388,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -394,54 +397,52 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
unshareWithUser(id: number, ownerId: number, username: string): Promise<{
owner: {
@@ -451,6 +452,8 @@ export declare class RecipesService {
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -458,59 +461,59 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
shares: {
userId: number;
}[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
create(createRecipeDto: CreateRecipeDto, userId: number): Promise<{
ingredients: ({
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -518,55 +521,55 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
})[];
} & {
isPublic: boolean;
name: string;
id: number;
createdAt: Date;
updatedAt: Date;
ownerId: number | null;
name: string;
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
isPublic: boolean;
ownerId: number | null;
createdAt: Date;
updatedAt: Date;
}>;
addIngredient(id: number, ingredient: CreateIngredientDto, userId: number): Promise<{
product: ({
nutrition: {
id: number;
productId: number;
calories: number | null;
protein: number | null;
fat: number | null;
@@ -574,38 +577,36 @@ export declare class RecipesService {
salt: number | null;
sugar: number | null;
fiber: number | null;
id: number;
productId: number;
} | null;
} & {
category: string | null;
status: string;
name: string;
categoryId: number | null;
canonicalName: string | null;
id: number;
normalizedName: string;
isActive: boolean;
deletedAt: Date | null;
name: string;
ownerId: number;
createdAt: Date;
updatedAt: Date;
ownerId: number;
status: string;
normalizedName: string;
category: string | null;
canonicalName: string | null;
isActive: boolean;
deletedAt: Date | null;
categoryId: number | null;
isPrivate: boolean;
}) | null;
} & {
id: number;
createdAt: Date;
updatedAt: Date;
recipeId: number;
productId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
rawName: string;
rawLine: string | null;
quantity: Prisma.Decimal | null;
unit: string | null;
note: string | null;
alternativeProductIds: Prisma.JsonValue | null;
matchConfidence: number | null;
matchSource: string | null;
alternativeProductIds: Prisma.JsonValue | null;
recipeId: number;
analysisStatus: string | null;
}>;
suggestRecipesFromInventory(userId: number): Promise<{
+8 -2
View File
@@ -29,6 +29,12 @@ let RecipesService = RecipesService_1 = class RecipesService {
throwRecipeNotFound(id) {
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
}
normalizeIngredientName(value) {
const trimmed = value.trim();
if (!trimmed)
return trimmed;
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
async assertProductsActive(productIds) {
if (productIds.length === 0)
return;
@@ -299,7 +305,7 @@ let RecipesService = RecipesService_1 = class RecipesService {
ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawName: this.normalizeIngredientName(ingredient.rawName),
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
@@ -443,7 +449,7 @@ let RecipesService = RecipesService_1 = class RecipesService {
ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawName: this.normalizeIngredientName(ingredient.rawName),
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
File diff suppressed because one or more lines are too long
@@ -0,0 +1,228 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { canConvert, convertUnit } from '../common/utils/units';
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
@Injectable()
export class RecipeAnalysisService {
constructor(private readonly prisma: PrismaService) {}
private async getAccessibleRecipe(id: number, userId: number) {
const recipe = await this.prisma.recipe.findFirst({
where: {
id,
OR: [
{ isPublic: true },
{ ownerId: userId },
{ shares: { some: { userId } } },
],
},
include: {
ingredients: {
include: {
product: true,
},
orderBy: { id: 'asc' },
},
},
});
if (!recipe) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
return recipe;
}
private calculateAvailableQuantity(
inventoryItems: Array<{ quantity: any; unit: string }>,
requiredUnit: string,
): number {
if (!requiredUnit) {
return inventoryItems.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
}
const normalizedRequiredUnit = requiredUnit.trim().toLowerCase();
const sameUnit = inventoryItems
.filter((item) => item.unit.trim().toLowerCase() === normalizedRequiredUnit)
.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
const converted = inventoryItems
.filter((item) => item.unit.trim().toLowerCase() !== normalizedRequiredUnit)
.reduce((sum, item) => {
if (!canConvert(item.unit, requiredUnit)) return sum;
try {
return sum + convertUnit(Number(item.quantity ?? 0), item.unit, requiredUnit);
} catch {
return sum;
}
}, 0);
return sameUnit + converted;
}
async analyzeRecipeIngredients(id: number, userId: number) {
const recipe = await this.getAccessibleRecipe(id, userId);
const pantryItems = await this.prisma.pantryItem.findMany({
where: { userId },
select: { productId: true },
});
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
const ingredients = await Promise.all(
recipe.ingredients.map(async (ingredient: any) => {
const requiredQuantity = Number(ingredient.quantity ?? 0);
const requiredUnit = (ingredient.unit ?? '').trim();
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
if (!ingredient.productId || !ingredient.product) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'missing' as AnalysisStatus,
matchedProductId: null,
matchedProductName: null,
source: null,
availableQuantity: 0,
missingQuantity: requiredQuantity,
};
}
if (pantryProductIds.has(ingredient.productId)) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'covered_by_pantry' as AnalysisStatus,
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: 'pantry',
availableQuantity: requiredQuantity,
missingQuantity: 0,
};
}
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: { productId: ingredient.productId },
select: { quantity: true, unit: true },
});
const availableQuantity = this.calculateAvailableQuantity(inventoryItems, requiredUnit);
if (availableQuantity >= requiredQuantity) {
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'exact_match' as AnalysisStatus,
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: 'inventory',
availableQuantity,
missingQuantity: 0,
};
}
const alternativeProductIds = Array.isArray(ingredient.alternativeProductIds)
? ingredient.alternativeProductIds.filter((id: any) => typeof id === 'number')
: [];
for (const altProductId of alternativeProductIds) {
if (pantryProductIds.has(altProductId)) {
const altProduct = await this.prisma.product.findUnique({
where: { id: altProductId },
select: { id: true, name: true, canonicalName: true },
});
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable' as AnalysisStatus,
matchedProductId: altProduct?.id ?? altProductId,
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
source: 'pantry_substitute',
availableQuantity: requiredQuantity,
missingQuantity: 0,
};
}
const altInventoryItems = await this.prisma.inventoryItem.findMany({
where: { productId: altProductId },
select: { quantity: true, unit: true },
});
const altAvailable = this.calculateAvailableQuantity(altInventoryItems, requiredUnit);
if (altAvailable > 0) {
const altProduct = await this.prisma.product.findUnique({
where: { id: altProductId },
select: { id: true, name: true, canonicalName: true },
});
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'substitutable' as AnalysisStatus,
matchedProductId: altProduct?.id ?? altProductId,
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
source: 'inventory_substitute',
availableQuantity: altAvailable,
missingQuantity: Math.max(0, requiredQuantity - altAvailable),
};
}
}
return {
ingredientId: ingredient.id,
rawName,
quantity: requiredQuantity,
unit: requiredUnit,
note: ingredient.note ?? null,
status: 'missing' as AnalysisStatus,
matchedProductId: ingredient.productId,
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
source: null,
availableQuantity,
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
};
}),
);
const summary = {
exactCount: ingredients.filter((i) => i.status === 'exact_match').length,
pantryCount: ingredients.filter((i) => i.status === 'covered_by_pantry').length,
substituteCount: ingredients.filter((i) => i.status === 'substitutable').length,
missingCount: ingredients.filter((i) => i.status === 'missing').length,
};
const shoppingListCandidates = ingredients
.filter((i) => i.status === 'missing')
.map((i) => ({
ingredientId: i.ingredientId,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
missingQuantity: i.missingQuantity,
}));
return {
recipeId: recipe.id,
ingredients,
summary,
shoppingListCandidates,
};
}
}
+13 -1
View File
@@ -7,6 +7,7 @@ import { ParseMarkdownDto } from './dto/parse-markdown.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { ShareRecipeDto } from './dto/share-recipe.dto';
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
import { RecipeAnalysisService } from './recipe-analysis.service';
class UpdateImageDto {
@IsString()
@@ -15,7 +16,10 @@ class UpdateImageDto {
@Controller('recipes')
export class RecipesController {
constructor(private readonly recipesService: RecipesService) {}
constructor(
private readonly recipesService: RecipesService,
private readonly recipeAnalysisService: RecipeAnalysisService,
) {}
@Post('parse-markdown')
parseMarkdown(@Body() dto: ParseMarkdownDto) {
@@ -40,6 +44,14 @@ export class RecipesController {
return this.recipesService.getInventoryPreview(id, user.userId);
}
@Get(':id/analysis')
getRecipeAnalysis(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number },
) {
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
}
@Get(':id')
findOne(
@Param('id', ParseIntPipe) id: number,
+2 -1
View File
@@ -3,10 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { RecipesController } from './recipes.controller';
import { RecipesService } from './recipes.service';
import { RecipeAnalysisService } from './recipe-analysis.service';
@Module({
imports: [PrismaModule, AiModule],
controllers: [RecipesController],
providers: [RecipesService],
providers: [RecipesService, RecipeAnalysisService],
})
export class RecipesModule {}
+8 -2
View File
@@ -34,6 +34,12 @@ export class RecipesService {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
private normalizeIngredientName(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
}
private async assertProductsActive(productIds: number[]): Promise<void> {
if (productIds.length === 0) return;
const activeProducts = await this.prisma.product.findMany({
@@ -361,7 +367,7 @@ export class RecipesService {
ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawName: this.normalizeIngredientName(ingredient.rawName),
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
@@ -537,7 +543,7 @@ export class RecipesService {
ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawName: this.normalizeIngredientName(ingredient.rawName),
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
File diff suppressed because one or more lines are too long
+1
View File
@@ -45,6 +45,7 @@ class RecipeApiPaths {
static String share(int id) => '/recipes/$id/share';
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
static String analysis(int id) => '/recipes/$id/analysis';
static const parseMarkdown = '/recipes/parse-markdown';
static const aiSuggestions = '/recipes/ai-suggestions';
}
@@ -5,6 +5,7 @@ import '../../../core/api/guarded_api_call.dart';
import '../../../features/auth/data/auth_providers.dart';
import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import '../domain/recipe_analysis.dart';
import 'recipe_repository.dart';
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
@@ -38,3 +39,14 @@ final inventoryPreviewProvider =
.fetchInventoryPreview(id, token: token),
);
});
final recipeAnalysisProvider =
FutureProvider.family<RecipeAnalysis, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref
.read(recipeRepositoryProvider)
.fetchRecipeAnalysis(id, token: token),
);
});
@@ -4,6 +4,7 @@ import '../../../core/api/api_paths.dart';
import '../domain/parsed_recipe.dart';
import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import '../domain/recipe_analysis.dart';
class RecipeRepository {
final ApiClient _api;
@@ -174,6 +175,27 @@ class RecipeRepository {
}
}
Future<RecipeAnalysis> fetchRecipeAnalysis(int id,
{String? token}) async {
try {
final data = await _api.getJson(
RecipeApiPaths.analysis(id),
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
}
return RecipeAnalysis.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta receptanalys.');
}
}
Future<ParsedRecipe> parseMarkdown(String markdown,
{String? token}) async {
try {
@@ -0,0 +1,139 @@
enum RecipeIngredientAvailabilityStatus {
exactMatch,
coveredByPantry,
substitutable,
missing,
}
class RecipeIngredientAnalysis {
final int ingredientId;
final String rawName;
final double quantity;
final String unit;
final String? note;
final RecipeIngredientAvailabilityStatus status;
final int? matchedProductId;
final String? matchedProductName;
final String? source;
final double availableQuantity;
final double missingQuantity;
const RecipeIngredientAnalysis({
required this.ingredientId,
required this.rawName,
required this.quantity,
required this.unit,
this.note,
required this.status,
this.matchedProductId,
this.matchedProductName,
this.source,
required this.availableQuantity,
required this.missingQuantity,
});
factory RecipeIngredientAnalysis.fromJson(Map<String, dynamic> json) {
final rawStatus = json['status'] as String? ?? 'missing';
final status = switch (rawStatus) {
'exact_match' => RecipeIngredientAvailabilityStatus.exactMatch,
'covered_by_pantry' => RecipeIngredientAvailabilityStatus.coveredByPantry,
'substitutable' => RecipeIngredientAvailabilityStatus.substitutable,
_ => RecipeIngredientAvailabilityStatus.missing,
};
return RecipeIngredientAnalysis(
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
rawName: (json['rawName'] as String? ?? '').trim(),
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
note: json['note'] as String?,
status: status,
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
source: json['source'] as String?,
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
);
}
}
class RecipeAnalysisSummary {
final int exactCount;
final int pantryCount;
final int substituteCount;
final int missingCount;
const RecipeAnalysisSummary({
required this.exactCount,
required this.pantryCount,
required this.substituteCount,
required this.missingCount,
});
factory RecipeAnalysisSummary.fromJson(Map<String, dynamic> json) {
return RecipeAnalysisSummary(
exactCount: (json['exactCount'] as num?)?.toInt() ?? 0,
pantryCount: (json['pantryCount'] as num?)?.toInt() ?? 0,
substituteCount: (json['substituteCount'] as num?)?.toInt() ?? 0,
missingCount: (json['missingCount'] as num?)?.toInt() ?? 0,
);
}
}
class RecipeShoppingCandidate {
final int ingredientId;
final String rawName;
final double quantity;
final String unit;
final double missingQuantity;
const RecipeShoppingCandidate({
required this.ingredientId,
required this.rawName,
required this.quantity,
required this.unit,
required this.missingQuantity,
});
factory RecipeShoppingCandidate.fromJson(Map<String, dynamic> json) {
return RecipeShoppingCandidate(
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
rawName: (json['rawName'] as String? ?? '').trim(),
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
);
}
}
class RecipeAnalysis {
final int recipeId;
final List<RecipeIngredientAnalysis> ingredients;
final RecipeAnalysisSummary summary;
final List<RecipeShoppingCandidate> shoppingListCandidates;
const RecipeAnalysis({
required this.recipeId,
required this.ingredients,
required this.summary,
required this.shoppingListCandidates,
});
factory RecipeAnalysis.fromJson(Map<String, dynamic> json) {
final rawIngredients = json['ingredients'] as List<dynamic>? ?? const [];
final rawShopping = json['shoppingListCandidates'] as List<dynamic>? ?? const [];
return RecipeAnalysis(
recipeId: (json['recipeId'] as num?)?.toInt() ?? 0,
ingredients: rawIngredients
.map((e) => RecipeIngredientAnalysis.fromJson(e as Map<String, dynamic>))
.toList(),
summary: RecipeAnalysisSummary.fromJson(
json['summary'] as Map<String, dynamic>? ?? const {},
),
shoppingListCandidates: rawShopping
.map((e) => RecipeShoppingCandidate.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -4,8 +4,6 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/l10n/l10n.dart';
import '../../auth/data/auth_providers.dart';
@@ -14,19 +12,6 @@ import '../domain/parsed_recipe.dart';
enum _Step { input, review }
class _ManualIngredient {
int? productId;
final TextEditingController qtyCtrl = TextEditingController();
final TextEditingController unitCtrl = TextEditingController(text: 'g');
final TextEditingController noteCtrl = TextEditingController();
void dispose() {
qtyCtrl.dispose();
unitCtrl.dispose();
noteCtrl.dispose();
}
}
class CreateRecipeScreen extends ConsumerStatefulWidget {
/// Optional markdown to pre-fill the input field, e.g. from import.
final String? initialMarkdown;
@@ -64,20 +49,12 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
late TextEditingController _nameCtrl;
late TextEditingController _servingsCtrl;
late List<bool> _included;
late Map<int, int?> _selectedProductIds;
late Map<int, String?> _selectedProductNames;
late Map<int, TextEditingController> _rawNameControllers;
late Map<int, TextEditingController> _qtyControllers;
late Map<int, TextEditingController> _unitControllers;
late Map<int, TextEditingController> _noteControllers;
// Produktlista för manuellt tillagda ingredienser
List<Map<String, dynamic>> _allProducts = [];
bool _isLoadingProducts = false;
// Manuellt tillagda ingredienser
final List<_ManualIngredient> _manualIngredients = [];
bool _isSaving = false;
String? _saveError;
@@ -87,64 +64,37 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
if (_step == _Step.review) {
_nameCtrl.dispose();
_servingsCtrl.dispose();
for (final c in _rawNameControllers.values) c.dispose();
for (final c in _qtyControllers.values) c.dispose();
for (final c in _unitControllers.values) c.dispose();
for (final c in _noteControllers.values) c.dispose();
for (final m in _manualIngredients) m.dispose();
}
super.dispose();
}
Future<void> _loadProducts() async {
setState(() => _isLoadingProducts = true);
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.getJson(ProductApiPaths.list, token: token);
if (!mounted) return;
final products = (data as List<dynamic>)
.map((e) => e as Map<String, dynamic>)
.toList()
..sort((a, b) {
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString();
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
return aName.toLowerCase().compareTo(bName.toLowerCase());
});
setState(() {
_allProducts = products;
_isLoadingProducts = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _isLoadingProducts = false);
}
}
void _initReviewState(ParsedRecipe parsed) {
_nameCtrl = TextEditingController(text: parsed.name);
_servingsCtrl = TextEditingController();
_included = List.generate(parsed.ingredients.length, (_) => true);
_selectedProductIds = {};
_selectedProductNames = {};
_rawNameControllers = {};
_qtyControllers = {};
_unitControllers = {};
_noteControllers = {};
for (var i = 0; i < parsed.ingredients.length; i++) {
final ing = parsed.ingredients[i];
_rawNameControllers[i] = TextEditingController(text: ing.rawName);
_qtyControllers[i] = TextEditingController(
text: ing.quantity > 0 ? formatQuantity(ing.quantity) : '',
);
_unitControllers[i] = TextEditingController(text: ing.unit);
_noteControllers[i] = TextEditingController(text: ing.note ?? '');
if (ing.suggestions.isNotEmpty) {
_selectedProductIds[i] = ing.suggestions.first.productId;
_selectedProductNames[i] = ing.suggestions.first.productName;
} else {
_selectedProductIds[i] = null;
_selectedProductNames[i] = null;
}
}
_loadProducts();
}
String _formatIngredientName(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed;
return '${trimmed[0].toUpperCase()}${trimmed.substring(1)}';
}
Future<void> _parseMarkdown() async {
@@ -190,7 +140,10 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
final ingredients = <Map<String, dynamic>>[];
for (var i = 0; i < _parsed!.ingredients.length; i++) {
if (!_included[i]) continue;
final productId = _selectedProductIds[i];
final rawName = _formatIngredientName(_rawNameControllers[i]!.text);
if (rawName.isEmpty) {
continue;
}
final qty = double.tryParse(
_qtyControllers[i]!.text.trim().replaceAll(',', '.'),
) ??
@@ -198,40 +151,18 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
final unit = _unitControllers[i]!.text.trim();
final note = _noteControllers[i]!.text.trim();
final ing = _parsed!.ingredients[i];
// Alternativa produkter: alla suggestions vars productId matchar ett alternativ
final alternativeProductIds = ing.alternatives.length > 1
? ing.suggestions
.where((s) => s.productId != productId)
.map((s) => s.productId)
.toList()
: <int>[];
ingredients.add({
'rawName': ing.rawName,
'rawName': 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,
});
}
// Inkludera manuellt tillagda ingredienser
for (final manual in _manualIngredients) {
if (manual.productId == null) continue;
final qty = double.tryParse(
manual.qtyCtrl.text.trim().replaceAll(',', '.'),
);
if (qty == null) continue;
final unit = manual.unitCtrl.text.trim();
if (unit.isEmpty) continue;
final note = manual.noteCtrl.text.trim();
ingredients.add({
'productId': manual.productId,
'quantity': qty,
'unit': unit,
if (note.isNotEmpty) 'note': note,
});
if (ingredients.isEmpty) {
setState(() => _saveError = 'Lägg till minst en ingrediensrad.');
return;
}
setState(() {
@@ -370,27 +301,6 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
parsed.ingredients.length,
(i) => _buildIngredientRow(i, parsed.ingredients[i])),
],
// Manuellt tillagda ingredienser
if (_manualIngredients.isNotEmpty) ...[
const SizedBox(height: 8),
...List.generate(
_manualIngredients.length,
(i) => _buildManualIngredientCard(i),
),
],
const SizedBox(height: 12),
// Knapp för att lägga till ingrediens
if (_isLoadingProducts)
const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: LinearProgressIndicator(),
)
else
OutlinedButton.icon(
onPressed: _addManualIngredient,
icon: const Icon(Icons.add),
label: const Text('Lägg till ingrediens'),
),
const SizedBox(height: 8),
],
),
@@ -423,124 +333,11 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
);
}
void _addManualIngredient() {
setState(() {
_manualIngredients.add(_ManualIngredient());
});
}
void _removeManualIngredient(int index) {
setState(() {
_manualIngredients[index].dispose();
_manualIngredients.removeAt(index);
});
}
Widget _buildManualIngredientCard(int index) {
final manual = _manualIngredients[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Tillagd ingrediens',
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _removeManualIngredient(index),
tooltip: 'Ta bort',
),
],
),
DropdownButtonFormField<int>(
value: manual.productId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Produkt *',
border: OutlineInputBorder(),
isDense: true,
),
hint: const Text('Välj produkt'),
items: _allProducts
.map((p) => DropdownMenuItem<int>(
value: p['id'] as int,
child: Text(
((p['canonicalName'] ?? p['name']) as Object).toString(),
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => manual.productId = value);
},
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 72,
child: TextField(
controller: manual.qtyCtrl,
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: manual.unitCtrl,
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: manual.noteCtrl,
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
),
),
);
}
Widget _buildIngredientRow(int index, ParsedIngredient ing) {
final isIncluded = _included[index];
final noProductFound = ing.suggestions.isEmpty;
// Problem #2: tydlig varning om rad är inkluderad men saknar produkt
final showMissingProductWarning = isIncluded && noProductFound;
final suggestionText = ing.suggestions.isEmpty
? null
: 'Förslag: ${ing.suggestions.take(3).map((s) => s.productName).join(', ')}';
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
@@ -562,86 +359,78 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
))
.toList(),
)
: Text(ing.rawName),
subtitle: noProductFound
? Text(
context.l10n.recipeCreateNoProductFound,
: Text(_formatIngredientName(ing.rawName)),
subtitle: suggestionText == null
? null
: Text(
suggestionText,
style: TextStyle(
color: showMissingProductWarning
? Theme.of(context).colorScheme.error
: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
)
: DropdownButton<int>(
value: _selectedProductIds[index],
isExpanded: true,
onChanged: isIncluded
? (id) {
if (id == null) return;
setState(() {
_selectedProductIds[index] = id;
_selectedProductNames[index] = ing.suggestions
.firstWhere((s) => s.productId == id)
.productName;
});
}
: null,
items: ing.suggestions
.map((s) => DropdownMenuItem(
value: s.productId,
child: Text(s.productName),
))
.toList(),
),
),
// Problem #1: editerbara qty/unit/note-fält per ingrediens
if (isIncluded)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
child: Column(
children: [
SizedBox(
width: 72,
child: TextField(
controller: _qtyControllers[index],
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
TextField(
controller: _rawNameControllers[index],
decoration: const InputDecoration(
labelText: 'Ingrediens',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: _unitControllers[index],
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 72,
child: TextField(
controller: _qtyControllers[index],
decoration: const InputDecoration(
labelText: 'Mängd',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _noteControllers[index],
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
controller: _unitControllers[index],
decoration: const InputDecoration(
labelText: 'Enhet',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _noteControllers[index],
decoration: const InputDecoration(
labelText: 'Not',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
),
@@ -11,7 +11,7 @@ import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart';
import '../domain/recipe.dart';
import '../domain/inventory_preview.dart';
import '../domain/recipe_analysis.dart';
String _fmtQty(double v) => formatQuantity(v);
@@ -126,7 +126,7 @@ class RecipeDetailScreen extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
@@ -296,7 +296,7 @@ class _ImagePlaceholder extends StatelessWidget {
child: Icon(
Icons.restaurant,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4),
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
);
@@ -543,7 +543,7 @@ class _InventoryPreviewSectionState
tooltip: 'Uppdatera',
icon: const Icon(Icons.refresh),
onPressed: () {
ref.invalidate(inventoryPreviewProvider(widget.recipeId));
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
},
),
],
@@ -561,7 +561,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final previewAsync = ref.watch(inventoryPreviewProvider(recipeId));
final previewAsync = ref.watch(recipeAnalysisProvider(recipeId));
final theme = Theme.of(context);
return previewAsync.when(
@@ -587,6 +587,33 @@ class _InventoryPreviewResults extends ConsumerWidget {
...preview.ingredients.map(
(ing) => _IngredientPreviewRow(ingredient: ing),
),
if (preview.shoppingListCandidates.isNotEmpty) ...[
const SizedBox(height: 16),
Text('Shoppinglista', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...preview.shoppingListCandidates.map((item) {
final qty = _fmtQty(item.missingQuantity > 0 ? item.missingQuantity : item.quantity);
final measure = '$qty ${item.unit}'.trim();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.shopping_cart_outlined, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
measure.isEmpty
? item.rawName
: '$measure ${item.rawName}',
style: theme.textTheme.bodySmall,
),
),
],
),
);
}),
],
],
);
},
@@ -595,7 +622,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
}
class _SummaryChips extends StatelessWidget {
final PreviewSummary summary;
final RecipeAnalysisSummary summary;
const _SummaryChips({required this.summary});
@@ -607,7 +634,7 @@ class _SummaryChips extends StatelessWidget {
spacing: 8,
runSpacing: 4,
children: [
if (summary.canCookExactly)
if (summary.missingCount == 0)
Chip(
avatar: Icon(Icons.check_circle,
color: cs.onPrimary, size: 16),
@@ -624,21 +651,29 @@ class _SummaryChips extends StatelessWidget {
backgroundColor: cs.errorContainer,
labelStyle: TextStyle(color: cs.onErrorContainer),
),
if (summary.unitMismatchCount > 0)
if (summary.substituteCount > 0)
Chip(
avatar: Icon(Icons.swap_horiz,
color: cs.onTertiaryContainer, size: 16),
label: Text('${summary.unitMismatchCount} enhetsmismatch'),
label: Text('${summary.substituteCount} ersättningsbar'),
backgroundColor: cs.tertiaryContainer,
labelStyle: TextStyle(color: cs.onTertiaryContainer),
),
if (summary.pantryCount > 0)
Chip(
avatar: Icon(Icons.kitchen_outlined,
color: cs.onSecondaryContainer, size: 16),
label: Text('${summary.pantryCount} i skafferiet'),
backgroundColor: cs.secondaryContainer,
labelStyle: TextStyle(color: cs.onSecondaryContainer),
),
],
);
}
}
class _IngredientPreviewRow extends StatelessWidget {
final IngredientPreview ingredient;
final RecipeIngredientAnalysis ingredient;
const _IngredientPreviewRow({required this.ingredient});
@@ -646,37 +681,35 @@ class _IngredientPreviewRow extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final label = ingredient.productName.trim().isEmpty
? 'Okänd ingrediens'
: ingredient.productName;
final matchedName = ingredient.matchedProductName?.trim() ?? '';
final label = matchedName.isEmpty
? (ingredient.rawName.trim().isEmpty ? 'Okänd ingrediens' : ingredient.rawName)
: matchedName;
final (icon, color) = ingredient.fromPantry
? (Icons.kitchen_outlined, cs.secondary)
: switch (ingredient.status) {
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
IngredientStatus.unitMismatch => (
Icons.swap_horiz,
cs.tertiary,
),
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
};
final (icon, color) = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => (Icons.kitchen_outlined, cs.secondary),
RecipeIngredientAvailabilityStatus.exactMatch => (Icons.check_circle_outline, cs.primary),
RecipeIngredientAvailabilityStatus.substitutable => (Icons.swap_horiz, cs.tertiary),
RecipeIngredientAvailabilityStatus.missing => (Icons.cancel_outlined, cs.error),
};
final effectiveUnit = ingredient.unit;
final requiredStr =
'${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
'${_fmtQty(ingredient.quantity)} $effectiveUnit'.trim();
final availableStr =
'${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
'${_fmtQty(ingredient.availableQuantity)} $effectiveUnit'.trim();
final subtitle = ingredient.fromPantry
? 'Finns i skafferiet'
: switch (ingredient.status) {
IngredientStatus.enough => 'Tillgängligt: $availableStr',
IngredientStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
'(har $availableStr)'
: 'Saknas helt',
IngredientStatus.unitMismatch =>
'Annan enhet i lager kontrollera manuellt',
};
final subtitle = switch (ingredient.status) {
RecipeIngredientAvailabilityStatus.coveredByPantry => 'Finns i skafferiet',
RecipeIngredientAvailabilityStatus.exactMatch => 'Tillgängligt: $availableStr',
RecipeIngredientAvailabilityStatus.substitutable =>
ingredient.matchedProductName == null || ingredient.matchedProductName!.trim().isEmpty
? 'Kan ersättas med annan vara'
: 'Kan ersättas med ${ingredient.matchedProductName}',
RecipeIngredientAvailabilityStatus.missing => ingredient.availableQuantity > 0
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} $effectiveUnit (har $availableStr)'
: 'Saknas helt',
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -152,7 +152,7 @@ class _RecipeImageCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.45),
color: Colors.black.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(10),
),
child: Text(