import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../../localization/app_localizations.dart'; import '../amount.dart'; import '../models/recipe.dart'; import '../photo_store.dart'; import '../recipe_draft.dart'; import '../recipe_list_provider.dart'; import '../recipe_repository.dart'; import 'widgets/ingredient_editor.dart'; import 'widgets/photo_editor.dart'; import 'widgets/step_editor.dart'; /// Create a Recipe (no [recipeId]) or edit an existing one. class RecipeEditView extends StatefulWidget { const RecipeEditView({super.key, this.recipeId}); static const routeName = '/recipe/edit'; final String? recipeId; @override State createState() => _RecipeEditViewState(); } class _RecipeEditViewState extends State { final _form = GlobalKey(); Recipe? _original; bool _loading = true; bool _dirty = false; bool _saved = false; final _name = TextEditingController(); final _servings = TextEditingController(); final _prep = TextEditingController(); final _cook = TextEditingController(); final _kcal = TextEditingController(); final _tags = TextEditingController(); final _notes = TextEditingController(); final _sources = TextEditingController(); String? _courseId; CaloriesBasis _basis = CaloriesBasis.perServing; List _photos = []; final _addedPhotos = []; List _ingredients = []; List _steps = []; late final PhotoStore _photoStore; @override void initState() { super.initState(); _photoStore = context.read(); _load(); } Future _load() async { final id = widget.recipeId; final recipe = id == null ? null : await context.read().getRecipe(id); if (!mounted) return; setState(() { _original = recipe; _name.text = recipe?.name ?? ''; _servings.text = recipe?.servings == null ? '' : formatNumber(recipe!.servings!); _prep.text = recipe?.prepMinutes?.toString() ?? ''; _cook.text = recipe?.cookMinutes?.toString() ?? ''; _kcal.text = recipe?.calories?.kcal.toString() ?? ''; _basis = recipe?.calories?.basis ?? CaloriesBasis.perServing; _tags.text = recipe?.tags.join(', ') ?? ''; _notes.text = recipe?.notes ?? ''; _sources.text = recipe?.sources.join('\n') ?? ''; _courseId = recipe?.courseId; _photos = [...?recipe?.photos]; _ingredients = recipe == null ? [LineEntry()] : ingredientEntriesFrom(recipe.ingredientGroups); _steps = recipe == null ? [StepTextEntry()] : stepEntriesFrom(recipe.stepSections); _loading = false; }); } void _changed() => setState(() => _dirty = true); Future _save() async { if (!_form.currentState!.validate()) return; final repository = context.read(); final now = DateTime.now(); final original = _original; final kcal = int.tryParse(_kcal.text.trim()); final recipe = Recipe( id: original?.id ?? const Uuid().v4(), name: _name.text.trim(), courseId: _courseId, tags: tagsFrom(_tags.text), servings: parseAmount(_servings.text)?.amount, prepMinutes: int.tryParse(_prep.text.trim()), cookMinutes: int.tryParse(_cook.text.trim()), calories: kcal == null ? null : Calories(kcal, _basis), ingredientGroups: ingredientGroupsFrom(_ingredients), stepSections: stepSectionsFrom(_steps), photos: _photos, sources: linesFrom(_sources.text), notes: _notes.text.trim().isEmpty ? null : _notes.text.trim(), favourite: original?.favourite ?? false, reviewReasons: original?.reviewReasons ?? const [], createdAt: original?.createdAt ?? now, updatedAt: now, ); await repository.saveRecipe(recipe); final removed = original?.photos.where((p) => !_photos.contains(p)) ?? const []; await _photoStore.delete(removed); _saved = true; if (mounted) Navigator.pop(context, recipe.id); } Future _confirmDiscard() async { final l10n = AppLocalizations.of(context); final discard = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.discardTitle), content: Text(l10n.discardBody), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.keepEditing)), FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.discard)), ], ), ); if (discard == true && mounted) { setState(() => _dirty = false); // PopScope only allows the pop once it has rebuilt with canPop: true. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.pop(context); }); } } @override void dispose() { if (!_saved && _addedPhotos.isNotEmpty) { _photoStore.delete(_addedPhotos); } for (final c in [_name, _servings, _prep, _cook, _kcal, _tags, _notes, _sources]) { c.dispose(); } for (final e in _ingredients) { e.dispose(); } for (final e in _steps) { e.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final courses = context.watch().courses; final courseIds = {for (final c in courses) c.id}; String? number(String? value, {bool decimal = false}) { final text = value?.trim() ?? ''; if (text.isEmpty) return null; if (!decimal) return int.tryParse(text) == null ? l10n.invalidNumber : null; final amount = parseAmount(text); return amount == null || amount.amountMax != null ? l10n.invalidNumber : null; } Widget section(String title) => Padding( padding: const EdgeInsets.only(top: 28, bottom: 10), child: Text(title, style: theme.textTheme.titleLarge), ); return PopScope( canPop: !_dirty, onPopInvokedWithResult: (didPop, _) { if (!didPop) _confirmDiscard(); }, child: Scaffold( appBar: AppBar( title: Text(widget.recipeId == null ? l10n.newRecipe : l10n.editRecipe), actions: [ Padding( padding: const EdgeInsets.only(right: 8), child: FilledButton(onPressed: _loading ? null : _save, child: Text(l10n.save)), ), ], ), body: _loading ? const Center(child: CircularProgressIndicator()) : Form( key: _form, onChanged: () { if (!_dirty) _changed(); }, child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 48), children: [ PhotoEditor(photos: _photos, onAdded: _addedPhotos.addAll, onChanged: _changed), const SizedBox(height: 20), TextFormField( controller: _name, style: theme.textTheme.titleLarge, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration(labelText: l10n.fieldName), validator: (v) => (v ?? '').trim().isEmpty ? l10n.fieldNameRequired : null, ), const SizedBox(height: 12), DropdownButtonFormField( value: courseIds.contains(_courseId) ? _courseId : null, isExpanded: true, decoration: InputDecoration(labelText: l10n.fieldCourse), items: [ DropdownMenuItem(value: null, child: Text(l10n.noCourse)), for (final c in courses) DropdownMenuItem(value: c.id, child: Text(c.label)), ], onChanged: (id) { _courseId = id; _changed(); }, ), const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: _servings, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration(labelText: l10n.fieldServings), validator: (v) => number(v, decimal: true), ), ), const SizedBox(width: 8), Expanded( child: TextFormField( controller: _prep, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: l10n.fieldPrep, suffixText: l10n.minutesSuffix), validator: number, ), ), const SizedBox(width: 8), Expanded( child: TextFormField( controller: _cook, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: l10n.fieldCook, suffixText: l10n.minutesSuffix), validator: number, ), ), ], ), const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: _kcal, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: l10n.fieldKcal), validator: number, ), ), const SizedBox(width: 8), SegmentedButton( showSelectedIcon: false, segments: [ ButtonSegment(value: CaloriesBasis.perServing, label: Text(l10n.basisPerServing)), ButtonSegment(value: CaloriesBasis.wholeRecipe, label: Text(l10n.basisWholeRecipe)), ], selected: {_basis}, onSelectionChanged: (s) { _basis = s.first; _changed(); }, ), ], ), const SizedBox(height: 12), TextFormField( controller: _tags, decoration: InputDecoration(labelText: l10n.fieldTags, hintText: l10n.fieldTagsHint), ), section(l10n.ingredients), IngredientEditor(entries: _ingredients, onChanged: _changed), section(l10n.steps), StepEditor(entries: _steps, onChanged: _changed), section(l10n.notes), TextFormField( controller: _notes, minLines: 3, maxLines: null, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration(hintText: l10n.fieldNotes), ), const SizedBox(height: 12), TextFormField( controller: _sources, minLines: 1, maxLines: null, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: l10n.fieldSources, hintText: l10n.fieldSourcesHint), ), ], ), ), ), ); } }