Files
CookIT/lib/src/recipes/views/recipe_edit_view.dart
T
2026-10-28 22:55:09 +01:00

314 lines
12 KiB
Dart

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<RecipeEditView> createState() => _RecipeEditViewState();
}
class _RecipeEditViewState extends State<RecipeEditView> {
final _form = GlobalKey<FormState>();
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<String> _photos = [];
final _addedPhotos = <String>[];
List<IngredientEntry> _ingredients = [];
List<StepEntry> _steps = [];
late final PhotoStore _photoStore;
@override
void initState() {
super.initState();
_photoStore = context.read<PhotoStore>();
_load();
}
Future<void> _load() async {
final id = widget.recipeId;
final recipe = id == null ? null : await context.read<RecipeRepository>().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<void> _save() async {
if (!_form.currentState!.validate()) return;
final repository = context.read<RecipeRepository>();
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 <String>[];
await _photoStore.delete(removed);
_saved = true;
if (mounted) Navigator.pop(context, recipe.id);
}
Future<void> _confirmDiscard() async {
final l10n = AppLocalizations.of(context);
final discard = await showDialog<bool>(
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<RecipeListProvider>().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<String?>(
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<CaloriesBasis>(
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),
),
],
),
),
),
);
}
}