Initial commit: CookIT recipe collection and week planner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7d7a0bc60b
173 files changed
+22803
No files matched your search
@@ -0,0 +1,313 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user