From ed4e18dc31542febafcdf08c02cfd7e006a1c47f Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 07:53:25 +0200 Subject: [PATCH] feat: add recipe creation, editing, and detail screens; enhance recipe model with instructions and ingredients --- flutter/lib/core/router/app_router.dart | 38 +- .../recipes/data/recipe_providers.dart | 9 + .../recipes/data/recipe_repository.dart | 93 ++++- .../recipes/domain/parsed_recipe.dart | 73 ++++ .../lib/features/recipes/domain/recipe.dart | 11 + .../recipes/domain/recipe_ingredient.dart | 32 ++ .../presentation/create_recipe_screen.dart | 335 ++++++++++++++++++ .../presentation/recipe_detail_screen.dart | 190 ++++++++++ .../presentation/recipe_edit_screen.dart | 204 +++++++++++ .../recipes/presentation/recipes_screen.dart | 76 ++-- 10 files changed, 1017 insertions(+), 44 deletions(-) create mode 100644 flutter/lib/features/recipes/domain/parsed_recipe.dart create mode 100644 flutter/lib/features/recipes/domain/recipe_ingredient.dart create mode 100644 flutter/lib/features/recipes/presentation/create_recipe_screen.dart create mode 100644 flutter/lib/features/recipes/presentation/recipe_detail_screen.dart create mode 100644 flutter/lib/features/recipes/presentation/recipe_edit_screen.dart diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 5542d557..cc37177e 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -7,6 +7,9 @@ import '../ui/async_state_views.dart'; import '../../features/auth/data/auth_providers.dart'; import '../../features/auth/presentation/login_screen.dart'; import '../../features/profile/presentation/profile_screen.dart'; +import '../../features/recipes/presentation/create_recipe_screen.dart'; +import '../../features/recipes/presentation/recipe_detail_screen.dart'; +import '../../features/recipes/presentation/recipe_edit_screen.dart'; import '../../features/recipes/presentation/recipes_screen.dart'; final appRouterProvider = Provider((ref) { @@ -22,22 +25,18 @@ final appRouterProvider = Provider((ref) { final isSplash = location == '/'; final isLogin = location == '/login'; - // Keep user on splash while auth state is being resolved from storage. if (isLoading) { return isSplash ? null : '/'; } - // Redirect away from splash once auth is known. if (isSplash) { return isLoggedIn ? '/recipes' : '/login'; } - // Unauthenticated user trying to reach a protected route. if (!isLoggedIn && !isLogin) { return '/login'; } - // Authenticated user landing on login. if (isLoggedIn && isLogin) { return '/recipes'; } @@ -55,6 +54,37 @@ final appRouterProvider = Provider((ref) { path: '/login', builder: (context, state) => const LoginScreen(), ), + // Detail routes — outside ShellRoute to get full-screen with back button. + // /recipes/create must be listed before /recipes/:id to avoid conflict. + GoRoute( + path: '/recipes/create', + builder: (context, state) => const CreateRecipeScreen(), + ), + GoRoute( + path: '/recipes/:id', + redirect: (context, state) { + final raw = state.pathParameters['id'] ?? ''; + if (int.tryParse(raw) == null) return '/recipes'; + return null; + }, + builder: (context, state) { + final id = int.parse(state.pathParameters['id']!); + return RecipeDetailScreen(recipeId: id); + }, + ), + GoRoute( + path: '/recipes/:id/edit', + redirect: (context, state) { + final raw = state.pathParameters['id'] ?? ''; + if (int.tryParse(raw) == null) return '/recipes'; + return null; + }, + builder: (context, state) { + final id = int.parse(state.pathParameters['id']!); + return RecipeEditScreen(recipeId: id); + }, + ), + // Shell routes — shared AppShell with navigation bar. ShellRoute( builder: (context, state, child) { return AppShell(location: state.uri.path, child: child); diff --git a/flutter/lib/features/recipes/data/recipe_providers.dart b/flutter/lib/features/recipes/data/recipe_providers.dart index 10339d5c..42f2de70 100644 --- a/flutter/lib/features/recipes/data/recipe_providers.dart +++ b/flutter/lib/features/recipes/data/recipe_providers.dart @@ -17,3 +17,12 @@ final recipesProvider = FutureProvider>((ref) async { () => ref.read(recipeRepositoryProvider).fetchRecipes(token: token), ); }); + +final recipeDetailProvider = + FutureProvider.family((ref, id) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref.read(recipeRepositoryProvider).fetchRecipeDetail(id, token: token), + ); +}); diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index 7e8d5885..8b086406 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -1,5 +1,6 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_exception.dart'; +import '../domain/parsed_recipe.dart'; import '../domain/recipe.dart'; class RecipeRepository { @@ -10,14 +11,10 @@ class RecipeRepository { Future> fetchRecipes({String? token}) async { try { final data = await _api.getJson('/recipes', token: token); - if (data is! List) { throw const ApiException( - type: ApiErrorType.unknown, - message: 'Ogiltigt svar fran servern.', - ); + type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); } - return data .map((e) => Recipe.fromJson(e as Map)) .toList(); @@ -25,9 +22,91 @@ class RecipeRepository { rethrow; } catch (_) { throw const ApiException( - type: ApiErrorType.network, - message: 'Kunde inte hamta recept.', + type: ApiErrorType.network, message: 'Kunde inte hamta recept.'); + } + } + + Future fetchRecipeDetail(int id, {String? token}) async { + try { + final data = await _api.getJson('/recipes/$id', token: token); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); + } + return Recipe.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, message: 'Kunde inte hamta recept.'); + } + } + + Future createRecipe(Map body, + {String? token}) async { + try { + final data = + await _api.postJson('/recipes', body: body, token: token); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); + } + return Recipe.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, message: 'Kunde inte spara recept.'); + } + } + + Future updateRecipe(int id, Map body, + {String? token}) async { + try { + final data = + await _api.putJson('/recipes/$id', body: body, token: token); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); + } + return Recipe.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, message: 'Kunde inte uppdatera recept.'); + } + } + + Future deleteRecipe(int id, {String? token}) async { + try { + await _api.deleteJson('/recipes/$id', token: token); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, message: 'Kunde inte ta bort recept.'); + } + } + + Future parseMarkdown(String markdown, + {String? token}) async { + try { + final data = await _api.postJson( + '/recipes/parse-markdown', + body: {'markdown': markdown}, + token: token, ); + if (data is! Map) { + throw const ApiException( + type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); + } + return ParsedRecipe.fromJson(data); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException( + type: ApiErrorType.network, message: 'Kunde inte tolka receptet.'); } } } diff --git a/flutter/lib/features/recipes/domain/parsed_recipe.dart b/flutter/lib/features/recipes/domain/parsed_recipe.dart new file mode 100644 index 00000000..8752fae9 --- /dev/null +++ b/flutter/lib/features/recipes/domain/parsed_recipe.dart @@ -0,0 +1,73 @@ +class IngredientSuggestion { + final int productId; + final String productName; + final double score; + + const IngredientSuggestion({ + required this.productId, + required this.productName, + required this.score, + }); + + factory IngredientSuggestion.fromJson(Map json) => + IngredientSuggestion( + productId: (json['productId'] as num).toInt(), + productName: json['productName'] as String? ?? '', + score: (json['score'] as num).toDouble(), + ); +} + +class ParsedIngredient { + final String rawName; + final double quantity; + final String unit; + final String? note; + final List suggestions; + + const ParsedIngredient({ + required this.rawName, + required this.quantity, + required this.unit, + this.note, + required this.suggestions, + }); + + factory ParsedIngredient.fromJson(Map json) { + final rawSuggestions = json['suggestions'] as List? ?? []; + return ParsedIngredient( + rawName: json['rawName'] as String? ?? '', + quantity: (json['quantity'] as num? ?? 0).toDouble(), + unit: json['unit'] as String? ?? '', + note: json['note'] as String?, + suggestions: rawSuggestions + .map((s) => IngredientSuggestion.fromJson(s as Map)) + .toList(), + ); + } +} + +class ParsedRecipe { + final String name; + final String? description; + final String? instructions; + final List ingredients; + + const ParsedRecipe({ + required this.name, + this.description, + this.instructions, + required this.ingredients, + }); + + factory ParsedRecipe.fromJson(Map json) { + final rawIngredients = json['ingredients'] as List? ?? []; + return ParsedRecipe( + name: json['name'] as String? ?? '', + description: json['description'] as String?, + instructions: json['instructions'] as String?, + ingredients: rawIngredients + .map((i) => ParsedIngredient.fromJson(i as Map)) + .toList(), + ); + } +} diff --git a/flutter/lib/features/recipes/domain/recipe.dart b/flutter/lib/features/recipes/domain/recipe.dart index cef753af..cb6ff630 100644 --- a/flutter/lib/features/recipes/domain/recipe.dart +++ b/flutter/lib/features/recipes/domain/recipe.dart @@ -1,9 +1,13 @@ +import 'recipe_ingredient.dart'; + class Recipe { final int id; final String title; final String? description; final String? imageUrl; final int? servings; + final String? instructions; + final List ingredients; const Recipe({ required this.id, @@ -11,6 +15,8 @@ class Recipe { this.description, this.imageUrl, this.servings, + this.instructions, + this.ingredients = const [], }); factory Recipe.fromJson(Map json) { @@ -19,6 +25,7 @@ class Recipe { final dynamic rawDescription = json['description']; final dynamic rawImageUrl = json['imageUrl']; final dynamic rawServings = json['servings']; + final rawIngredients = json['ingredients'] as List? ?? []; return Recipe( id: rawId is num ? rawId.toInt() : int.parse(rawId.toString()), @@ -30,6 +37,10 @@ class Recipe { : (rawServings is num ? rawServings.toInt() : int.tryParse(rawServings.toString())), + instructions: json['instructions'] as String?, + ingredients: rawIngredients + .map((i) => RecipeIngredient.fromJson(i as Map)) + .toList(), ); } } diff --git a/flutter/lib/features/recipes/domain/recipe_ingredient.dart b/flutter/lib/features/recipes/domain/recipe_ingredient.dart new file mode 100644 index 00000000..9e6cd008 --- /dev/null +++ b/flutter/lib/features/recipes/domain/recipe_ingredient.dart @@ -0,0 +1,32 @@ +class RecipeIngredient { + final int id; + final int productId; + final String productName; + final double quantity; + final String unit; + final String? note; + + const RecipeIngredient({ + required this.id, + required this.productId, + required this.productName, + required this.quantity, + required this.unit, + this.note, + }); + + factory RecipeIngredient.fromJson(Map json) { + final product = json['product'] as Map?; + final rawQty = json['quantity']; + return RecipeIngredient( + id: (json['id'] as num).toInt(), + productId: (json['productId'] as num).toInt(), + productName: product?['name'] as String? ?? '', + quantity: rawQty is num + ? rawQty.toDouble() + : double.tryParse(rawQty?.toString() ?? '') ?? 0, + unit: json['unit'] as String? ?? '', + note: json['note'] as String?, + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart new file mode 100644 index 00000000..f32a5e3d --- /dev/null +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_exception.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/recipe_providers.dart'; +import '../domain/parsed_recipe.dart'; + +enum _Step { input, review } + +class CreateRecipeScreen extends ConsumerStatefulWidget { + const CreateRecipeScreen({super.key}); + + @override + ConsumerState createState() => + _CreateRecipeScreenState(); +} + +class _CreateRecipeScreenState extends ConsumerState { + _Step _step = _Step.input; + + // Step 1 — markdown input + final _markdownCtrl = TextEditingController(); + bool _isParsing = false; + String? _parseError; + + // Step 2 — review state + ParsedRecipe? _parsed; + late TextEditingController _nameCtrl; + late TextEditingController _servingsCtrl; + late List _included; + late Map _selectedProductIds; + late Map _selectedProductNames; + + bool _isSaving = false; + String? _saveError; + + @override + void dispose() { + _markdownCtrl.dispose(); + if (_step == _Step.review) { + _nameCtrl.dispose(); + _servingsCtrl.dispose(); + } + super.dispose(); + } + + void _initReviewState(ParsedRecipe parsed) { + _nameCtrl = TextEditingController(text: parsed.name); + _servingsCtrl = TextEditingController(); + _included = List.generate(parsed.ingredients.length, (_) => true); + _selectedProductIds = {}; + _selectedProductNames = {}; + for (var i = 0; i < parsed.ingredients.length; i++) { + final suggestions = parsed.ingredients[i].suggestions; + if (suggestions.isNotEmpty) { + _selectedProductIds[i] = suggestions.first.productId; + _selectedProductNames[i] = suggestions.first.productName; + } else { + _selectedProductIds[i] = null; + _selectedProductNames[i] = null; + } + } + } + + Future _parseMarkdown() async { + final markdown = _markdownCtrl.text.trim(); + if (markdown.isEmpty) { + setState(() => _parseError = 'Klistra in eller skriv ett recept i Markdown-format.'); + return; + } + setState(() { + _isParsing = true; + _parseError = null; + }); + try { + final token = await ref.read(authStateProvider.future); + final parsed = await ref + .read(recipeRepositoryProvider) + .parseMarkdown(markdown, token: token); + _initReviewState(parsed); + setState(() { + _parsed = parsed; + _step = _Step.review; + _isParsing = false; + }); + } on ApiException catch (e) { + if (e.type == ApiErrorType.unauthorized) { + await ref.read(authStateProvider.notifier).logout(); + return; + } + setState(() { + _parseError = mapErrorToUserMessage(e); + _isParsing = false; + }); + } + } + + Future _save() async { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) { + setState(() => _saveError = 'Receptnamnet får inte vara tomt.'); + return; + } + + final ingredients = >[]; + for (var i = 0; i < _parsed!.ingredients.length; i++) { + if (!_included[i]) continue; + final productId = _selectedProductIds[i]; + if (productId == null) continue; + final ing = _parsed!.ingredients[i]; + ingredients.add({ + 'productId': productId, + 'quantity': ing.quantity, + 'unit': ing.unit, + if (ing.note != null) 'note': ing.note, + }); + } + + setState(() { + _isSaving = true; + _saveError = null; + }); + + try { + final token = await ref.read(authStateProvider.future); + final servingsRaw = int.tryParse(_servingsCtrl.text.trim()); + final created = await ref.read(recipeRepositoryProvider).createRecipe( + { + 'name': name, + if (_parsed!.description != null) 'description': _parsed!.description, + if (servingsRaw != null) 'servings': servingsRaw, + if (_parsed!.instructions != null) + 'instructions': _parsed!.instructions, + 'ingredients': ingredients, + }, + token: token, + ); + ref.invalidate(recipesProvider); + if (mounted) context.go('/recipes/${created.id}'); + } on ApiException catch (e) { + if (e.type == ApiErrorType.unauthorized) { + await ref.read(authStateProvider.notifier).logout(); + return; + } + setState(() { + _saveError = mapErrorToUserMessage(e); + _isSaving = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: + Text(_step == _Step.input ? 'Nytt recept' : 'Granska ingredienser'), + leading: _step == _Step.review + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => setState(() { + _step = _Step.input; + _nameCtrl.dispose(); + _servingsCtrl.dispose(); + }), + ) + : null, + ), + body: _step == _Step.input ? _buildInputStep() : _buildReviewStep(), + ); + } + + Widget _buildInputStep() { + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: TextField( + controller: _markdownCtrl, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + hintText: '# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n- 1 st lök\n\n## Tillvägagångssätt\nStek löken...', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ), + ), + if (_parseError != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _parseError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isParsing ? null : _parseMarkdown, + child: _isParsing + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Granska ingredienser'), + ), + ), + ), + ], + ); + } + + Widget _buildReviewStep() { + final parsed = _parsed!; + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Receptnamn'), + ), + const SizedBox(height: 12), + TextField( + controller: _servingsCtrl, + decoration: const InputDecoration( + labelText: 'Antal portioner (valfritt)'), + keyboardType: TextInputType.number, + ), + if (parsed.ingredients.isNotEmpty) ...[ + const SizedBox(height: 20), + Text('Ingredienser', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Bocka av ingredienser att inkludera och välj rätt produkt.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + ...List.generate( + parsed.ingredients.length, + (i) => _buildIngredientRow(i, parsed.ingredients[i])), + ], + const SizedBox(height: 8), + ], + ), + ), + if (_saveError != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _saveError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Spara recept'), + ), + ), + ), + ], + ); + } + + Widget _buildIngredientRow(int index, ParsedIngredient ing) { + final qtyStr = ing.quantity > 0 + ? (ing.quantity == ing.quantity.truncateToDouble() + ? '${ing.quantity.toInt()} ' + : '${ing.quantity} ') + : ''; + final unitStr = ing.unit.isNotEmpty ? '${ing.unit} ' : ''; + final noteStr = ing.note != null ? ' (${ing.note})' : ''; + final label = '$qtyStr$unitStr${ing.rawName}$noteStr'; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: CheckboxListTile( + value: _included[index], + onChanged: (v) => setState(() => _included[index] = v ?? false), + title: Text(label), + subtitle: ing.suggestions.isEmpty + ? Text( + 'Ingen produkt hittades — ingrediensen hoppas över.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12), + ) + : DropdownButton( + value: _selectedProductIds[index], + isExpanded: true, + onChanged: _included[index] + ? (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(), + ), + ), + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart new file mode 100644 index 00000000..b8530f35 --- /dev/null +++ b/flutter/lib/features/recipes/presentation/recipe_detail_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/recipe_providers.dart'; +import '../domain/recipe.dart'; + +class RecipeDetailScreen extends ConsumerWidget { + final int recipeId; + + const RecipeDetailScreen({super.key, required this.recipeId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recipeAsync = ref.watch(recipeDetailProvider(recipeId)); + + return Scaffold( + appBar: AppBar( + title: Text(recipeAsync.valueOrNull?.title ?? 'Recept'), + actions: recipeAsync.valueOrNull == null + ? [] + : [ + IconButton( + tooltip: 'Redigera', + icon: const Icon(Icons.edit_outlined), + onPressed: () => + context.push('/recipes/$recipeId/edit'), + ), + _DeleteButton(recipe: recipeAsync.value!), + ], + ), + body: recipeAsync.when( + loading: () => const LoadingStateView(label: 'Laddar recept...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error), + onRetry: () => ref.invalidate(recipeDetailProvider(recipeId)), + ), + data: (recipe) => _RecipeBody(recipe: recipe), + ), + ); + } +} + +class _DeleteButton extends ConsumerWidget { + final Recipe recipe; + + const _DeleteButton({required this.recipe}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + tooltip: 'Ta bort', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(context, ref), + ); + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Ta bort recept?'), + content: Text( + 'Vill du ta bort "${recipe.title}"? Åtgärden kan inte ångras.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Avbryt'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error), + onPressed: () => Navigator.pop(context, true), + child: const Text('Ta bort'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + final token = await ref.read(authStateProvider.future); + await ref.read(recipeRepositoryProvider).deleteRecipe(recipe.id, + token: token); + ref.invalidate(recipesProvider); + if (context.mounted) context.go('/recipes'); + } on ApiException catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e))), + ); + } + } +} + +class _RecipeBody extends StatelessWidget { + final Recipe recipe; + + const _RecipeBody({required this.recipe}); + + String _formatQty(double qty) { + if (qty == 0) return ''; + return qty == qty.truncateToDouble() + ? qty.toInt().toString() + : qty.toString(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recipe.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Image.network( + recipe.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + if (recipe.imageUrl != null) const SizedBox(height: 20), + Text(recipe.title, style: theme.textTheme.headlineSmall), + if (recipe.description != null) ...[ + const SizedBox(height: 8), + Text(recipe.description!, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + if (recipe.servings != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.people_outline, size: 16), + const SizedBox(width: 4), + Text('${recipe.servings} portioner', + style: theme.textTheme.bodySmall), + ], + ), + ], + if (recipe.ingredients.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('Ingredienser', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + ...recipe.ingredients.map((ing) { + final qtyStr = _formatQty(ing.quantity); + final parts = [ + if (qtyStr.isNotEmpty) qtyStr, + if (ing.unit.isNotEmpty) ing.unit, + ing.productName, + if (ing.note != null) '(${ing.note})', + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• '), + Expanded(child: Text(parts.join(' '))), + ], + ), + ); + }), + ], + if (recipe.instructions != null && + recipe.instructions!.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('Tillvägagångssätt', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(recipe.instructions!, + style: theme.textTheme.bodyMedium + ?.copyWith(height: 1.6)), + ], + const SizedBox(height: 40), + ], + ), + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart new file mode 100644 index 00000000..1ed0c7ba --- /dev/null +++ b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/recipe_providers.dart'; +import '../domain/recipe.dart'; + +class RecipeEditScreen extends ConsumerStatefulWidget { + final int recipeId; + + const RecipeEditScreen({super.key, required this.recipeId}); + + @override + ConsumerState createState() => _RecipeEditScreenState(); +} + +class _RecipeEditScreenState extends ConsumerState { + final _formKey = GlobalKey(); + bool _initialized = false; + + late TextEditingController _nameCtrl; + late TextEditingController _descCtrl; + late TextEditingController _servingsCtrl; + late TextEditingController _instructionsCtrl; + + bool _isSaving = false; + String? _saveError; + + @override + void dispose() { + if (_initialized) { + _nameCtrl.dispose(); + _descCtrl.dispose(); + _servingsCtrl.dispose(); + _instructionsCtrl.dispose(); + } + super.dispose(); + } + + void _initControllers(Recipe recipe) { + _nameCtrl = TextEditingController(text: recipe.title); + _descCtrl = TextEditingController(text: recipe.description ?? ''); + _servingsCtrl = + TextEditingController(text: recipe.servings?.toString() ?? ''); + _instructionsCtrl = + TextEditingController(text: recipe.instructions ?? ''); + _initialized = true; + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() { + _isSaving = true; + _saveError = null; + }); + + try { + final token = await ref.read(authStateProvider.future); + final servings = int.tryParse(_servingsCtrl.text.trim()); + await ref.read(recipeRepositoryProvider).updateRecipe( + widget.recipeId, + { + 'name': _nameCtrl.text.trim(), + 'description': _descCtrl.text.trim().isEmpty + ? null + : _descCtrl.text.trim(), + if (servings != null) 'servings': servings, + 'instructions': _instructionsCtrl.text.trim().isEmpty + ? null + : _instructionsCtrl.text.trim(), + }, + token: token, + ); + ref.invalidate(recipeDetailProvider(widget.recipeId)); + ref.invalidate(recipesProvider); + if (mounted) context.pop(); + } on ApiException catch (e) { + if (e.type == ApiErrorType.unauthorized) { + await ref.read(authStateProvider.notifier).logout(); + return; + } + setState(() { + _saveError = mapErrorToUserMessage(e); + _isSaving = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final recipeAsync = ref.watch(recipeDetailProvider(widget.recipeId)); + + return Scaffold( + appBar: AppBar( + title: const Text('Redigera recept'), + actions: [ + if (_initialized) + TextButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Spara'), + ), + ], + ), + body: recipeAsync.when( + loading: () => const LoadingStateView(label: 'Laddar recept...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error), + onRetry: () => ref.invalidate(recipeDetailProvider(widget.recipeId)), + ), + data: (recipe) { + if (!_initialized) _initControllers(recipe); + return _buildForm(context); + }, + ), + ); + } + + Widget _buildForm(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Receptnamn'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Ange ett receptnamn.' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _descCtrl, + decoration: + const InputDecoration(labelText: 'Beskrivning (valfritt)'), + maxLines: 3, + ), + const SizedBox(height: 16), + TextFormField( + controller: _servingsCtrl, + decoration: + const InputDecoration(labelText: 'Antal portioner (valfritt)'), + keyboardType: TextInputType.number, + validator: (v) { + if (v == null || v.trim().isEmpty) return null; + if (int.tryParse(v.trim()) == null) { + return 'Ange ett heltal.'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _instructionsCtrl, + decoration: + const InputDecoration(labelText: 'Tillvägagångssätt (valfritt)'), + maxLines: 10, + textAlignVertical: TextAlignVertical.top, + ), + const SizedBox(height: 8), + Text( + 'Ingredienser redigeras via Skapa recept-flödet.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + if (_saveError != null) ...[ + const SizedBox(height: 16), + Text( + _saveError!, + style: + TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Spara ändringar'), + ), + const SizedBox(height: 40), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index dbcf4f0b..ca3c1cae 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/ui/async_state_views.dart'; @@ -11,40 +12,49 @@ class RecipesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final recipesAsync = ref.watch(recipesProvider); - return recipesAsync.when( - loading: () => const LoadingStateView(label: 'Laddar recept...'), - error: (error, _) => ErrorStateView( - message: mapErrorToUserMessage(error), - onRetry: () => ref.invalidate(recipesProvider), - ), - data: (recipes) { - if (recipes.isEmpty) { - return const EmptyStateView( - title: 'Inga recept hittades', - description: 'Lagg till ett recept for att komma igang.', - ); - } - - return ListView.builder( - itemCount: recipes.length, - itemBuilder: (context, index) { - final recipe = recipes[index]; - return ListTile( - leading: recipe.imageUrl != null - ? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover) - : const Icon(Icons.restaurant), - title: Text(recipe.title), - subtitle: recipe.description != null - ? Text( - recipe.description!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, + return Scaffold( + body: recipesAsync.when( + loading: () => const LoadingStateView(label: 'Laddar recept...'), + error: (error, _) => ErrorStateView( + message: mapErrorToUserMessage(error), + onRetry: () => ref.invalidate(recipesProvider), + ), + data: (recipes) { + if (recipes.isEmpty) { + return const EmptyStateView( + title: 'Inga recept hittades', + description: 'Lägg till ett recept för att komma igång.', ); - }, - ); - }, + } + + return ListView.builder( + itemCount: recipes.length, + itemBuilder: (context, index) { + final recipe = recipes[index]; + return ListTile( + leading: recipe.imageUrl != null + ? Image.network(recipe.imageUrl!, width: 56, fit: BoxFit.cover) + : const Icon(Icons.restaurant), + title: Text(recipe.title), + subtitle: recipe.description != null + ? Text( + recipe.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/recipes/${recipe.id}'), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + tooltip: 'Nytt recept', + onPressed: () => context.push('/recipes/create'), + child: const Icon(Icons.add), + ), ); } }