Initial commit: CookIT recipe collection and week planner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 22:55:09 +01:00
commit 7d7a0bc60b
173 files changed
+22803

No files matched your search

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/amount.dart';
import '../../recipes/models/recipe.dart';
import '../../recipes/time_format.dart';
import '../../recipes/unit_catalog.dart';
import '../../recipes/views/recipe_detail_view.dart';
import '../recipe_share_service.dart';
import '../shared_recipe.dart';
/// A Recipe from another cook, shown before anything is saved: what it holds,
/// a hint when one of that name exists already, and "Speichern".
class SharedRecipeView extends StatefulWidget {
const SharedRecipeView({super.key, required this.shared});
static const routeName = '/received';
final SharedRecipe shared;
@override
State<SharedRecipeView> createState() => _SharedRecipeViewState();
}
class _SharedRecipeViewState extends State<SharedRecipeView> {
late final Future<bool> _nameTaken = context.read<RecipeShareService>().hasRecipeNamed(widget.shared.recipe.name);
var _saving = false;
Future<void> _save() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
setState(() => _saving = true);
final recipe = await context.read<RecipeShareService>().save(widget.shared);
navigator.pushReplacementNamed(RecipeDetailView.routeName, arguments: recipe.id);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.recipeSaved(recipe.name))));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final units = context.watch<UnitCatalog>();
final shared = widget.shared;
final recipe = shared.recipe;
final sentUnits = {for (final u in shared.units) u.key: u.label};
String unitLabel(String key) =>
units.byKey(key) != null || !sentUnits.containsKey(key) ? units.label(l10n, key) : sentUnits[key]!;
final facts = [
if (shared.courseName != null) shared.courseName!,
if (recipe.servings != null) '${formatNumber(recipe.servings!)} ${l10n.factServings}',
if (recipe.totalMinutes != null) formatMinutes(l10n, recipe.totalMinutes!),
].join(' · ');
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w800, fontSize: 17, color: c.green);
var number = 0;
return Scaffold(
appBar: AppBar(title: Text(l10n.receivedRecipe)),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: const Icon(Icons.download_done_rounded),
label: Text(l10n.save),
),
),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
if (shared.photos.isNotEmpty) ...[
ClipRRect(
borderRadius: BorderRadius.circular(22),
child: AspectRatio(
aspectRatio: 16 / 10,
child: Image.memory(shared.photos.first, fit: BoxFit.cover, cacheWidth: 1000),
),
),
const SizedBox(height: 16),
],
Text(
recipe.name,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 28, color: c.ink),
),
if (facts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
facts,
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
FutureBuilder<bool>(
future: _nameTaken,
builder: (context, snapshot) => snapshot.data != true
? const SizedBox.shrink()
: Container(
margin: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: c.honey, borderRadius: BorderRadius.circular(14)),
child: Text(
l10n.recipeNameTaken(recipe.name),
style: TextStyle(fontWeight: FontWeight.w700, color: c.onHoney),
),
),
),
const SizedBox(height: 20),
Text(l10n.ingredients, style: heading),
if (recipe.ingredientGroups.isEmpty) Text(l10n.noIngredients),
for (final group in recipe.ingredientGroups) ...[
if (group.name != null)
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 2),
child: Text(
group.name!,
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
),
),
for (final line in group.lines) _IngredientLine(line: line, unitLabel: unitLabel),
],
const SizedBox(height: 20),
Text(l10n.steps, style: heading),
if (recipe.stepSections.isEmpty) Text(l10n.noSteps),
for (final section in recipe.stepSections) ...[
if (section.title != null)
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 2),
child: Text(
section.title!,
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
),
),
for (final step in section.steps)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('${++number}. $step', style: TextStyle(fontSize: 15, height: 1.4, color: c.ink)),
),
],
if (recipe.notes != null) ...[
const SizedBox(height: 20),
Text(l10n.notes, style: heading),
Text(recipe.notes!, style: TextStyle(fontSize: 15, height: 1.4, color: c.ink)),
],
],
),
);
}
}
class _IngredientLine extends StatelessWidget {
const _IngredientLine({required this.line, required this.unitLabel});
final IngredientLine line;
final String Function(String key) unitLabel;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final amount = [
formatAmount(line.amount, line.amountMax),
if (line.unitKey != null) unitLabel(line.unitKey!),
].where((s) => s.isNotEmpty).join(' ');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
amount,
style: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
),
),
Expanded(
child: Text.rich(
TextSpan(
text: line.name,
children: [
if (line.remark != null)
TextSpan(
text: ' ${line.remark}',
style: TextStyle(color: c.inkMuted),
),
],
),
style: TextStyle(color: c.ink),
),
),
],
),
);
}
}