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/api/api_providers.dart'; import '../../../core/forms/form_options.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; final TextEditingController quantityCtrl; String unit; final TextEditingController noteCtrl; _EditableIngredient({ required this.productId, required this.productName, required String quantity, required this.unit, String note = '', }) : quantityCtrl = TextEditingController(text: quantity), noteCtrl = TextEditingController(text: note); factory _EditableIngredient.fromRecipe(RecipeIngredient ingredient) { final quantity = ingredient.quantity == ingredient.quantity.truncateToDouble() ? ingredient.quantity.toInt().toString() : ingredient.quantity.toString(); return _EditableIngredient( productId: ingredient.productId, productName: ingredient.productName, quantity: 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('/products', 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: '', quantity: '', unit: 'st', ), ); }); } void _removeIngredient(int index) { setState(() { final ingredient = _ingredients.removeAt(index); ingredient.dispose(); }); } String? _validateIngredients() { if (_ingredients.isEmpty) { return 'Minst en ingrediens kravs.'; } for (final ingredient in _ingredients) { if (ingredient.productId == null) { return 'Valj produkt for alla ingredienser.'; } final quantity = double.tryParse( ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), ); if (quantity == null || quantity < 0) { return 'Ange giltig mangd for alla ingredienser.'; } if (ingredient.unit.trim().isEmpty) { return 'Valj enhet for alla ingredienser.'; } } 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) => { 'productId': ingredient.productId, 'quantity': double.parse( ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), ), '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.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: 20), Row( children: [ Expanded( child: Text( 'Ingredienser', style: Theme.of(context).textTheme.titleMedium, ), ), OutlinedButton.icon( onPressed: _isSaving ? null : _addIngredient, icon: const Icon(Icons.add), label: const Text('Lagg till'), ), ], ), const SizedBox(height: 8), Text( 'Valj produkt, mangd och enhet for varje ingrediens.', 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) const Card( child: Padding( padding: EdgeInsets.all(16), child: Text('Inga ingredienser tillagda an.'), ), ), ...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), ) : const Text('Spara ändringar'), ), 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( 'Ingrediens ${index + 1}', style: Theme.of(context).textTheme.titleSmall, ), ), IconButton( onPressed: _isSaving ? null : () => _removeIngredient(index), icon: const Icon(Icons.delete_outline), tooltip: 'Ta bort ingrediens', ), ], ), DropdownButtonFormField( value: ingredient.productId, isExpanded: true, decoration: const InputDecoration( labelText: 'Produkt *', 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), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: ingredient.quantityCtrl, decoration: const InputDecoration( labelText: 'Mangd *', border: OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Ange mangd'; } if (double.tryParse(value.trim().replaceAll(',', '.')) == null) { return 'Ogiltigt tal'; } return null; }, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( value: ingredient.unit.trim().isEmpty ? null : ingredient.unit, isExpanded: true, decoration: const InputDecoration( labelText: 'Enhet *', border: 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(), ), ), ], ), ), ); } }