diff --git a/backend/src/recipes/dto/create-ingredient.dto.ts b/backend/src/recipes/dto/create-ingredient.dto.ts new file mode 100644 index 00000000..cda42b27 --- /dev/null +++ b/backend/src/recipes/dto/create-ingredient.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsNumber, IsOptional } from 'class-validator'; + +export class CreateIngredientDto { + @IsNumber() + productId: number; + + @IsNumber() + quantity: number; + + @IsString() + unit: string; + + @IsOptional() + @IsString() + note?: string; +} \ No newline at end of file diff --git a/backend/src/recipes/recipes.controller.ts b/backend/src/recipes/recipes.controller.ts index c648e496..26999cc5 100644 --- a/backend/src/recipes/recipes.controller.ts +++ b/backend/src/recipes/recipes.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseIntPipe, Post, Pat import { IsString } from 'class-validator'; import { RecipesService } from './recipes.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; +import { CreateIngredientDto } from './dto/create-ingredient.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { ShareRecipeDto } from './dto/share-recipe.dto'; @@ -77,6 +78,15 @@ export class RecipesController { return this.recipesService.updateImage(id, dto.sourceUrl, user.userId); } + @Post(':id/ingredients') + async addIngredient( + @Param('id', ParseIntPipe) id: number, + @Body() ingredient: CreateIngredientDto, + @CurrentUser() user: { userId: number }, + ) { + return this.recipesService.addIngredient(id, ingredient, user.userId); + } + @Patch(':id/visibility') async setVisibility( @Param('id', ParseIntPipe) id: number, diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index a3b43e2d..0b0b9409 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { CreateRecipeDto } from './dto/create-recipe.dto'; +import { CreateIngredientDto } from './dto/create-ingredient.dto'; import { ParseMarkdownDto } from './dto/parse-markdown.dto'; import { downloadAndOptimizeImage } from '../common/utils/download-image'; import { parseRecipeMarkdown, ParsedRecipe, ParsedIngredient } from '../common/utils/recipe-parser'; @@ -475,6 +476,25 @@ export class RecipesService { } } + async addIngredient(id: number, ingredient: CreateIngredientDto, userId: number) { + const recipe = await this.findRecipeByIdOrThrow(id); + await this.assertRecipeOwnedByUser(recipe, userId, id); + await this.assertProductsActive([ingredient.productId]); + + return this.prisma.recipeIngredient.create({ + data: { + productId: ingredient.productId, + quantity: ingredient.quantity, + unit: ingredient.unit, + note: ingredient.note || null, + recipeId: id, + }, + include: { + product: { include: { nutrition: true } }, + }, + }); + } + async parseMarkdown(dto: ParseMarkdownDto) { // Delegera markdown-parsning till microservice-importer const importerUrl = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart index 89556c09..07f62c1c 100644 --- a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -4,6 +4,8 @@ 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'; @@ -12,6 +14,19 @@ 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; @@ -56,6 +71,13 @@ class _CreateRecipeScreenState extends ConsumerState { late Map _unitControllers; late Map _noteControllers; + // Produktlista för manuellt tillagda ingredienser + List> _allProducts = []; + bool _isLoadingProducts = false; + + // Manuellt tillagda ingredienser + final List<_ManualIngredient> _manualIngredients = []; + bool _isSaving = false; String? _saveError; @@ -68,10 +90,36 @@ class _CreateRecipeScreenState extends ConsumerState { 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 _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) + .map((e) => e as Map) + .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(); @@ -96,6 +144,7 @@ class _CreateRecipeScreenState extends ConsumerState { _selectedProductNames[i] = null; } } + _loadProducts(); } Future _parseMarkdown() async { @@ -166,6 +215,23 @@ class _CreateRecipeScreenState extends ConsumerState { '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, + }); + } setState(() { _isSaving = true; @@ -303,6 +369,27 @@ class _CreateRecipeScreenState extends ConsumerState { 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), ], ), @@ -335,6 +422,119 @@ class _CreateRecipeScreenState extends ConsumerState { ); } + 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( + 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( + 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;