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_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/l10n/l10n.dart'; import '../../auth/data/auth_providers.dart'; import '../data/recipe_providers.dart'; 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; /// Optional image URL pre-filled from a quick-import result. final String? initialImageUrl; const CreateRecipeScreen({ super.key, this.initialMarkdown, this.initialImageUrl, }); @override ConsumerState createState() => _CreateRecipeScreenState(); } class _CreateRecipeScreenState extends ConsumerState { _Step _step = _Step.input; // Step 1 — markdown input late final TextEditingController _markdownCtrl; bool _isParsing = false; String? _parseError; @override void initState() { super.initState(); _markdownCtrl = TextEditingController(text: widget.initialMarkdown ?? ''); } // Step 2 — review state ParsedRecipe? _parsed; late TextEditingController _nameCtrl; late TextEditingController _servingsCtrl; late List _included; late Map _selectedProductIds; late Map _selectedProductNames; late Map _qtyControllers; 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; @override void dispose() { _markdownCtrl.dispose(); if (_step == _Step.review) { _nameCtrl.dispose(); _servingsCtrl.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 _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(); _included = List.generate(parsed.ingredients.length, (_) => true); _selectedProductIds = {}; _selectedProductNames = {}; _qtyControllers = {}; _unitControllers = {}; _noteControllers = {}; for (var i = 0; i < parsed.ingredients.length; i++) { final ing = parsed.ingredients[i]; _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(); } Future _parseMarkdown() async { final markdown = _markdownCtrl.text.trim(); if (markdown.isEmpty) { setState(() => _parseError = context.l10n.recipeCreateMarkdownHint); 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, context); _isParsing = false; }); } } Future _save() async { final name = _nameCtrl.text.trim(); if (name.isEmpty) { setState(() => _saveError = context.l10n.recipeCreateNameRequired); return; } final ingredients = >[]; for (var i = 0; i < _parsed!.ingredients.length; i++) { if (!_included[i]) continue; final productId = _selectedProductIds[i]; final qty = double.tryParse( _qtyControllers[i]!.text.trim().replaceAll(',', '.'), ) ?? _parsed!.ingredients[i].quantity; 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() : []; ingredients.add({ 'rawName': ing.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, }); } 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, if (widget.initialImageUrl != null && widget.initialImageUrl!.isNotEmpty) 'imageUrl': widget.initialImageUrl, '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, context); _isSaving = false; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(_step == _Step.input ? context.l10n.recipeCreateTitle : context.l10n.recipeCreateReviewIngredients), 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: InputDecoration( hintText: context.l10n.recipeCreateMarkdownPlaceholder, border: const 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), ) : Text(context.l10n.recipeCreateReviewIngredients), ), ), ), ], ); } Widget _buildReviewStep() { final parsed = _parsed!; return Column( children: [ Expanded( child: ListView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [ TextField( controller: _nameCtrl, decoration: InputDecoration(labelText: context.l10n.recipeEditNameLabel), ), const SizedBox(height: 12), TextField( controller: _servingsCtrl, decoration: InputDecoration( labelText: context.l10n.recipeEditServingsLabel), keyboardType: TextInputType.number, ), if (parsed.ingredients.isNotEmpty) ...[ const SizedBox(height: 20), Text(context.l10n.recipeEditIngredientsLabel, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text( context.l10n.recipeCreateIngredientsHint, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), ...List.generate( 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), ], ), ), 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), ) : Text(context.l10n.recipeCreateSaveAction), ), ), ), ], ); } 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; // Problem #2: tydlig varning om rad är inkluderad men saknar produkt final showMissingProductWarning = isIncluded && noProductFound; return Card( margin: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ CheckboxListTile( value: isIncluded, onChanged: (v) => setState(() => _included[index] = v ?? false), title: ing.alternatives.length > 1 ? Wrap( spacing: 4, children: ing.alternatives .map((alt) => Chip( label: Text(alt), padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, )) .toList(), ) : Text(ing.rawName), subtitle: noProductFound ? Text( context.l10n.recipeCreateNoProductFound, style: TextStyle( color: showMissingProductWarning ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ), ) : DropdownButton( 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( 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), 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), ), ), ), ], ), ), ], ), ); } }