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/l10n/l10n.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 { /// 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; 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(); } super.dispose(); } 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; } } } 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]; if (productId == null) continue; 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({ 'productId': productId, 'quantity': qty, 'unit': unit, if (note.isNotEmpty) 'note': note, if (alternativeProductIds.isNotEmpty) 'alternativeProductIds': alternativeProductIds, }); } 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])), ], 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), ), ), ), ], ); } 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), ), ), ), ], ), ), ], ), ); } }