313 lines
12 KiB
Dart
313 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),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|