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/utils/formatters.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/recipe_providers.dart'; import '../domain/recipe.dart'; import '../domain/recipe_ingredient.dart'; class _EditableIngredient { int? productId; String? productName; String rawName; String? rawLine; final TextEditingController quantityCtrl; String unit; final TextEditingController noteCtrl; _EditableIngredient({ required this.productId, this.productName, required this.rawName, this.rawLine, required String quantity, required this.unit, String note = '', }) : quantityCtrl = TextEditingController(text: quantity), noteCtrl = TextEditingController(text: note); factory _EditableIngredient.fromRecipe(RecipeIngredient ingredient) { return _EditableIngredient( productId: ingredient.productId, productName: ingredient.productName, rawName: ingredient.rawName.trim().isNotEmpty ? ingredient.rawName : (ingredient.productName ?? ''), rawLine: ingredient.rawLine, quantity: formatQuantity(ingredient.quantity), unit: ingredient.unit, note: ingredient.note ?? '', ); } void dispose() { quantityCtrl.dispose(); noteCtrl.dispose(); } } 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; final List<_EditableIngredient> _ingredients = []; List> _allProducts = []; bool _isLoadingProducts = false; bool _isSaving = false; String? _saveError; @override void initState() { super.initState(); _loadProducts(); } @override void dispose() { if (_initialized) { _nameCtrl.dispose(); _descCtrl.dispose(); _servingsCtrl.dispose(); _instructionsCtrl.dispose(); for (final ingredient in _ingredients) { ingredient.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 ?? ''); _ingredients ..clear() ..addAll(recipe.ingredients.map(_EditableIngredient.fromRecipe)); _initialized = true; } 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 _addIngredient() { setState(() { _ingredients.add( _EditableIngredient( productId: null, productName: '', rawName: '', quantity: '', unit: 'st', ), ); }); } void _removeIngredient(int index) { setState(() { final ingredient = _ingredients.removeAt(index); ingredient.dispose(); }); } String? _validateIngredients() { if (_ingredients.isEmpty) { return context.l10n.recipeEditMinIngredients; } for (final ingredient in _ingredients) { if (ingredient.productId == null && ingredient.rawName.trim().isEmpty) { return 'Ange ingrediensnamn eller välj produkt'; } final qtyText = ingredient.quantityCtrl.text.trim(); final quantity = qtyText.isEmpty ? null : double.tryParse(qtyText.replaceAll(',', '.')); if (qtyText.isNotEmpty && (quantity == null || quantity < 0)) { return context.l10n.recipeEditValidQuantity; } if (qtyText.isNotEmpty && ingredient.unit.trim().isEmpty) { return context.l10n.recipeEditSelectUnit; } } return null; } Future _save() async { if (!(_formKey.currentState?.validate() ?? false)) return; final ingredientError = _validateIngredients(); if (ingredientError != null) { setState(() => _saveError = ingredientError); return; } setState(() { _isSaving = true; _saveError = null; }); try { final token = await ref.read(authStateProvider.future); final servings = int.tryParse(_servingsCtrl.text.trim()); final ingredients = _ingredients .map( (ingredient) { final parsedQty = double.tryParse( ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), ); return { 'rawName': ingredient.rawName.trim(), if ((ingredient.rawLine ?? '').trim().isNotEmpty) 'rawLine': ingredient.rawLine, if (ingredient.productId != null) 'productId': ingredient.productId, if (parsedQty != null) 'quantity': parsedQty, if (ingredient.unit.trim().isNotEmpty) 'unit': ingredient.unit, if (ingredient.noteCtrl.text.trim().isNotEmpty) 'note': ingredient.noteCtrl.text.trim(), }; }, ) .toList(); 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(), 'ingredients': ingredients, }, token: token, ); ref.invalidate(recipeDetailProvider(widget.recipeId)); ref.invalidate(recipesProvider); if (mounted) context.go('/recipes/${widget.recipeId}'); } on ApiException catch (e) { if (e.type == ApiErrorType.unauthorized) { await ref.read(authStateProvider.notifier).logout(); return; } setState(() { _saveError = mapErrorToUserMessage(e, context); _isSaving = false; }); } } @override Widget build(BuildContext context) { final recipeAsync = ref.watch(recipeDetailProvider(widget.recipeId)); return Scaffold( appBar: AppBar( title: Text(context.l10n.recipeEditTitle), actions: [ if (_initialized) TextButton( onPressed: _isSaving ? null : _save, child: _isSaving ? const SizedBox( height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Text(context.l10n.saveAction), ), ], ), body: recipeAsync.when( loading: () => LoadingStateView(label: context.l10n.recipeDetailLoading), error: (error, _) => ErrorStateView( message: mapErrorToUserMessage(error, context), 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: InputDecoration(labelText: context.l10n.recipeEditNameLabel), validator: (v) => (v == null || v.trim().isEmpty) ? context.l10n.recipeEditNameRequired : null, ), const SizedBox(height: 16), TextFormField( controller: _descCtrl, decoration: InputDecoration(labelText: context.l10n.recipeEditDescriptionLabel), maxLines: 3, ), const SizedBox(height: 16), TextFormField( controller: _servingsCtrl, decoration: InputDecoration(labelText: context.l10n.recipeEditServingsLabel), keyboardType: TextInputType.number, validator: (v) { if (v == null || v.trim().isEmpty) return null; if (int.tryParse(v.trim()) == null) { return context.l10n.recipeEditServingsInvalid; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _instructionsCtrl, decoration: InputDecoration(labelText: context.l10n.recipeEditInstructionsLabel), maxLines: 10, textAlignVertical: TextAlignVertical.top, ), const SizedBox(height: 20), Row( children: [ Expanded( child: Text( context.l10n.recipeEditIngredientsLabel, style: Theme.of(context).textTheme.titleMedium, ), ), OutlinedButton.icon( onPressed: _isSaving ? null : _addIngredient, icon: const Icon(Icons.add), label: Text(context.l10n.addAction), ), ], ), const SizedBox(height: 8), Text( context.l10n.recipeEditIngredientsHint, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 8), if (_isLoadingProducts) const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: LinearProgressIndicator(), ), if (_ingredients.isEmpty) Card( child: Padding( padding: const EdgeInsets.all(16), child: Text(context.l10n.recipeEditNoIngredients), ), ), ...List.generate( _ingredients.length, (index) => _buildIngredientCard(context, index), ), 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), ) : Text(context.l10n.recipeEditSaveChanges), ), const SizedBox(height: 40), ], ), ), ); } Widget _buildIngredientCard(BuildContext context, int index) { final ingredient = _ingredients[index]; return Card( margin: const EdgeInsets.only(bottom: 12), child: Padding( padding: const EdgeInsets.all(12), child: Column( children: [ Row( children: [ Expanded( child: Text( '${context.l10n.recipeEditIngredientPrefix}${index + 1}', style: Theme.of(context).textTheme.titleSmall, ), ), IconButton( onPressed: _isSaving ? null : () => _removeIngredient(index), icon: const Icon(Icons.delete_outline), tooltip: context.l10n.recipeEditRemoveIngredient, ), ], ), DropdownButtonFormField( initialValue: ingredient.productId, isExpanded: true, decoration: const InputDecoration( labelText: 'Produkt (valfritt)', border: OutlineInputBorder(), ), items: _allProducts .map( (product) => DropdownMenuItem( value: product['id'] as int, child: Text( ((product['canonicalName'] ?? product['name']) as Object) .toString(), overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: (_isSaving || _isLoadingProducts) ? null : (value) { if (value == null) return; final product = _allProducts.firstWhere( (item) => item['id'] == value, orElse: () => {'name': ''}, ); setState(() { ingredient.productId = value; ingredient.productName = ((product['canonicalName'] ?? product['name']) as Object) .toString(); }); }, ), const SizedBox(height: 12), TextFormField( initialValue: ingredient.rawName, decoration: const InputDecoration( labelText: 'Ingrediensnamn', border: OutlineInputBorder(), ), onChanged: (value) => ingredient.rawName = value, validator: (value) { if (ingredient.productId == null && (value == null || value.trim().isEmpty)) { return 'Ange namn eller välj produkt'; } return null; }, ), const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: ingredient.quantityCtrl, decoration: InputDecoration( labelText: context.l10n.quantityLabel, border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (value) { if (value == null || value.trim().isEmpty) return null; if (double.tryParse(value.trim().replaceAll(',', '.')) == null) { return context.l10n.invalidNumber; } return null; }, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( initialValue: ingredient.unit.trim().isEmpty ? null : ingredient.unit, isExpanded: true, decoration: InputDecoration( labelText: context.l10n.unitLabel, border: const OutlineInputBorder(), ), items: unitOptions .map( (option) => DropdownMenuItem( value: option.value, child: Text(option.label, overflow: TextOverflow.ellipsis), ), ) .toList(), onChanged: _isSaving ? null : (value) => setState(() => ingredient.unit = value ?? ''), ), ), ], ), const SizedBox(height: 12), TextFormField( controller: ingredient.noteCtrl, decoration: const InputDecoration( labelText: 'Notering (valfritt)', border: OutlineInputBorder(), ), ), ], ), ), ); } }