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

+54
View File
@@ -0,0 +1,54 @@
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'src/app.dart';
import 'src/bundle/bundle_service.dart';
import 'src/database/database.dart';
import 'src/planning/plan_repository.dart';
import 'src/planning/plan_share_service.dart';
import 'src/planning/week_plan_provider.dart';
import 'src/recipes/course_repository.dart';
import 'src/recipes/photo_store.dart';
import 'src/recipes/recipe_list_provider.dart';
import 'src/recipes/recipe_repository.dart';
import 'src/recipes/unit_catalog.dart';
import 'src/recipes/unit_repository.dart';
import 'src/settings/settings_controller.dart';
import 'src/sharing/recipe_share_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
final db = AppDatabase(driftDatabase(name: 'cookit'));
final photos = await PhotoStore.open();
final recipes = RecipeRepository(db, photos);
final courses = CourseRepository(db);
final units = UnitRepository(db);
final plan = PlanRepository(db);
final bundles = BundleService(recipes, courses, units, photos);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: photos),
Provider.value(value: recipes),
Provider.value(value: courses),
Provider.value(value: units),
Provider.value(value: plan),
ChangeNotifierProvider(create: (_) => UnitCatalog(units)),
Provider.value(value: bundles),
Provider(create: (_) => RecipeShareService(recipes, courses, units, photos, bundles)),
Provider(create: (_) => PlanShareService(recipes, courses, units, photos, plan, bundles)),
ChangeNotifierProvider(create: (_) => RecipeListProvider(recipes, courses)),
ChangeNotifierProvider(create: (_) => WeekPlanProvider(plan)),
],
child: const CookItApp(),
),
);
}
+111
View File
@@ -0,0 +1,111 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'bundle/bundle_codec.dart';
import 'bundle/bundle_service.dart';
import 'cooking_mode/cooking_mode_view.dart';
import 'design/app_theme.dart';
import 'home/home_view.dart';
import 'localization/app_localizations.dart';
import 'planning/plan_share_service.dart';
import 'planning/views/shared_week_view.dart';
import 'recipes/views/recipe_detail_view.dart';
import 'recipes/views/recipe_edit_view.dart';
import 'settings/courses_view.dart';
import 'settings/meals_view.dart';
import 'settings/settings_controller.dart';
import 'settings/settings_view.dart';
import 'settings/units_view.dart';
import 'sharing/cookit_platform.dart';
import 'sharing/recipe_share_service.dart';
import 'sharing/shared_recipe.dart';
import 'sharing/views/scan_recipe_view.dart';
import 'sharing/views/share_recipe_view.dart';
import 'sharing/views/shared_recipe_view.dart';
class CookItApp extends StatefulWidget {
const CookItApp({super.key});
@override
State<CookItApp> createState() => _CookItAppState();
}
class _CookItAppState extends State<CookItApp> {
final _navigator = GlobalKey<NavigatorState>();
final _messenger = GlobalKey<ScaffoldMessengerState>();
@override
void initState() {
super.initState();
CookItPlatform.listenForFiles(_openFile);
}
/// A file opened with or shared to CookIt: a shared week opens its preview,
/// so does a single Recipe; a whole collection is imported as in the settings.
Future<void> _openFile(Uint8List bytes) async {
final shares = context.read<RecipeShareService>();
final bundles = context.read<BundleService>();
String message(Object e) => e is BundleFormatException ? e.message : '$e';
try {
final contents = await bundles.read(bytes);
if (contents.plan != null) {
await _navigator.currentState?.pushNamed(SharedWeekView.routeName, arguments: SharedWeek(contents));
return;
}
final shared = shares.fromContents(contents);
if (shared != null) {
await _navigator.currentState?.pushNamed(SharedRecipeView.routeName, arguments: shared);
return;
}
final result = await bundles.importBundle(bytes);
final l10n = AppLocalizations.of(_navigator.currentContext!);
_messenger.currentState?.showSnackBar(SnackBar(content: Text(l10n.importResult(result.added, result.skipped))));
} catch (e) {
final navigatorContext = _navigator.currentContext;
if (navigatorContext == null || !navigatorContext.mounted) return;
final l10n = AppLocalizations.of(navigatorContext);
_messenger.currentState?.showSnackBar(SnackBar(content: Text(l10n.fileUnreadable(message(e)))));
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: _navigator,
scaffoldMessengerKey: _messenger,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: context.watch<SettingsController>().themeMode,
restorationScopeId: 'app',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
settings: settings,
builder: (context) => switch (settings.name) {
RecipeDetailView.routeName => RecipeDetailView(recipeId: settings.arguments! as String),
CookingModeView.routeName => CookingModeView(recipeId: settings.arguments! as String),
RecipeEditView.routeName => RecipeEditView(recipeId: settings.arguments as String?),
SettingsView.routeName => const SettingsView(),
CoursesView.routeName => const CoursesView(),
UnitsView.routeName => const UnitsView(),
MealsView.routeName => const MealsView(),
ShareRecipeView.routeName => ShareRecipeView(recipeId: settings.arguments! as String),
ScanRecipeView.routeName => const ScanRecipeView(),
SharedRecipeView.routeName => SharedRecipeView(shared: settings.arguments! as SharedRecipe),
SharedWeekView.routeName => SharedWeekView(week: settings.arguments! as SharedWeek),
_ => const HomeView(),
},
),
);
}
}
+193
View File
@@ -0,0 +1,193 @@
import '../recipes/models/recipe.dart';
/// JSON side of the Recipe Bundle format (docs/recipe-bundle-format.md).
class BundleCodec {
static const format = 'cookit.recipe-bundle';
/// The newest version this app reads. 2 added the cook's own units
/// (`units`), 3 a week of the Week Plan (`plan`). A bundle is written with
/// the lowest version its content needs, so older apps still read what they can.
static const version = 3;
static const manifestName = 'bundle.json';
/// [photoPaths] are the paths inside the zip, in the Recipe's photo order.
static Map<String, dynamic> encodeRecipe(
Recipe recipe, {
required String? courseName,
required List<String> photoPaths,
}) => {
'id': recipe.id,
'name': recipe.name,
'course': courseName,
'tags': recipe.tags,
'servings': recipe.servings,
'prepMinutes': recipe.prepMinutes,
'cookMinutes': recipe.cookMinutes,
'calories': recipe.calories?.toJson(),
'ingredientGroups': [for (final g in recipe.ingredientGroups) g.toJson()],
'stepSections': [for (final s in recipe.stepSections) s.toJson()],
'photos': photoPaths,
'sources': recipe.sources,
'notes': recipe.notes,
'favourite': recipe.favourite,
'reviewReasons': recipe.reviewReasons,
};
static Map<String, dynamic> encodeManifest(
List<Map<String, dynamic>> recipes, {
required DateTime createdAt,
List<BundleUnit> units = const [],
BundlePlan? plan,
}) => {
'format': format,
'version': plan == null ? 2 : 3,
'createdAt': createdAt.toUtc().toIso8601String(),
'units': [for (final u in units) u.toJson()],
'recipes': recipes,
if (plan != null) 'plan': plan.toJson(),
};
/// The week of the Week Plan a bundle carries, if any (version 3).
static BundlePlan? decodePlan(Object? json) {
if (json is! Map<String, dynamic> || json['plan'] == null) return null;
try {
return BundlePlan.fromJson(json['plan'] as Map<String, dynamic>);
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// The cook's own units carried in a bundle (none in version 1 bundles).
static List<BundleUnit> decodeUnits(Object? json) {
if (json is! Map<String, dynamic>) return const [];
try {
return [for (final u in json['units'] as List? ?? const []) BundleUnit.fromJson(u as Map<String, dynamic>)];
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// Throws [BundleFormatException] when [json] isn't a bundle this app can read.
static List<BundleRecipe> decodeManifest(Object? json, {required DateTime now}) {
if (json is! Map<String, dynamic> || json['format'] != format) {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
final v = json['version'];
if (v is! int || v > version) {
throw const BundleFormatException('Dieses Rezeptpaket stammt aus einer neueren CookIt-Version.');
}
try {
return [for (final r in json['recipes'] as List) decodeRecipe(r as Map<String, dynamic>, now: now)];
} on BundleFormatException {
rethrow;
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// One Recipe as [encodeRecipe] wrote it. A Recipe Code leaves out the id
/// (the receiver gives it a new one), so it may be missing here.
static BundleRecipe decodeRecipe(Map<String, dynamic> json, {required DateTime now}) {
final calories = json['calories'];
final recipe = Recipe(
id: json['id'] as String? ?? '',
name: json['name'] as String,
tags: _strings(json['tags']),
servings: (json['servings'] as num?)?.toDouble(),
prepMinutes: (json['prepMinutes'] as num?)?.toInt(),
cookMinutes: (json['cookMinutes'] as num?)?.toInt(),
calories: calories == null ? null : Calories.fromJson(calories as Map<String, dynamic>),
ingredientGroups: [
for (final g in json['ingredientGroups'] as List? ?? const [])
IngredientGroup.fromJson(g as Map<String, dynamic>),
],
stepSections: [
for (final s in json['stepSections'] as List? ?? const []) StepSection.fromJson(s as Map<String, dynamic>),
],
sources: _strings(json['sources']),
notes: json['notes'] as String?,
favourite: json['favourite'] as bool? ?? false,
reviewReasons: _strings(json['reviewReasons']),
createdAt: now,
updatedAt: now,
);
return BundleRecipe(recipe, courseName: json['course'] as String?, photoPaths: _strings(json['photos']));
}
static List<String> _strings(Object? value) => [for (final s in value as List? ?? const []) s as String];
}
/// One of the cook's own units as a bundle carries it.
class BundleUnit {
const BundleUnit({required this.key, required this.label, required this.direct});
final String key;
final String label;
final bool direct;
Map<String, dynamic> toJson() => {'key': key, 'label': label, 'direct': direct};
static BundleUnit fromJson(Map<String, dynamic> json) =>
BundleUnit(key: json['key'] as String, label: json['label'] as String, direct: json['direct'] as bool? ?? true);
}
/// A week of the Week Plan as a bundle carries it: its entries with their
/// day, their Meal by name, and either the id of a Recipe in the bundle or a note.
class BundlePlan {
const BundlePlan({required this.weekStart, required this.meals, required this.entries});
/// "2026-10-19"
final String weekStart;
/// The sender's Meals the entries use, in the sender's order.
final List<String> meals;
final List<BundlePlanEntry> entries;
Map<String, dynamic> toJson() => {
'weekStart': weekStart,
'meals': meals,
'entries': [for (final e in entries) e.toJson()],
};
static BundlePlan fromJson(Map<String, dynamic> json) => BundlePlan(
weekStart: json['weekStart'] as String,
meals: [for (final m in json['meals'] as List) m as String],
entries: [for (final e in json['entries'] as List) BundlePlanEntry.fromJson(e as Map<String, dynamic>)],
);
}
class BundlePlanEntry {
const BundlePlanEntry({required this.day, required this.meal, this.recipeId, this.note});
final String day;
final String meal;
final String? recipeId;
final String? note;
Map<String, dynamic> toJson() => {'day': day, 'meal': meal, 'recipe': recipeId, 'note': note};
static BundlePlanEntry fromJson(Map<String, dynamic> json) => BundlePlanEntry(
day: json['day'] as String,
meal: json['meal'] as String,
recipeId: json['recipe'] as String?,
note: json['note'] as String?,
);
}
/// A Recipe read from a bundle, before its Course and Photos are resolved locally.
class BundleRecipe {
const BundleRecipe(this.recipe, {required this.courseName, required this.photoPaths});
final Recipe recipe;
final String? courseName;
final List<String> photoPaths;
}
class BundleFormatException implements Exception {
const BundleFormatException(this.message);
final String message;
@override
String toString() => message;
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:convert';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/recipe.dart';
import '../recipes/photo_store.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/unit_repository.dart';
import 'bundle_codec.dart';
/// What a Recipe Bundle holds, read but not yet added to the collection.
class BundleContents {
const BundleContents({required this.recipes, required this.units, required this.files, this.plan});
final List<BundleRecipe> recipes;
final List<BundleUnit> units;
/// A week of the Week Plan, when the bundle was shared from there.
final BundlePlan? plan;
/// Every file in the zip by its path, Photos included.
final Map<String, Uint8List> files;
}
class ImportResult {
const ImportResult({required this.added, required this.skipped});
final int added;
final int skipped;
}
/// Reads and writes Recipe Bundles: "Rezepte importieren" and "Sammlung exportieren".
class BundleService {
BundleService(this._recipes, this._courses, this._units, this._photos, {DateTime Function()? clock})
: _clock = clock ?? DateTime.now;
final RecipeRepository _recipes;
final CourseRepository _courses;
final UnitRepository _units;
final PhotoStore _photos;
final DateTime Function() _clock;
/// The bundle's Recipes, the cook's own units it carries and its files.
/// Throws [BundleFormatException].
Future<BundleContents> read(Uint8List zipBytes) async {
final files = await Isolate.run(() => _unzip(zipBytes));
final manifest = files[BundleCodec.manifestName];
if (manifest == null) {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
final Object? json;
try {
json = jsonDecode(utf8.decode(manifest));
} on FormatException {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
return BundleContents(
recipes: BundleCodec.decodeManifest(json, now: _clock()),
units: BundleCodec.decodeUnits(json),
files: files,
plan: BundleCodec.decodePlan(json),
);
}
/// Adds every Recipe from the bundle that isn't in the collection yet.
Future<ImportResult> importBundle(Uint8List zipBytes) async {
final contents = await read(zipBytes);
final entries = contents.recipes;
final files = contents.files;
final known = {for (final u in await _units.allUnits()) u.key};
for (final unit in contents.units) {
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
}
final existing = await _recipes.existingIds();
final fresh = entries.where((e) => !existing.contains(e.recipe.id)).toList();
final courseIds = <String, String>{};
final recipes = [
for (final entry in fresh)
entry.recipe.copyWith(
courseId: () => null,
photos: [
for (final path in entry.photoPaths)
if (files[path] case final bytes?) await _photos.saveBytes(bytes),
],
),
];
for (final (i, entry) in fresh.indexed) {
final name = entry.courseName;
if (name == null) continue;
final id = courseIds[name] ??= (await _courses.findOrCreate(name)).id;
recipes[i] = recipes[i].copyWith(courseId: () => id);
}
await _recipes.insertAll(recipes);
return ImportResult(added: fresh.length, skipped: entries.length - fresh.length);
}
/// The whole collection, Photos included, as a zip.
Future<Uint8List> exportBundle() async {
final ownUnits = [
for (final u in await _units.allUnits())
if (!u.builtIn) BundleUnit(key: u.key, label: u.label ?? u.key, direct: u.direct),
];
return _export(await _recipes.allRecipes(), units: ownUnits);
}
/// One Recipe for another cook ("Als Datei teilen"): with its Photos and
/// the own units it uses, without what is personal (Favourite, Review Flag,
/// and the Notes unless [includeNotes]).
Future<Uint8List> exportRecipe(Recipe recipe, {bool includeNotes = true}) async {
final shared = recipe.copyWith(favourite: false, reviewReasons: const [], notes: includeNotes ? null : () => null);
return _export([shared], units: await ownUnitsOf([recipe]));
}
/// A week of the Week Plan for another cook: its entries and the Recipes
/// they plan, with Photos, without what is personal.
Future<Uint8List> exportWeek(List<Recipe> recipes, BundlePlan plan) async {
final shared = [for (final r in recipes) r.copyWith(favourite: false, reviewReasons: const [])];
return _export(shared, units: await ownUnitsOf(recipes), plan: plan);
}
/// The cook's own units [recipes] use, as a bundle or Recipe Code carries them.
Future<List<BundleUnit>> ownUnitsOf(Iterable<Recipe> recipes) async {
final used = {
for (final recipe in recipes)
for (final line in recipe.ingredientLines) line.unitKey,
};
return [
for (final u in await _units.allUnits())
if (!u.builtIn && used.contains(u.key)) BundleUnit(key: u.key, label: u.label ?? u.key, direct: u.direct),
];
}
Future<Uint8List> _export(List<Recipe> recipes, {required List<BundleUnit> units, BundlePlan? plan}) async {
final courses = {for (final c in await _courses.allCourses()) c.id: c.name};
final files = <String, Uint8List>{};
final entries = <Map<String, dynamic>>[];
for (final recipe in recipes) {
final paths = <String>[];
for (final (i, photo) in recipe.photos.indexed) {
final path = 'photos/${recipe.id}-${i + 1}.jpg';
try {
files[path] = await _photos.read(photo);
paths.add(path);
} on Exception {
// A missing photo file shouldn't block the backup of everything else.
}
}
entries.add(BundleCodec.encodeRecipe(recipe, courseName: courses[recipe.courseId], photoPaths: paths));
}
final manifest = BundleCodec.encodeManifest(entries, createdAt: _clock(), units: units, plan: plan);
files[BundleCodec.manifestName] = utf8.encode(const JsonEncoder.withIndent(' ').convert(manifest));
return Isolate.run(() => _zip(files));
}
static Map<String, Uint8List> _unzip(Uint8List bytes) {
try {
final archive = ZipDecoder().decodeBytes(bytes);
return {
for (final file in archive.files)
if (file.isFile) file.name: file.content,
};
} on Exception {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
}
static Uint8List _zip(Map<String, Uint8List> files) {
final archive = Archive();
for (final MapEntry(key: name, value: bytes) in files.entries) {
final file = ArchiveFile.bytes(name, bytes);
// Photos are JPEG already; deflating them only costs time.
if (name.endsWith('.jpg')) file.compression = CompressionType.none;
archive.add(file);
}
return ZipEncoder().encodeBytes(archive);
}
}
+415
View File
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../design/cookit_colors.dart';
import '../design/herb_pattern.dart';
import '../localization/app_localizations.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/views/widgets/ingredient_checklist.dart';
import '../recipes/views/widgets/servings_stepper.dart';
import 'cooking_session.dart';
/// Cooking Mode: the ingredients to get ready, then one Step per page in large
/// type, with the screen kept on.
class CookingModeView extends StatefulWidget {
const CookingModeView({super.key, required this.recipeId});
static const routeName = '/recipe/cook';
final String recipeId;
@override
State<CookingModeView> createState() => _CookingModeViewState();
}
class _CookingModeViewState extends State<CookingModeView> {
final _pages = PageController();
final _checked = <String>{};
CookingSession? _session;
int _page = 0;
@override
void initState() {
super.initState();
_keepScreenOn(true);
context.read<RecipeRepository>().getRecipe(widget.recipeId).then((recipe) {
if (mounted && recipe != null) setState(() => _session = CookingSession(recipe));
});
}
@override
void dispose() {
_keepScreenOn(false);
_pages.dispose();
super.dispose();
}
Future<void> _keepScreenOn(bool on) async {
try {
await WakelockPlus.toggle(enable: on);
} on PlatformException {
// Not fatal: cooking works with the normal screen timeout too.
} on MissingPluginException {
// Widget tests and platforms without the plugin.
}
}
/// Start page, one page per Step, end page.
int get _pageCount => _session!.steps.length + 2;
void _goTo(int page) =>
_pages.animateToPage(page, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic);
void _toggle(String key) => setState(() => _checked.contains(key) ? _checked.remove(key) : _checked.add(key));
void _showIngredients() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.92,
builder: (context, scroll) => ListView(
controller: scroll,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
Text(AppLocalizations.of(context).ingredients, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
IngredientChecklist(
groups: _session!.ingredientGroups,
checked: _checked,
large: true,
onToggle: (key) {
_toggle(key);
setSheetState(() {});
},
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final session = _session;
return Scaffold(
backgroundColor: c.background,
body: Stack(
children: [
const Positioned.fill(child: Opacity(opacity: 0.6, child: HerbPattern())),
SafeArea(
child: session == null
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_TopBar(
progress: _page / (_pageCount - 1),
showIngredients: _page > 0 && _page < _pageCount - 1,
onClose: () => Navigator.maybePop(context),
onIngredients: _showIngredients,
),
Expanded(
child: PageView(
controller: _pages,
onPageChanged: (page) => setState(() => _page = page),
children: [
_StartPage(
session: session,
checked: _checked,
onToggle: _toggle,
onServingsChanged: () => setState(() {}),
),
for (final step in session.steps) _StepPage(step: step, total: session.steps.length),
const _DonePage(),
],
),
),
if (_page < _pageCount - 1)
_BottomBar(
page: _page,
lastStep: _page == _pageCount - 2,
onPrevious: () => _goTo(_page - 1),
onNext: () => _goTo(_page + 1),
),
],
),
),
],
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({
required this.progress,
required this.showIngredients,
required this.onClose,
required this.onIngredients,
});
final double progress;
final bool showIngredients;
final VoidCallback onClose;
final VoidCallback onIngredients;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final round = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(48, 48),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
IconButton(tooltip: l10n.cookClose, onPressed: onClose, style: round, icon: const Icon(Icons.close_rounded)),
const SizedBox(width: 12),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(value: progress, minHeight: 10, color: c.brand, backgroundColor: c.navBar),
),
),
const SizedBox(width: 12),
AnimatedOpacity(
opacity: showIngredients ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: IconButton(
tooltip: l10n.ingredients,
onPressed: showIngredients ? onIngredients : null,
style: round,
icon: const Icon(Icons.shopping_basket_outlined),
),
),
],
),
);
}
}
class _StartPage extends StatelessWidget {
const _StartPage({
required this.session,
required this.checked,
required this.onToggle,
required this.onServingsChanged,
});
final CookingSession session;
final Set<String> checked;
final ValueChanged<String> onToggle;
final VoidCallback onServingsChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
Text(
session.recipe.name.toUpperCase(),
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: c.green),
),
const SizedBox(height: 4),
Text(
l10n.cookReadyTitle,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 30, height: 1.1, color: c.ink),
),
const SizedBox(height: 6),
Text(l10n.cookReadyBody, style: TextStyle(fontSize: 16, color: c.inkMuted)),
if (session.canScale) ...[
const SizedBox(height: 16),
ServingsStepper(
servings: session.servings!,
onFewer: session.servings! > 1
? () {
session.fewerServings();
onServingsChanged();
}
: null,
onMore: () {
session.moreServings();
onServingsChanged();
},
),
],
const SizedBox(height: 12),
IngredientChecklist(groups: session.ingredientGroups, checked: checked, onToggle: onToggle, large: true),
if (session.steps.isEmpty) ...[
const SizedBox(height: 16),
Text(l10n.cookNoSteps, style: TextStyle(fontSize: 16, color: c.inkMuted)),
],
],
);
}
}
class _StepPage extends StatelessWidget {
const _StepPage({required this.step, required this.total});
final CookingStep step;
final int total;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
children: [
Row(
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(color: c.lime, shape: BoxShape.circle),
child: Text(
'${step.number}',
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 20, color: c.onBrand),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.cookStepOf(step.number, total),
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 15, color: c.inkMuted),
),
),
if (step.section != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(12)),
child: Text(
step.section!,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 15, color: c.green),
),
),
],
),
const SizedBox(height: 24),
Text(
step.text,
style: TextStyle(fontSize: 24, height: 1.45, fontWeight: FontWeight.w600, color: c.ink),
),
],
);
}
}
class _DonePage extends StatelessWidget {
const _DonePage();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(color: c.lime, shape: BoxShape.circle),
child: Icon(Icons.restaurant_rounded, size: 56, color: c.onBrand),
),
const SizedBox(height: 24),
Text(
l10n.cookDoneTitle,
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 32, color: c.ink),
),
const SizedBox(height: 8),
Text(
l10n.cookDoneBody,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 17, color: c.inkMuted),
),
const SizedBox(height: 32),
SizedBox(
height: 56,
child: FilledButton(
onPressed: () => Navigator.maybePop(context),
style: FilledButton.styleFrom(
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 32),
textStyle: const TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 17),
),
child: Text(l10n.cookBackToRecipe),
),
),
],
),
);
}
}
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.page, required this.lastStep, required this.onPrevious, required this.onNext});
final int page;
final bool lastStep;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
const textStyle = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 18);
final label = page == 0 ? l10n.cookLetsGo : (lastStep ? l10n.cookFinish : l10n.cookNext);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
if (page > 0) ...[
IconButton(
tooltip: l10n.cookPrevious,
onPressed: onPrevious,
style: IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(64, 64),
side: BorderSide(color: c.chipBorder, width: 2),
),
icon: const Icon(Icons.arrow_back_rounded, size: 28),
),
const SizedBox(width: 12),
],
Expanded(
child: SizedBox(
height: 64,
child: FilledButton(
onPressed: onNext,
style: FilledButton.styleFrom(shape: const StadiumBorder(), textStyle: textStyle),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label),
const SizedBox(width: 8),
Icon(lastStep ? Icons.check_rounded : Icons.arrow_forward_rounded),
],
),
),
),
),
],
),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import '../recipes/models/recipe.dart';
/// One Step as Cooking Mode shows it: its text, its Step Section and its number.
class CookingStep {
const CookingStep({required this.number, required this.text, this.section});
final int number;
final String text;
final String? section;
}
/// A Recipe being cooked: its Steps one after another and its ingredients
/// for the number of servings the cook chose.
class CookingSession {
CookingSession(this.recipe) : servings = recipe.servings;
final Recipe recipe;
/// Servings the cook is making; null when the Recipe has no Servings to scale from.
double? servings;
late final List<CookingStep> steps = () {
var number = 0;
return [
for (final section in recipe.stepSections)
for (final step in section.steps) CookingStep(number: ++number, text: step, section: section.title),
];
}();
bool get canScale => recipe.servings != null && recipe.servings! > 0;
double get factor => canScale && servings != null ? servings! / recipe.servings! : 1;
List<IngredientGroup> get ingredientGroups => [
for (final g in recipe.ingredientGroups)
IngredientGroup(name: g.name, lines: [for (final l in g.lines) l.scaled(factor)]),
];
void moreServings() {
if (servings == null) return;
servings = servings!.floorToDouble() + 1;
}
void fewerServings() {
if (servings == null) return;
final next = servings!.ceilToDouble() - 1;
servings = next < 1 ? 1 : next;
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../recipes/models/course.dart';
import '../recipes/models/recipe.dart';
import '../planning/models/meal.dart';
import '../recipes/models/unit.dart';
part 'database.g.dart';
/// Courses the cook can assign to Recipes, in the order they edited.
@DataClassName('CourseRow')
class Courses extends Table {
TextColumn get id => text()();
TextColumn get name => text().unique()();
TextColumn get emoji => text().nullable()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {id};
}
/// Units the cook can pick for an Ingredient Line: the built-in ones (labels come
/// from the localization files) and the cook's own (with a [label]). See docs/adr/0003.
@DataClassName('UnitRow')
class Units extends Table {
TextColumn get key => text()();
TextColumn get label => text().nullable()();
BoolColumn get builtIn => boolean()();
/// Offered directly in the unit picker rather than under "Weitere Einheiten".
BoolColumn get direct => boolean()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {key};
}
/// One row per Recipe. The parts that are only ever read and written together
/// with their Recipe are stored as JSON (see docs/adr/0002).
@DataClassName('RecipeRow')
class Recipes extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get courseId => text().nullable().references(Courses, #id, onDelete: KeyAction.setNull)();
TextColumn get tags => text().map(const StringListConverter())();
RealColumn get servings => real().nullable()();
IntColumn get prepMinutes => integer().nullable()();
IntColumn get cookMinutes => integer().nullable()();
IntColumn get kcal => integer().nullable()();
TextColumn get caloriesBasis => text().nullable()();
TextColumn get ingredientGroups => text().map(const IngredientGroupsConverter())();
TextColumn get stepSections => text().map(const StepSectionsConverter())();
TextColumn get photos => text().map(const StringListConverter())();
TextColumn get sources => text().map(const StringListConverter())();
TextColumn get notes => text().nullable()();
BoolColumn get favourite => boolean().withDefault(const Constant(false))();
TextColumn get reviewReasons => text().map(const StringListConverter())();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// The Meals each day of the Week Plan is divided into, in the cook's order. A
/// deleted Meal that past Plan Entries still use stays as [retired], so the
/// history keeps its name.
@DataClassName('MealRow')
class Meals extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
IntColumn get position => integer()();
BoolColumn get retired => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
/// One Plan Entry: a Recipe or a note for a Meal on a day ("yyyy-MM-dd").
@DataClassName('PlanEntryRow')
class PlanEntries extends Table {
TextColumn get id => text()();
TextColumn get day => text()();
TextColumn get mealId => text().references(Meals, #id)();
TextColumn get recipeId => text().nullable().references(Recipes, #id, onDelete: KeyAction.setNull)();
TextColumn get note => text().nullable()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [Courses, Recipes, Units, Meals, PlanEntries])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.executor);
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await batch((b) {
for (final (i, (name, emoji)) in defaultCourses.indexed) {
b.insert(
courses,
CoursesCompanion.insert(id: const Uuid().v4(), name: name, emoji: Value(emoji), position: i),
);
}
});
await _seedUnits();
await _seedMeals();
},
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.createTable(units);
await _seedUnits();
}
if (from < 3) {
await m.createTable(meals);
await m.createTable(planEntries);
await _seedMeals();
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
extension on AppDatabase {
Future<void> _seedUnits() => batch((b) {
for (final (i, unit) in Unit.values.indexed) {
b.insert(units, UnitsCompanion.insert(key: unit.key, builtIn: true, direct: unit.common, position: i));
}
});
Future<void> _seedMeals() => batch((b) {
for (final (i, name) in defaultMeals.indexed) {
b.insert(meals, MealsCompanion.insert(id: const Uuid().v4(), name: name, position: i));
}
});
}
class StringListConverter extends TypeConverter<List<String>, String> {
const StringListConverter();
@override
List<String> fromSql(String fromDb) => [for (final s in jsonDecode(fromDb) as List) s as String];
@override
String toSql(List<String> value) => jsonEncode(value);
}
class IngredientGroupsConverter extends TypeConverter<List<IngredientGroup>, String> {
const IngredientGroupsConverter();
@override
List<IngredientGroup> fromSql(String fromDb) => [
for (final g in jsonDecode(fromDb) as List) IngredientGroup.fromJson(g as Map<String, dynamic>),
];
@override
String toSql(List<IngredientGroup> value) => jsonEncode([for (final g in value) g.toJson()]);
}
class StepSectionsConverter extends TypeConverter<List<StepSection>, String> {
const StepSectionsConverter();
@override
List<StepSection> fromSql(String fromDb) => [
for (final s in jsonDecode(fromDb) as List) StepSection.fromJson(s as Map<String, dynamic>),
];
@override
String toSql(List<StepSection> value) => jsonEncode([for (final s in value) s.toJson()]);
}
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'cookit_colors.dart';
/// The "Gewürzregal" look: mint ground, lime and sage accents, Nunito for
/// headings and Nunito Sans for reading. Colours live in [CookItColors].
class AppTheme {
static const seed = Color(0xFF65A86C);
static const headingFont = 'Nunito';
static const bodyFont = 'NunitoSans';
static const radius = 22.0;
static ThemeData light() => _build(Brightness.light, CookItColors.light);
static ThemeData dark() => _build(Brightness.dark, CookItColors.dark);
static ThemeData _build(Brightness brightness, CookItColors c) {
final scheme =
ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
).copyWith(
primary: c.green,
onPrimary: brightness == Brightness.light ? Colors.white : c.onBrand,
secondaryContainer: c.sage,
onSecondaryContainer: c.ink,
surface: c.background,
onSurface: c.ink,
onSurfaceVariant: c.inkMuted,
surfaceContainerLow: c.surface,
surfaceContainerHighest: brightness == Brightness.light ? const Color(0xFFD6EAC6) : const Color(0xFF35573B),
);
final base = ThemeData(colorScheme: scheme, useMaterial3: true, fontFamily: bodyFont);
final text = base.textTheme.apply(bodyColor: c.ink, displayColor: c.ink);
TextStyle? heading(TextStyle? s, FontWeight w) => s?.copyWith(fontFamily: headingFont, fontWeight: w);
return base.copyWith(
extensions: [c],
scaffoldBackgroundColor: c.background,
textTheme: text.copyWith(
displaySmall: heading(text.displaySmall, FontWeight.w900),
headlineLarge: heading(text.headlineLarge, FontWeight.w900),
headlineMedium: heading(text.headlineMedium, FontWeight.w900),
headlineSmall: heading(text.headlineSmall, FontWeight.w800),
titleLarge: heading(text.titleLarge, FontWeight.w800),
titleMedium: heading(text.titleMedium, FontWeight.w800),
titleSmall: heading(text.titleSmall, FontWeight.w800),
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w800),
),
appBarTheme: AppBarTheme(
backgroundColor: c.background,
surfaceTintColor: Colors.transparent,
foregroundColor: c.ink,
centerTitle: false,
titleTextStyle: heading(text.titleLarge, FontWeight.w900)?.copyWith(color: c.ink),
),
cardTheme: CardThemeData(
elevation: 0,
color: c.surface,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: c.cardBorder == null ? BorderSide.none : BorderSide(color: c.cardBorder!),
),
),
chipTheme: ChipThemeData(
shape: const StadiumBorder(),
side: BorderSide(color: c.chipBorder, width: 2),
backgroundColor: c.surface,
labelStyle: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: c.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: c.brand, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: c.brand,
foregroundColor: c.onBrand,
textStyle: const TextStyle(fontFamily: bodyFont, fontWeight: FontWeight.w800),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: c.green,
side: BorderSide(color: c.chipBorder, width: 2),
textStyle: const TextStyle(fontFamily: bodyFont, fontWeight: FontWeight.w800),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? c.sage : c.surface,
),
foregroundColor: WidgetStatePropertyAll(c.ink),
side: WidgetStatePropertyAll(BorderSide(color: c.chipBorder, width: 2)),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: c.brand,
foregroundColor: c.onBrand,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)),
),
dialogTheme: DialogThemeData(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius + 6)),
),
popupMenuTheme: PopupMenuThemeData(color: c.surface),
bottomSheetTheme: BottomSheetThemeData(backgroundColor: c.surface),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
// Above the floating navigation bar and the "Kochmodus starten" button.
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
/// The "Gewürzregal" palette: soft mint ground, lime and sage highlights, honey
/// for things to check. Read it with `CookItColors.of(context)`.
@immutable
class CookItColors extends ThemeExtension<CookItColors> {
const CookItColors({
required this.background,
required this.surface,
this.cardBorder,
required this.ink,
required this.inkMuted,
required this.green,
required this.brand,
required this.onBrand,
required this.lime,
required this.sage,
required this.chipBorder,
required this.navBar,
required this.honey,
required this.onHoney,
required this.favourite,
required this.tiles,
required this.patternOpacity,
required this.planCell,
required this.weekendTint,
required this.noteFill,
required this.noteBorder,
required this.noteInk,
});
/// Page background behind the herb pattern.
final Color background;
/// Cards, search field, chips.
final Color surface;
/// Hairline around Recipe cards where the surface alone does not stand out enough.
final Color? cardBorder;
final Color ink;
final Color inkMuted;
/// Readable green for text, icons and tags on light surfaces.
final Color green;
/// CookIt green (#65A86C) for the add button and selected states.
final Color brand;
final Color onBrand;
final Color lime;
/// Soft sage for the suggestion card and the active navigation pill.
final Color sage;
final Color chipBorder;
final Color navBar;
/// Review Flag colours.
final Color honey;
final Color onHoney;
final Color favourite;
/// Soft lime, sage and peach for small decorative tiles (e.g. a Recipe's facts).
final List<Color> tiles;
final double patternOpacity;
/// Week Plan grid: a filled cell, the weekend rows, and notes (warm, so they
/// stand apart from Recipes).
final Color planCell;
final Color weekendTint;
final Color noteFill;
final Color noteBorder;
final Color noteInk;
static const light = CookItColors(
background: Color(0xFFE3F1D5),
surface: Color(0xFFFFFFFF),
ink: Color(0xFF17331A),
inkMuted: Color(0xFF3C5A35),
green: Color(0xFF2E6B37),
brand: Color(0xFF65A86C),
onBrand: Color(0xFF0E2A13),
lime: Color(0xFFD4EC6B),
sage: Color(0xFFA8D896),
chipBorder: Color(0xFFA8D896),
navBar: Color(0xFFCFE8BE),
honey: Color(0xFFFFE29A),
onHoney: Color(0xFF4F3A00),
favourite: Color(0xFFB3261E),
tiles: [Color(0xFFEAF5C4), Color(0xFFD9EFCC), Color(0xFFFBE4D6)],
patternOpacity: 1,
planCell: Color(0xFFF3F9EE),
weekendTint: Color(0xFFDDEBCF),
noteFill: Color(0xFFFFF4D6),
noteBorder: Color(0xFFF2DC9A),
noteInk: Color(0xFF5A4A1A),
);
// A deep forest ground with clearly lifted cards, so they stand apart.
static const dark = CookItColors(
background: Color(0xFF14231A),
surface: Color(0xFF2D4C35),
cardBorder: Color(0xFF41694A),
ink: Color(0xFFEAF5E1),
inkMuted: Color(0xFFC3DBB8),
green: Color(0xFFB4E0A3),
brand: Color(0xFF7BBE82),
onBrand: Color(0xFF0E2A13),
lime: Color(0xFFD4EC6B),
sage: Color(0xFF4A7D50),
chipBorder: Color(0xFF4A7D50),
navBar: Color(0xFF35573B),
honey: Color(0xFF6B5310),
onHoney: Color(0xFFFFE6A8),
favourite: Color(0xFFFFB4AB),
tiles: [Color(0xFF41522A), Color(0xFF335639), Color(0xFF5A4433)],
patternOpacity: 0.35,
planCell: Color(0xFF365A3F),
weekendTint: Color(0xFF3A5536),
noteFill: Color(0xFF4A4122),
noteBorder: Color(0xFF6E5E2C),
noteInk: Color(0xFFF4E3B0),
);
static CookItColors of(BuildContext context) => Theme.of(context).extension<CookItColors>() ?? light;
@override
CookItColors copyWith() => this;
@override
CookItColors lerp(CookItColors? other, double t) => t < 0.5 || other == null ? this : other;
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'cookit_colors.dart';
class NavDestination {
const NavDestination({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
}
/// The floating, soft-green bar at the bottom: the selected destination is a
/// white pill with its label, the others are icons.
class CookItNavBar extends StatelessWidget {
const CookItNavBar({super.key, required this.destinations, required this.selected});
static const height = 68.0;
final List<NavDestination> destinations;
final int selected;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: c.navBar,
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [BoxShadow(color: c.green.withValues(alpha: 0.16), blurRadius: 20, offset: const Offset(0, 6))],
),
child: Row(
children: [
for (final (i, d) in destinations.indexed)
Expanded(
// The selected pill carries a label, so it gets more of the width.
flex: i == selected ? 2 : 1,
child: Center(
child: i == selected
? Semantics(
selected: true,
button: true,
child: Material(
color: c.surface,
shape: const StadiumBorder(),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: d.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(d.icon, size: 20, color: c.green),
const SizedBox(width: 8),
Flexible(
child: Text(
d.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: c.green, fontWeight: FontWeight.w900, fontSize: 14),
),
),
],
),
),
),
),
)
: IconButton(
tooltip: d.label,
onPressed: d.onTap,
icon: Icon(d.icon, color: c.green),
),
),
),
],
),
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'cookit_colors.dart';
// The "Gewürzregal" decorations, drawn for a 390 px wide phone and scaled to the screen width.
const _designWidth = 390.0;
const _leaf =
'<path d="M0 60C0 24 30 0 72 0c0 42-30 66-72 60z"/>'
'<path d="M6 54 66 6" stroke="#1F4D27" stroke-opacity=".15" stroke-width="3" stroke-linecap="round" fill="none"/>';
const _mint = '''
<path d="M24 47V12" fill="none" stroke="#9BD18A" stroke-width="2.4" stroke-linecap="round"/>
<ellipse cx="24" cy="9" rx="5" ry="8"/>
<ellipse cx="15" cy="21" rx="5" ry="9" transform="rotate(-55 15 21)"/>
<ellipse cx="33" cy="21" rx="5" ry="9" transform="rotate(55 33 21)"/>
<ellipse cx="14" cy="35" rx="5.5" ry="10" transform="rotate(-62 14 35)"/>
<ellipse cx="34" cy="35" rx="5.5" ry="10" transform="rotate(62 34 35)"/>
<path d="M24 4v10M10 17l9 6M38 17l-9 6M8 30l11 7M40 30l-11 7" fill="none" stroke="#FFFFFF" stroke-opacity=".5" stroke-width="1.4" stroke-linecap="round"/>''';
const _parsley = '''
<path d="M24 47V26M24 26 14 15M24 26 34 15M24 26V11" fill="none" stroke="#65A86C" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="5"/><circle cx="7" cy="17" r="4"/><circle cx="15" cy="7" r="4"/>
<circle cx="24" cy="8" r="5"/><circle cx="20" cy="4" r="3.5"/><circle cx="28" cy="4" r="3.5"/>
<circle cx="36" cy="12" r="5"/><circle cx="41" cy="17" r="4"/><circle cx="33" cy="7" r="4"/>''';
const _chili = '''
<path d="M8 38c12 3 25-5 29-22" stroke-width="6"/>
<path d="M37 16c1-5 4-8 8-8" stroke-width="2.4"/>''';
const _pepper = '<circle cx="16" cy="26" r="5.5"/><circle cx="29" cy="22" r="5.5"/><circle cx="23" cy="35" r="5.5"/>';
/// The cluster around the home screen's title: big lime leaf, sage leaf and mint.
const _headerHeight = 220.0;
const _headerSvg =
'''
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="220" viewBox="0 0 390 220">
<g transform="rotate(20 325 28) translate(270 -18) scale(1.53)" fill="#D4EC6B">$_leaf</g>
<g transform="rotate(110 358 119) translate(330 96) scale(0.778)" fill="#A8D896">$_leaf</g>
<g transform="rotate(-18 249 99) translate(222 72) scale(1.125)" fill="#9BD18A">$_mint</g>
</svg>''';
/// One tile of edge decorations; tiles repeat down the page.
const _edgeHeight = 844.0;
const _edgeSvg =
'''
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="844" viewBox="0 0 390 844">
<g transform="rotate(25 13 329) translate(-12 304) scale(1.042)" fill="#65A86C" opacity=".55">$_parsley</g>
<g transform="rotate(140 377 409) translate(352 388) scale(0.694)" fill="#D4EC6B">$_leaf</g>
<g transform="translate(-12 560) scale(1.042)" fill="none" stroke="#A8D896" stroke-linecap="round">$_chili</g>
<g transform="translate(356 600) scale(0.625)" fill="#65A86C" opacity=".45">$_pepper</g>
<g transform="rotate(-150 372 790) translate(350 774) scale(0.6)" fill="#A8D896">$_leaf</g>
<g transform="rotate(-30 10 110) translate(-14 92) scale(0.55)" fill="#A8D896">$_leaf</g>
<g transform="translate(360 196) scale(0.5)" fill="#65A86C" opacity=".4">$_pepper</g>
</svg>''';
Widget _faded(BuildContext context, Widget child) => ExcludeSemantics(
child: Opacity(opacity: CookItColors.of(context).patternOpacity, child: child),
);
/// Decorations that belong to a page's header and scroll with it. Place it in a
/// Stack behind the header content, anchored at the top.
class HeaderDecor extends StatelessWidget {
const HeaderDecor({super.key});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return _faded(
context,
SizedBox(
width: width,
height: width / _designWidth * _headerHeight,
child: SvgPicture.string(_headerSvg, fit: BoxFit.fill),
),
);
}
}
/// The scattered edge decorations, repeating down the page and moving at
/// [speed] times the scroll speed of [controller], for a sense of depth.
/// Without a controller they stand still.
class ParallaxEdgePattern extends StatelessWidget {
const ParallaxEdgePattern({super.key, this.controller, this.speed = 1 / 3});
final ScrollController? controller;
final double speed;
@override
Widget build(BuildContext context) {
return _faded(
context,
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final tile = width / _designWidth * _edgeHeight;
final count = (constraints.maxHeight / tile).ceil() + 1;
final picture = SvgPicture.string(_edgeSvg, fit: BoxFit.fill);
Widget tiles(double shift) => Stack(
children: [
for (var i = 0; i < count; i++)
Positioned(left: 0, top: i * tile - shift, width: width, height: tile, child: picture),
],
);
final scroll = controller;
if (scroll == null) return ClipRect(child: tiles(0));
return ClipRect(
child: AnimatedBuilder(
animation: scroll,
builder: (context, _) {
// Negative offsets too: the Week Plan scrolls up into past weeks.
final offset = scroll.hasClients ? scroll.offset : 0.0;
return tiles((offset * speed) % tile);
},
),
);
},
),
);
}
}
/// Header and edge decorations together, standing still (for calm screens like Cooking Mode).
class HerbPattern extends StatelessWidget {
const HerbPattern({super.key});
@override
Widget build(BuildContext context) {
return const Stack(
children: [
Positioned.fill(child: ParallaxEdgePattern()),
Positioned(top: 0, left: 0, right: 0, child: HeaderDecor()),
],
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
/// The herb pattern's leaf shape, for decorating cards and tiles.
class LeafPainter extends CustomPainter {
const LeafPainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final sx = size.width / 72;
final sy = size.height / 60;
final leaf = Path()
..moveTo(0, 60 * sy)
..cubicTo(0, 24 * sy, 30 * sx, 0, 72 * sx, 0)
..cubicTo(72 * sx, 42 * sy, 42 * sx, 66 * sy, 0, 60 * sy)
..close();
canvas.drawPath(leaf, Paint()..color = color);
canvas.drawLine(
Offset(6 * sx, 54 * sy),
Offset(66 * sx, 6 * sy),
Paint()
..color = const Color(0x261F4D27)
..strokeWidth = 3 * sx
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(LeafPainter old) => old.color != color;
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../design/cookit_nav_bar.dart';
import '../localization/app_localizations.dart';
import '../planning/views/week_plan_view.dart';
import '../recipes/views/recipe_list_view.dart';
/// The two main tabs, Rezepte and Wochenplan, under the floating navigation bar.
class HomeView extends StatefulWidget {
const HomeView({super.key});
static const routeName = '/';
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
var _tab = 0;
/// An entry of the Week Plan is being dragged: the plan shows "remove" where the bar was.
var _dragging = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
return PopScope(
// Back on the Wochenplan returns to the Recipes first.
canPop: _tab == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(() => _tab = 0);
},
child: Scaffold(
body: Stack(
children: [
IndexedStack(
index: _tab,
children: [
const RecipeListView(),
WeekPlanView(onDraggingChanged: (dragging) => setState(() => _dragging = dragging)),
],
),
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
child: IgnorePointer(
ignoring: _dragging,
child: AnimatedOpacity(
opacity: _dragging ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: CookItNavBar(
selected: _tab,
destinations: [
NavDestination(
icon: Icons.menu_book_rounded,
label: l10n.navRecipes,
onTap: () => setState(() => _tab = 0),
),
NavDestination(
icon: Icons.calendar_month_rounded,
label: l10n.navWeekPlan,
onTap: () => setState(() => _tab = 1),
),
],
),
),
),
),
],
),
),
);
}
}
+349
View File
@@ -0,0 +1,349 @@
{
"@@locale": "de",
"appTitle": "CookIt",
"searchHint": "Rezept, Zutat oder Stichwort",
"homeTitle": "Was kochen wir heute?",
"greetingMorning": "Guten Morgen",
"greetingDay": "Guten Tag",
"greetingEvening": "Guten Abend",
"greetingLunch": "Mahlzeit!",
"greetingLate": "Schönen Abend",
"suggestionLabel": "Idee für heute",
"anotherIdea": "Andere Idee",
"decisionButton": "Hilf mir entscheiden",
"decisionCourseQuestion": "Worauf hast du Lust?",
"decisionCourseHint": "Mehrere möglich – nichts gewählt heißt egal.",
"decisionTimeQuestion": "Wie viel Zeit hast du?",
"decisionUpTo30": "bis 30 Min.",
"decisionUpTo60": "bis 60 Min.",
"decisionAnyTime": "Egal",
"decisionNext": "Weiter",
"decisionShow": "Vorschlag zeigen",
"decisionMatches": "{count, plural, =1{1 Rezept passt} other{{count} Rezepte passen}}",
"@decisionMatches": { "placeholders": { "count": { "type": "int" } } },
"decisionAnother": "Noch einer",
"decisionOpen": "Rezept öffnen",
"decisionStartOver": "Neu wählen",
"decisionNoneTitle": "Dazu passt leider nichts.",
"decisionWithoutTime": "Ohne Zeitlimit: {count}",
"@decisionWithoutTime": { "placeholders": { "count": { "type": "int" } } },
"decisionAllCourses": "Alle Gänge: {count}",
"@decisionAllCourses": { "placeholders": { "count": { "type": "int" } } },
"reviewBanner": "{count, plural, =1{1 Rezept wartet auf deinen Blick} other{{count} Rezepte warten auf deinen Blick}}",
"@reviewBanner": { "placeholders": { "count": { "type": "int" } } },
"reviewBannerActive": "Nur Rezepte zum Prüfen – alle zeigen",
"filterAll": "Alle",
"navRecipes": "Rezepte",
"navWeekPlan": "Wochenplan",
"durationMinutes": "{minutes} Min.",
"@durationMinutes": { "placeholders": { "minutes": { "type": "int" } } },
"durationHours": "{hours} Std.",
"@durationHours": { "placeholders": { "hours": { "type": "int" } } },
"factTotal": "Gesamt",
"factServings": "Portionen",
"factCalories": "Kalorien",
"kcalValue": "{kcal} kcal",
"@kcalValue": { "placeholders": { "kcal": { "type": "int" } } },
"tabIngredients": "Zutaten · {count}",
"@tabIngredients": { "placeholders": { "count": { "type": "int" } } },
"tabSteps": "Zubereitung · {count}",
"@tabSteps": { "placeholders": { "count": { "type": "int" } } },
"back": "Zurück",
"cookStart": "Kochmodus starten",
"cookClose": "Kochmodus beenden",
"cookReadyTitle": "Bereit zum Kochen?",
"cookReadyBody": "Leg dir alles bereit – abhaken hilft.",
"cookLetsGo": "Los geht’s",
"cookStepOf": "Schritt {number} von {total}",
"@cookStepOf": { "placeholders": { "number": { "type": "int" }, "total": { "type": "int" } } },
"cookNext": "Weiter",
"cookPrevious": "Zurück",
"cookFinish": "Fertig",
"cookDoneTitle": "Guten Appetit!",
"cookDoneBody": "Alle Schritte geschafft. Lass es dir schmecken.",
"cookBackToRecipe": "Zurück zum Rezept",
"cookNoSteps": "Für dieses Rezept ist keine Zubereitung eingetragen.",
"servingsFewer": "Weniger Portionen",
"servingsMore": "Mehr Portionen",
"shareIngredients": "Zutaten teilen",
"shareIngredientsHint": "Jede Zutat steht in einer eigenen Zeile – so kann z. B. Bring! sie als einzelne Artikel übernehmen.",
"shareIngredientsSubject": "{name} – Zutaten",
"@shareIngredientsSubject": { "placeholders": { "name": { "type": "String" } } },
"shareIngredientsSubjectServings": "{name} – Zutaten für {servings} Portionen",
"@shareIngredientsSubjectServings": { "placeholders": { "name": { "type": "String" }, "servings": { "type": "String" } } },
"share": "Teilen",
"shareRecipe": "Rezept teilen",
"shareRecipeHint": "Auf dem anderen Handy in CookIt oben auf „Rezept scannen“ tippen und diesen Code scannen – ganz ohne Internet. Fotos reisen nur als Datei mit.",
"includeNotes": "Notizen mitschicken",
"shareAsFile": "Als Datei teilen",
"shareAsFileHint": "Mit Fotos – z. B. über Quick Share, auch ohne Internet.",
"codeTooLong": "Dieses Rezept ist zu lang für einen Code. Teile es als Datei.",
"scanRecipe": "Rezept scannen",
"scanHint": "Halte die Kamera auf den Rezept-Code.",
"notARecipeCode": "Das ist kein CookIt-Rezept-Code.",
"codeFromNewerVersion": "Dieser Code stammt aus einer neueren CookIt-Version. Bitte CookIt aktualisieren.",
"codeDamaged": "Der Code ließ sich nicht lesen. Versuch es noch einmal.",
"cameraUnavailable": "Die Kamera ist nicht verfügbar. Erlaube CookIt den Kamerazugriff in den Android-Einstellungen.",
"receivedRecipe": "Rezept empfangen",
"recipeNameTaken": "Du hast schon ein Rezept „{name}“. Beim Speichern kommt dieses als eigenes dazu.",
"@recipeNameTaken": { "placeholders": { "name": { "type": "String" } } },
"recipeSaved": "„{name}“ gespeichert",
"@recipeSaved": { "placeholders": { "name": { "type": "String" } } },
"fileUnreadable": "Die Datei ließ sich nicht öffnen: {message}",
"@fileUnreadable": { "placeholders": { "message": { "type": "String" } } },
"plannedToday": "Heute geplant",
"plannedTodayMore": "{count, plural, =1{+1 weiterer Eintrag} other{+{count} weitere Einträge}}",
"@plannedTodayMore": { "placeholders": { "count": { "type": "int" } } },
"showIdea": "Lieber eine Idee",
"weekPlanTitle": "Wochenplan",
"jumpToToday": "Heute",
"thisWeek": "Diese Woche",
"nextWeek": "Nächste Woche",
"lastWeek": "Letzte Woche",
"calendarWeek": "KW {number}",
"@calendarWeek": { "placeholders": { "number": { "type": "int" } } },
"todayMarker": "heute",
"previousWeek": "Vorige Woche",
"dropToRemove": "Hier ablegen zum Entfernen",
"dropAdd": "+ dazulegen",
"cook": "Kochen",
"todayBanner": "Heute · {weekday}",
"@todayBanner": { "placeholders": { "weekday": { "type": "String" } } },
"addAnother": "Weiteres hinzufügen",
"entryRemoved": "„{name}“ entfernt",
"@entryRemoved": { "placeholders": { "name": { "type": "String" } } },
"undo": "Rückgängig",
"entryEdit": "Bearbeiten",
"editNoteTitle": "Notiz bearbeiten",
"copy": "Kopieren",
"copied": "In die Zwischenablage kopiert",
"shareMenu": "Teilen",
"shareWeekPlan": "Wochenplan teilen",
"shareWeekPlanHint": "Als Datei an eine andere CookIt-App – mit allen Rezepten und Fotos.",
"shareWeekIngredientsHint": "Zusammengezählt, z. B. für die Einkaufsliste.",
"weekPlanFileName": "Wochenplan {range}",
"@weekPlanFileName": { "placeholders": { "range": { "type": "String" } } },
"receivedWeek": "Wochenplan empfangen",
"receivedWeekEntries": "{count, plural, =1{1 Eintrag} other{{count} Einträge}}",
"@receivedWeekEntries": { "placeholders": { "count": { "type": "int" } } },
"receivedWeekNewRecipes": "{count, plural, =0{keine neuen Rezepte} =1{1 neues Rezept} other{{count} neue Rezepte}}",
"@receivedWeekNewRecipes": { "placeholders": { "count": { "type": "int" } } },
"receivedWeekExisting": "{count, plural, =1{Du hast in dieser Woche schon 1 Eintrag – die neuen kommen dazu.} other{Du hast in dieser Woche schon {count} Einträge – die neuen kommen dazu.}}",
"@receivedWeekExisting": { "placeholders": { "count": { "type": "int" } } },
"adopt": "Übernehmen",
"weekAdopted": "Wochenplan {range} übernommen",
"@weekAdopted": { "placeholders": { "range": { "type": "String" } } },
"addEntry": "Eintrag hinzufügen",
"entryReroll": "Neu würfeln",
"entryMove": "Verschieben …",
"entryRemove": "Entfernen",
"noOtherRecipe": "Kein anderes passendes Rezept gefunden.",
"addEntryTitle": "{meal} · {day}",
"@addEntryTitle": { "placeholders": { "meal": { "type": "String" }, "day": { "type": "String" } } },
"addEntryHint": "Rezept suchen oder Notiz schreiben",
"addAsNote": "Als Notiz: „{text}“",
"@addAsNote": { "placeholders": { "text": { "type": "String" } } },
"planRecipe": "Einplanen",
"planWhichDay": "An welchem Tag?",
"planWhichMeal": "Zu welcher Mahlzeit?",
"planOtherDate": "Anderes Datum …",
"tomorrow": "Morgen",
"planned": "Eingeplant: {day} · {meal}",
"@planned": { "placeholders": { "day": { "type": "String" }, "meal": { "type": "String" } } },
"moveEntryTitle": "Verschieben",
"noMeals": "Lege zuerst in den Einstellungen eine Mahlzeit an.",
"fillWeek": "Woche füllen",
"fillWeekHint": "Füllt die leeren Tage ab heute mit zufälligen Rezepten – ohne Wiederholung in der Woche, und lieber mit Rezepten, die länger nicht dran waren.",
"fillWhichMeal": "Welche Mahlzeit?",
"fillAction": "Füllen",
"fillResult": "{count, plural, =0{Keine leeren Tage gefüllt.} =1{1 Tag gefüllt.} other{{count} Tage gefüllt.}}",
"@fillResult": { "placeholders": { "count": { "type": "int" } } },
"fillLeftEmpty": "{count, plural, =1{1 Tag bleibt leer – keine passenden Rezepte mehr.} other{{count} Tage bleiben leer – keine passenden Rezepte mehr.}}",
"@fillLeftEmpty": { "placeholders": { "count": { "type": "int" } } },
"fillNothingEmpty": "An den Tagen ab heute ist schon alles geplant.",
"shareWeek": "Zutaten der Woche teilen",
"shareWeekHint": "Gleiche Zutaten werden zusammengezählt. Jedes Rezept zählt mit seinen eigenen Portionen.",
"shareWeekSubject": "Zutaten für {range}",
"@shareWeekSubject": { "placeholders": { "range": { "type": "String" } } },
"shareWeekEmpty": "An den gewählten Tagen ist kein Rezept geplant.",
"settingsSectionPlanning": "Wochenplan",
"settingsMeals": "Mahlzeiten bearbeiten",
"settingsMealsSubtitle": "z. B. Frühstück, Mittag, Abend",
"settingsWeekStart": "Woche beginnt am",
"mealsTitle": "Mahlzeiten",
"addMeal": "Mahlzeit hinzufügen",
"editMeal": "Mahlzeit umbenennen",
"mealNameTaken": "Diese Mahlzeit gibt es schon.",
"deleteMealTitle": "Mahlzeit löschen?",
"deleteMealBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 geplanter Eintrag ab heute wird entfernt.} other{„{name}“ wird gelöscht. {count} geplante Einträge ab heute werden entfernt.}} Vergangene Wochen behalten sie.",
"@deleteMealBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"more": "Mehr",
"filterFavourites": "Favoriten",
"filterReview": "Zu prüfen ({count})",
"@filterReview": { "placeholders": { "count": { "type": "int" } } },
"newRecipe": "Neues Rezept",
"settings": "Einstellungen",
"emptyCollectionTitle": "Noch keine Rezepte",
"emptyCollectionBody": "Leg dein erstes Rezept an oder importiere ein Rezeptpaket in den Einstellungen.",
"noMatchesTitle": "Nichts gefunden",
"noMatchesBody": "Versuch es mit einem anderen Suchbegriff oder weniger Filtern.",
"resetFilters": "Filter zurücksetzen",
"recipeCount": "{count, plural, =1{1 Rezept} other{{count} Rezepte}}",
"@recipeCount": { "placeholders": { "count": { "type": "int" } } },
"favourite": "Favorit",
"removeFavourite": "Kein Favorit mehr",
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"save": "Speichern",
"deleteRecipeTitle": "Rezept löschen?",
"deleteRecipeBody": "„{name}“ und seine Fotos werden endgültig gelöscht.",
"@deleteRecipeBody": { "placeholders": { "name": { "type": "String" } } },
"recipeNotFound": "Dieses Rezept gibt es nicht mehr.",
"reviewTitle": "Bitte prüfen",
"reviewDone": "Erledigt",
"servings": "Portionen",
"servingsOne": "1 Portion",
"servingsValue": "{value} Portionen",
"@servingsValue": { "placeholders": { "value": { "type": "String" } } },
"minutesValue": "{value} Min.",
"@minutesValue": { "placeholders": { "value": { "type": "int" } } },
"prepTime": "Vorbereitung",
"cookTime": "Kochen",
"totalTime": "Gesamt",
"caloriesPerServing": "{kcal} kcal pro Portion",
"@caloriesPerServing": { "placeholders": { "kcal": { "type": "int" } } },
"caloriesWholeRecipe": "{kcal} kcal gesamt",
"@caloriesWholeRecipe": { "placeholders": { "kcal": { "type": "int" } } },
"ingredients": "Zutaten",
"steps": "Zubereitung",
"notes": "Notizen",
"sources": "Quellen",
"tags": "Stichwörter",
"noIngredients": "Keine Zutaten eingetragen.",
"noSteps": "Keine Zubereitung eingetragen.",
"editRecipe": "Rezept bearbeiten",
"discardTitle": "Änderungen verwerfen?",
"discardBody": "Deine Änderungen an diesem Rezept gehen verloren.",
"discard": "Verwerfen",
"keepEditing": "Weiter bearbeiten",
"fieldName": "Name",
"fieldNameRequired": "Bitte gib dem Rezept einen Namen.",
"fieldCourse": "Gang",
"noCourse": "Kein Gang",
"fieldServings": "Portionen",
"fieldPrep": "Vorbereitung",
"fieldCook": "Kochen",
"minutesSuffix": "Min.",
"fieldKcal": "Kalorien (kcal)",
"basisPerServing": "pro Portion",
"basisWholeRecipe": "gesamt",
"fieldTags": "Stichwörter",
"fieldTagsHint": "durch Komma getrennt, z. B. vegan, schnell",
"fieldNotes": "Notizen",
"fieldSources": "Quellen",
"fieldSourcesHint": "ein Link pro Zeile",
"invalidNumber": "Keine gültige Zahl",
"photos": "Fotos",
"addPhoto": "Foto hinzufügen",
"photoFromCamera": "Kamera",
"photoFromGallery": "Galerie",
"mainPhoto": "Hauptfoto",
"setMainPhoto": "Als Hauptfoto festlegen",
"photoHint": "Gedrückt halten und ziehen zum Sortieren – das erste Foto ist das Hauptfoto. Antippen für mehr.",
"removePhoto": "Foto entfernen",
"addIngredient": "Zutat",
"addIngredientGroup": "Gruppe",
"ingredientAmount": "Menge",
"ingredientUnit": "Einheit",
"ingredientName": "Zutat",
"ingredientRemark": "Hinweis, z. B. gehackt",
"addRemark": "Hinweis hinzufügen",
"invalidAmount": "z. B. 2, 1,5, ½ oder 2-3",
"groupName": "Gruppenname (optional)",
"noUnit": "–",
"addStep": "Schritt",
"addStepSection": "Abschnitt",
"stepText": "Schritt {number}",
"@stepText": { "placeholders": { "number": { "type": "int" } } },
"sectionTitle": "Abschnitt, z. B. Teig",
"removeItem": "Entfernen",
"unnamedGroup": "Weitere Zutaten",
"settingsCourses": "Gänge bearbeiten",
"settingsCoursesSubtitle": "Hinzufügen, umbenennen, sortieren",
"settingsImport": "Rezepte importieren",
"settingsImportSubtitle": "Rezeptpaket (.zip) laden",
"settingsExport": "Sammlung exportieren",
"settingsExportSubtitle": "Alle Rezepte mit Fotos als Backup speichern",
"settingsSectionCollection": "Sammlung",
"settingsSectionData": "Daten",
"settingsSectionAppearance": "Darstellung",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"importing": "Rezepte werden importiert …",
"importResult": "{added} neu, {skipped} übersprungen",
"@importResult": { "placeholders": { "added": { "type": "int" }, "skipped": { "type": "int" } } },
"importFailed": "Import fehlgeschlagen: {message}",
"@importFailed": { "placeholders": { "message": { "type": "String" } } },
"exporting": "Sammlung wird exportiert …",
"exportDone": "Sammlung gespeichert.",
"exportFailed": "Export fehlgeschlagen: {message}",
"@exportFailed": { "placeholders": { "message": { "type": "String" } } },
"settingsUnits": "Maßeinheiten bearbeiten",
"settingsUnitsSubtitle": "Eigene hinzufügen, sortieren, direkt anzeigen",
"unitsTitle": "Maßeinheiten",
"unitsDirectHint": "Eingeschaltete Einheiten erscheinen direkt in der Auswahl, die anderen unter „Weitere Einheiten“. Ziehen zum Sortieren.",
"addUnit": "Einheit hinzufügen",
"editUnit": "Einheit umbenennen",
"unitName": "Name",
"ownUnit": "Eigene Einheit",
"unitNameTaken": "Diese Einheit gibt es schon.",
"showDirectly": "Direkt anzeigen",
"deleteUnitTitle": "Einheit löschen?",
"deleteUnitBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 Rezept verwendet diese Einheit – dort wandert das Wort in den Zutatennamen.} other{„{name}“ wird gelöscht. {count} Rezepte verwenden diese Einheit – dort wandert das Wort in den Zutatennamen.}}",
"@deleteUnitBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"coursesTitle": "Gänge",
"addCourse": "Gang hinzufügen",
"editCourse": "Gang bearbeiten",
"courseName": "Name",
"courseEmoji": "Emoji (optional)",
"courseNameTaken": "Diesen Gang gibt es schon.",
"deleteCourseTitle": "Gang löschen?",
"deleteCourseBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 Rezept hat danach keinen Gang mehr.} other{„{name}“ wird gelöscht. {count} Rezepte haben danach keinen Gang mehr.}}",
"@deleteCourseBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"unitGram": "g",
"unitKilogram": "kg",
"unitMilliliter": "ml",
"unitLiter": "l",
"unitTablespoon": "EL",
"unitTeaspoon": "TL",
"unitPinch": "Prise",
"unitKnifeTip": "Msp.",
"unitPack": "Pck.",
"unitPiece": "Stück",
"unitSlice": "Scheibe(n)",
"unitClove": "Zehe(n)",
"unitSprig": "Zweig(e)",
"unitBunch": "Bund",
"unitHandful": "Handvoll",
"unitCup": "Tasse(n)",
"unitCan": "Dose",
"unitJar": "Glas",
"unitTub": "Becher",
"unitBag": "Beutel",
"unitCarton": "Karton",
"unitHead": "Kopf",
"unitGrain": "Korn",
"unitSmall": "kleine(r)",
"unitMedium": "mittlere(r)",
"unitLarge": "große(r)",
"unitSome": "etwas",
"moreUnits": "Weitere Einheiten …"
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
/// Calendar days for the Week Plan. A day is a UTC midnight, so adding days
/// never trips over daylight saving time.
DateTime dayOf(DateTime local) => DateTime.utc(local.year, local.month, local.day);
DateTime addDays(DateTime day, int days) => DateTime.utc(day.year, day.month, day.day + days);
/// "2026-10-23", as days are stored.
String dayKey(DateTime day) =>
'${day.year.toString().padLeft(4, '0')}-${day.month.toString().padLeft(2, '0')}-${day.day.toString().padLeft(2, '0')}';
DateTime parseDayKey(String key) {
final [y, m, d] = key.split('-').map(int.parse).toList();
return DateTime.utc(y, m, d);
}
/// The first day of the week [day] belongs to, for weeks starting on
/// [weekStart] (`DateTime.monday` … `DateTime.sunday`).
DateTime weekStartOf(DateTime day, int weekStart) => addDays(day, -((day.weekday - weekStart) % 7));
List<DateTime> weekDays(DateTime start) => [for (var i = 0; i < 7; i++) addDays(start, i)];
/// ISO calendar week ("KW") of the week starting at [start], taken from the
/// week's middle so weeks not starting on Monday still get a sensible number.
int calendarWeek(DateTime start) {
final middle = addDays(start, 3);
final thursday = addDays(middle, DateTime.thursday - middle.weekday);
final firstThursday = addDays(
DateTime.utc(thursday.year),
(DateTime.thursday - DateTime.utc(thursday.year).weekday) % 7,
);
return 1 + thursday.difference(firstThursday).inDays ~/ 7;
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:intl/intl.dart';
import '../localization/app_localizations.dart';
import 'day.dart';
/// "Heute", "Morgen" or "Sa., 24. Okt."
String dayLabel(AppLocalizations l10n, DateTime day, DateTime today) {
if (day == today) return l10n.jumpToToday;
if (day == addDays(today, 1)) return l10n.tomorrow;
return DateFormat('E, d. MMM', 'de').format(day);
}
/// "19.–25. Okt." within a month, "26. Okt. – 1. Nov." across two.
String dayRange(DateTime first, DateTime last) {
if (first == last) return DateFormat('d. MMM', 'de').format(first);
if (first.month == last.month) return '${first.day}.–${DateFormat('d. MMM', 'de').format(last)}';
return '${DateFormat('d. MMM', 'de').format(first)} – ${DateFormat('d. MMM', 'de').format(last)}';
}
/// "Fr." for the day column of the Week Plan.
String weekdayShort(DateTime day) => DateFormat('E', 'de').format(day);
+27
View File
@@ -0,0 +1,27 @@
import '../recipes/models/recipe.dart';
/// All Ingredient Lines of [recipes] (each at its own Servings), with lines of
/// the same name and Unit added up: "200 g Butter" twice becomes "400 g Butter".
/// Lines in different Units stay apart; a line without an amount is listed once.
/// Remarks are kept only when every added line agrees on them.
List<IngredientLine> ingredientTotals(Iterable<Recipe> recipes) {
final totals = <(String, String?), IngredientLine>{};
for (final line in recipes.expand((r) => r.ingredientLines)) {
final key = (line.name.trim().toLowerCase(), line.unitKey);
final sum = totals[key];
totals[key] = sum == null ? line : _add(sum, line);
}
return totals.values.toList();
}
IngredientLine _add(IngredientLine a, IngredientLine b) {
final amount = a.amount == null && b.amount == null ? null : (a.amount ?? 0) + (b.amount ?? 0);
final ranged = a.amountMax != null || b.amountMax != null;
return IngredientLine(
amount: amount,
amountMax: ranged ? (a.amountMax ?? a.amount ?? 0) + (b.amountMax ?? b.amount ?? 0) : null,
unitKey: a.unitKey,
name: a.name,
remark: a.remark == b.remark ? a.remark : null,
);
}
+14
View File
@@ -0,0 +1,14 @@
/// A time of day the cook plans for, such as Mittag or Abend.
class Meal {
const Meal({required this.id, required this.name, required this.position, this.retired = false});
final String id;
final String name;
final int position;
/// Deleted by the cook but still named by past Plan Entries.
final bool retired;
}
/// The Meals a fresh install starts with.
const defaultMeals = ['Mittag', 'Abend'];
+22
View File
@@ -0,0 +1,22 @@
/// One thing planned for a Meal on a day: a Recipe or a short note.
class PlanEntry {
const PlanEntry({
required this.id,
required this.day,
required this.mealId,
this.recipeId,
this.note,
required this.position,
}) : assert((recipeId == null) != (note == null), 'A Plan Entry is either a Recipe or a note');
final String id;
/// The calendar day, as a UTC midnight (see `day.dart`).
final DateTime day;
final String mealId;
final String? recipeId;
final String? note;
/// Order within its Meal on its day.
final int position;
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import 'day.dart';
import 'models/meal.dart';
import 'models/plan_entry.dart';
/// Meals and Plan Entries of the Week Plan.
class PlanRepository {
PlanRepository(this._db);
final AppDatabase _db;
Stream<List<Meal>> watchMeals() {
final query = _db.select(_db.meals)..orderBy([(m) => OrderingTerm.asc(m.position)]);
return query.watch().map((rows) => rows.map(_toMeal).toList());
}
Future<List<Meal>> allMeals() async {
final query = _db.select(_db.meals)..orderBy([(m) => OrderingTerm.asc(m.position)]);
return (await query.get()).map(_toMeal).toList();
}
/// Adds a Meal at the end of the list. A retired Meal of the same name comes
/// back instead, so its past Plan Entries join the new one.
Future<void> addMeal(String name) => _db.transaction(() async {
final meals = await allMeals();
final position = meals.isEmpty ? 0 : meals.last.position + 1;
for (final meal in meals.where((m) => m.retired)) {
if (meal.name.toLowerCase() == name.toLowerCase()) {
await _updateMeal(
meal.id,
MealsCompanion(name: Value(name), retired: const Value(false), position: Value(position)),
);
return;
}
}
await _db.into(_db.meals).insert(MealsCompanion.insert(id: const Uuid().v4(), name: name, position: position));
});
Future<void> renameMeal(String id, String name) => _updateMeal(id, MealsCompanion(name: Value(name)));
Future<void> reorderMeals(List<Meal> ordered) => _db.batch((b) {
for (final (i, meal) in ordered.indexed) {
b.update(_db.meals, MealsCompanion(position: Value(i)), where: (m) => m.id.equals(meal.id));
}
});
/// Plan Entries for this Meal from [today] on, which deleting it removes.
Future<int> upcomingEntryCount(String mealId, DateTime today) async {
final count = _db.planEntries.id.count();
final query = _db.selectOnly(_db.planEntries)
..addColumns([count])
..where(_db.planEntries.mealId.equals(mealId) & _db.planEntries.day.isBiggerOrEqualValue(dayKey(today)));
return (await query.getSingle()).read(count) ?? 0;
}
/// Removes the Meal and its Plan Entries from [today] on; past entries keep it as a retired Meal.
Future<void> deleteMeal(String mealId, DateTime today) => _db.transaction(() async {
await (_db.delete(
_db.planEntries,
)..where((e) => e.mealId.equals(mealId) & e.day.isBiggerOrEqualValue(dayKey(today)))).go();
final remaining = await (_db.select(_db.planEntries)..where((e) => e.mealId.equals(mealId))).get();
if (remaining.isEmpty) {
await (_db.delete(_db.meals)..where((m) => m.id.equals(mealId))).go();
} else {
await _updateMeal(mealId, const MealsCompanion(retired: Value(true)));
}
});
Stream<List<PlanEntry>> watchEntries() {
final query = _db.select(_db.planEntries)
..orderBy([(e) => OrderingTerm.asc(e.day), (e) => OrderingTerm.asc(e.position)]);
return query.watch().map((rows) => rows.map(_toEntry).toList());
}
Future<List<PlanEntry>> allEntries() async {
final query = _db.select(_db.planEntries)
..orderBy([(e) => OrderingTerm.asc(e.day), (e) => OrderingTerm.asc(e.position)]);
return (await query.get()).map(_toEntry).toList();
}
/// Plans a Recipe ([recipeId]) or a [note] at the end of the Meal on [day].
Future<void> addEntry(DateTime day, String mealId, {String? recipeId, String? note}) =>
addEntries([(day: day, mealId: mealId, recipeId: recipeId, note: note)]);
Future<void> addEntries(List<({DateTime day, String mealId, String? recipeId, String? note})> entries) =>
_db.transaction(() async {
for (final e in entries) {
await _db
.into(_db.planEntries)
.insert(
PlanEntriesCompanion.insert(
id: const Uuid().v4(),
day: dayKey(e.day),
mealId: e.mealId,
recipeId: Value(e.recipeId),
note: Value(e.note),
position: await _nextPosition(e.day, e.mealId),
),
);
}
});
/// "Tauschen": the entry now plans another Recipe.
Future<void> replaceRecipe(String entryId, String recipeId) =>
(_db.update(_db.planEntries)..where((e) => e.id.equals(entryId))).write(
PlanEntriesCompanion(recipeId: Value(recipeId), note: const Value(null)),
);
/// A note's new text.
Future<void> updateNote(String entryId, String note) => (_db.update(
_db.planEntries,
)..where((e) => e.id.equals(entryId) & e.recipeId.isNull())).write(PlanEntriesCompanion(note: Value(note)));
/// "Verschieben": to the end of another Meal or day.
Future<void> moveEntry(String entryId, DateTime day, String mealId) => _db.transaction(() async {
final position = await _nextPosition(day, mealId);
await (_db.update(_db.planEntries)..where((e) => e.id.equals(entryId))).write(
PlanEntriesCompanion(day: Value(dayKey(day)), mealId: Value(mealId), position: Value(position)),
);
});
Future<void> removeEntry(String entryId) => (_db.delete(_db.planEntries)..where((e) => e.id.equals(entryId))).go();
Future<int> _nextPosition(DateTime day, String mealId) async {
final max = _db.planEntries.position.max();
final query = _db.selectOnly(_db.planEntries)
..addColumns([max])
..where(_db.planEntries.day.equals(dayKey(day)) & _db.planEntries.mealId.equals(mealId));
final current = (await query.getSingle()).read(max);
return current == null ? 0 : current + 1;
}
Future<void> _updateMeal(String id, MealsCompanion companion) =>
(_db.update(_db.meals)..where((m) => m.id.equals(id))).write(companion);
static Meal _toMeal(MealRow row) => Meal(id: row.id, name: row.name, position: row.position, retired: row.retired);
static PlanEntry _toEntry(PlanEntryRow row) => PlanEntry(
id: row.id,
day: parseDayKey(row.day),
mealId: row.mealId,
recipeId: row.recipeId,
note: row.note,
position: row.position,
);
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:typed_data';
import '../bundle/bundle_codec.dart';
import '../bundle/bundle_service.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/recipe.dart';
import '../recipes/photo_store.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/unit_repository.dart';
import 'day.dart';
import 'plan_repository.dart';
/// A week of another cook's Week Plan, read from a Recipe Bundle and not yet
/// added here.
class SharedWeek {
const SharedWeek(this.contents);
final BundleContents contents;
BundlePlan get plan => contents.plan!;
DateTime get weekStart => parseDayKey(plan.weekStart);
/// The bundle's Recipes by their id in the bundle.
Map<String, BundleRecipe> get recipes => {for (final r in contents.recipes) r.recipe.id: r};
}
/// What adopting a [SharedWeek] would do.
class SharedWeekPreview {
const SharedWeekPreview({required this.entries, required this.newRecipes, required this.existingEntries});
final int entries;
/// Recipes this collection doesn't have yet (by id or by name).
final int newRecipes;
/// Plan Entries already in that week here, which stay.
final int existingEntries;
}
/// "Wochenplan teilen": one week of the Week Plan, with the Recipes it plans,
/// as a Recipe Bundle for another cook's CookIt – and taking one in.
class PlanShareService {
PlanShareService(this._recipes, this._courses, this._units, this._photos, this._plan, this._bundles);
final RecipeRepository _recipes;
final CourseRepository _courses;
final UnitRepository _units;
final PhotoStore _photos;
final PlanRepository _plan;
final BundleService _bundles;
Future<Uint8List> weekFile(DateTime weekStart) async {
final end = addDays(weekStart, 6);
final entries = [
for (final e in await _plan.allEntries())
if (!e.day.isBefore(weekStart) && !e.day.isAfter(end)) e,
];
final meals = {for (final m in await _plan.allMeals()) m.id: m.name};
final used = {for (final e in entries) e.mealId};
final recipeIds = {for (final e in entries) ?e.recipeId};
final recipes = [
for (final r in await _recipes.allRecipes())
if (recipeIds.contains(r.id)) r,
];
final plan = BundlePlan(
weekStart: dayKey(weekStart),
meals: [
for (final MapEntry(key: id, value: name) in meals.entries)
if (used.contains(id)) name,
],
entries: [
for (final e in entries)
BundlePlanEntry(day: dayKey(e.day), meal: meals[e.mealId] ?? '', recipeId: e.recipeId, note: e.note),
],
);
return _bundles.exportWeek(recipes, plan);
}
Future<SharedWeekPreview> preview(SharedWeek week) async {
final matches = await _matchRecipes(week);
final start = week.weekStart;
final end = addDays(start, 6);
final existing = (await _plan.allEntries()).where((e) => !e.day.isBefore(start) && !e.day.isAfter(end));
return SharedWeekPreview(
entries: week.plan.entries.length,
newRecipes: week.recipes.keys.where((id) => matches[id] == null).length,
existingEntries: existing.length,
);
}
/// Adds the week's entries next to what is planned here: Recipes this
/// collection has (same id or same name) are reused, the others added;
/// Meals are matched by name and created when missing.
Future<void> adopt(SharedWeek week) async {
final matches = await _matchRecipes(week);
final known = {for (final u in await _units.allUnits()) u.key};
for (final unit in week.contents.units) {
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
}
final ids = <String, String>{};
for (final MapEntry(key: id, value: entry) in week.recipes.entries) {
ids[id] = matches[id] ?? await _addRecipe(entry, week.contents.files);
}
final mealIds = <String, String>{};
for (final name in {...week.plan.meals, for (final e in week.plan.entries) e.meal}) {
mealIds[name] = await _mealNamed(name);
}
await _plan.addEntries([
for (final e in week.plan.entries)
(
day: parseDayKey(e.day),
mealId: mealIds[e.meal]!,
recipeId: e.recipeId == null ? null : ids[e.recipeId],
note: e.recipeId == null || ids[e.recipeId] == null ? e.note ?? '' : null,
),
]);
}
/// The bundle's Recipe ids mapped to the Recipe here that is the same
/// (same id, else same name), or null when there is none.
Future<Map<String, String?>> _matchRecipes(SharedWeek week) async {
final mine = await _recipes.allRecipes();
final byId = {for (final r in mine) r.id};
final byName = {for (final r in mine) _key(r.name): r.id};
return {
for (final MapEntry(key: id, value: entry) in week.recipes.entries)
id: byId.contains(id) ? id : byName[_key(entry.recipe.name)],
};
}
/// Adds a Recipe from the bundle, keeping its id so a later week from the
/// same cook finds it again.
Future<String> _addRecipe(BundleRecipe entry, Map<String, Uint8List> files) async {
final courseName = entry.courseName;
final courseId = courseName == null ? null : (await _courses.findOrCreate(courseName)).id;
final Recipe recipe = entry.recipe.copyWith(
courseId: () => courseId,
favourite: false,
reviewReasons: const [],
photos: [
for (final path in entry.photoPaths)
if (files[path] case final bytes?) await _photos.saveBytes(bytes),
],
);
await _recipes.saveRecipe(recipe);
return recipe.id;
}
Future<String> _mealNamed(String name) async {
bool same(String other) => _key(other) == _key(name);
for (final meal in await _plan.allMeals()) {
if (!meal.retired && same(meal.name)) return meal.id;
}
await _plan.addMeal(name); // brings a retired Meal of that name back
return (await _plan.allMeals()).firstWhere((m) => !m.retired && same(m.name)).id;
}
static String _key(String name) => name.trim().toLowerCase();
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/text_breaks.dart';
import '../day.dart';
import '../day_format.dart';
import '../plan_share_service.dart';
/// A week of another cook's Week Plan, shown before anything is added: the
/// entries as a grid, what adopting would add, and "Übernehmen".
class SharedWeekView extends StatefulWidget {
const SharedWeekView({super.key, required this.week});
static const routeName = '/received-week';
final SharedWeek week;
@override
State<SharedWeekView> createState() => _SharedWeekViewState();
}
class _SharedWeekViewState extends State<SharedWeekView> {
late final Future<SharedWeekPreview> _preview = context.read<PlanShareService>().preview(widget.week);
var _adopting = false;
Future<void> _adopt() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
setState(() => _adopting = true);
await context.read<PlanShareService>().adopt(widget.week);
navigator.pop();
final start = widget.week.weekStart;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.weekAdopted(dayRange(start, addDays(start, 6))))));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final week = widget.week;
final start = week.weekStart;
final recipes = week.recipes;
final meals = week.plan.meals;
return Scaffold(
appBar: AppBar(title: Text(l10n.receivedWeek)),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: _adopting ? null : _adopt,
icon: const Icon(Icons.calendar_month_rounded),
label: Text(l10n.adopt),
),
),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text(
l10n.weekPlanFileName(dayRange(start, addDays(start, 6))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
FutureBuilder<SharedWeekPreview>(
future: _preview,
builder: (context, snapshot) {
final preview = snapshot.data;
if (preview == null) return const SizedBox(height: 24);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Text(
'${l10n.receivedWeekEntries(preview.entries)} · ${l10n.receivedWeekNewRecipes(preview.newRecipes)}',
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
),
if (preview.existingEntries > 0)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: c.honey, borderRadius: BorderRadius.circular(14)),
child: Text(
l10n.receivedWeekExisting(preview.existingEntries),
style: TextStyle(fontWeight: FontWeight.w700, color: c.onHoney),
),
),
],
);
},
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(20)),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 46),
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6, bottom: 6),
child: Text(
meal,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.inkMuted),
),
),
),
],
),
for (final day in weekDays(start))
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 46,
child: Text(
'${weekdayShort(day).replaceAll('.', '')}\n${day.day}.${day.month}.',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 12, color: c.ink),
),
),
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final e in week.plan.entries)
if (e.day == dayKey(day) && e.meal == meal)
_PreviewEntry(
text: recipes[e.recipeId]?.recipe.name ?? e.note ?? '',
note: e.recipeId == null,
),
],
),
),
),
],
),
),
],
),
),
],
),
);
}
}
class _PreviewEntry extends StatelessWidget {
const _PreviewEntry({required this.text, required this.note});
final String text;
final bool note;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: note ? c.noteFill : c.planCell,
borderRadius: BorderRadius.circular(10),
border: note ? Border.all(color: c.noteBorder, width: 1.5) : null,
),
child: Row(
children: [
if (note) ...[Icon(Icons.sticky_note_2_outlined, size: 14, color: c.noteInk), const SizedBox(width: 4)],
Expanded(
child: Text(
withNaturalBreaks(text),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.ink),
),
),
],
),
);
}
}
+395
View File
@@ -0,0 +1,395 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../design/cookit_nav_bar.dart';
import '../../design/herb_pattern.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/recipe_list_provider.dart';
import '../../settings/settings_controller.dart';
import '../day.dart';
import '../day_format.dart';
import '../models/plan_entry.dart';
import '../plan_repository.dart';
import 'widgets/fill_week_sheet.dart';
import 'widgets/plan_actions.dart';
import 'widgets/plan_grid.dart';
import 'widgets/week_share_menu.dart';
import 'widgets/today_banner.dart';
/// The Week Plan: today's banner, then one week as a grid. Swipe (or ‹ ›) for
/// other weeks. While an entry is dragged, the week bar turns into drop zones
/// for the neighbouring weeks and the navigation bar into "remove".
class WeekPlanView extends StatefulWidget {
const WeekPlanView({super.key, this.onDraggingChanged});
/// Lets the home screen hide its navigation bar while an entry is dragged.
final ValueChanged<bool>? onDraggingChanged;
@override
State<WeekPlanView> createState() => _WeekPlanViewState();
}
class _WeekPlanViewState extends State<WeekPlanView> {
/// The page showing the current week; pages before it are past weeks.
static const _origin = 10000;
final _pages = PageController(initialPage: _origin);
var _page = _origin;
var _dragging = false;
@override
void dispose() {
_pages.dispose();
super.dispose();
}
DateTime get _thisWeek => weekStartOf(dayOf(DateTime.now()), context.read<SettingsController>().weekStart);
DateTime _weekOf(int page) => addDays(_thisWeek, 7 * (page - _origin));
void _goTo(int page) =>
_pages.animateToPage(page, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic);
// A drag can outlive the cell it started in (the week flips under it), so it
// ends with the finger, not with the cell's callbacks.
void _setDragging(bool dragging) {
if (_dragging == dragging) return;
setState(() => _dragging = dragging);
widget.onDraggingChanged?.call(dragging);
}
/// Dropped on "Vorige/Nächste Woche": same weekday and Meal in the week now
/// shown, or in the neighbouring one if the week hasn't flipped yet.
Future<void> _dropOnWeek(PlanEntry entry, int direction) async {
final weekStart = context.read<SettingsController>().weekStart;
final entryWeek = weekStartOf(entry.day, weekStart);
var target = _weekOf(_page);
if (target == entryWeek) target = addDays(target, 7 * direction);
final day = addDays(target, (entry.day.weekday - weekStart) % 7);
final page = _origin + target.difference(_thisWeek).inDays ~/ 7;
await context.read<PlanRepository>().moveEntry(entry.id, day, entry.mealId);
_goTo(page);
}
Future<void> _remove(PlanEntry entry) async {
final recipes = context.read<RecipeListProvider>().allRecipes;
final name = recipes.where((r) => r.id == entry.recipeId).firstOrNull?.name ?? entry.note ?? '';
await removeEntryWithUndo(context, entry, name);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
context.watch<SettingsController>();
final today = dayOf(DateTime.now());
final topInset = MediaQuery.paddingOf(context).top;
final bottomInset = MediaQuery.paddingOf(context).bottom;
final shown = _weekOf(_page);
return Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
// No Scaffold of its own: HomeView's is the one the SnackBars appear in.
child: ColoredBox(
color: c.background,
child: Stack(
children: [
const Positioned.fill(child: ParallaxEdgePattern()),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, topInset + 16, 20, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.weekPlanTitle,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 32,
color: c.ink,
),
),
),
FilledButton.tonalIcon(
onPressed: () => _goTo(_origin),
icon: const Icon(Icons.today_rounded, size: 18),
label: Text(l10n.jumpToToday),
),
],
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: TodayBanner()),
SizedBox(
height: 60,
child: _dragging
? Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Expanded(
child: _WeekDropZone(
label: l10n.previousWeek,
icon: Icons.chevron_left_rounded,
onFlip: () => _goTo(_page - 1),
onDrop: (entry) => _dropOnWeek(entry, -1),
),
),
const SizedBox(width: 8),
Expanded(
child: _WeekDropZone(
label: l10n.nextWeek,
icon: Icons.chevron_right_rounded,
iconAfter: true,
onFlip: () => _goTo(_page + 1),
onDrop: (entry) => _dropOnWeek(entry, 1),
),
),
],
),
)
: _WeekBar(
start: shown,
weeksAway: _page - _origin,
past: addDays(shown, 6).isBefore(today),
onPrevious: () => _goTo(_page - 1),
onNext: () => _goTo(_page + 1),
),
),
Expanded(
child: PageView.builder(
controller: _pages,
onPageChanged: (page) => setState(() => _page = page),
itemBuilder: (context, page) => SingleChildScrollView(
padding: EdgeInsets.fromLTRB(12, 0, 12, 32 + bottomInset + CookItNavBar.height),
child: PlanGrid(weekStart: _weekOf(page), today: today, onDragStarted: () => _setDragging(true)),
),
),
),
],
),
if (_dragging)
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
height: CookItNavBar.height,
child: DragTarget<PlanEntry>(
onAcceptWithDetails: (d) => _remove(d.data),
builder: (context, candidates, _) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.tiles[2] : const Color(0xFFF4C4AE),
borderRadius: BorderRadius.circular(CookItNavBar.height / 2),
border: Border.all(color: const Color(0xFFC0563A), width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outline_rounded, color: Color(0xFF8A2E17)),
const SizedBox(width: 8),
Text(
l10n.dropToRemove,
style: const TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF8A2E17),
),
),
],
),
),
),
),
),
],
),
),
);
}
}
class _WeekBar extends StatelessWidget {
const _WeekBar({
required this.start,
required this.weeksAway,
required this.past,
required this.onPrevious,
required this.onNext,
});
final DateTime start;
final int weeksAway;
final bool past;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final title = switch (weeksAway) {
0 => l10n.thisWeek,
1 => l10n.nextWeek,
-1 => l10n.lastWeek,
_ => l10n.calendarWeek(calendarWeek(start)),
};
final round = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(40, 40),
);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 16, 0),
child: Row(
children: [
// ‹ title › take what the buttons leave, the title shortening only if it must.
Expanded(
child: Row(
children: [
IconButton(
tooltip: l10n.previousWeek,
onPressed: onPrevious,
icon: Icon(Icons.chevron_left_rounded, color: c.green),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 17,
color: c.green,
),
),
Text(
dayRange(start, addDays(start, 6)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12, color: c.inkMuted),
),
],
),
),
IconButton(
tooltip: l10n.nextWeek,
onPressed: onNext,
icon: Icon(Icons.chevron_right_rounded, color: c.green),
),
],
),
),
IconButton(
tooltip: l10n.shareMenu,
onPressed: () => showWeekShareMenu(context, start),
style: round,
icon: const Icon(Icons.share_rounded, size: 20),
),
if (!past) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.fillWeek,
onPressed: () => showFillWeekSheet(context, start),
style: round,
icon: const Icon(Icons.casino_outlined, size: 20),
),
],
],
),
);
}
}
/// "Vorige Woche" / "Nächste Woche" while dragging: holding an entry over it
/// flips the week (again every moment it stays there); dropping it there moves it.
class _WeekDropZone extends StatefulWidget {
const _WeekDropZone({
required this.label,
required this.icon,
required this.onFlip,
required this.onDrop,
this.iconAfter = false,
});
final String label;
final IconData icon;
final bool iconAfter;
final VoidCallback onFlip;
final ValueChanged<PlanEntry> onDrop;
@override
State<_WeekDropZone> createState() => _WeekDropZoneState();
}
class _WeekDropZoneState extends State<_WeekDropZone> {
Timer? _flipping;
void _stop() {
_flipping?.cancel();
_flipping = null;
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return DragTarget<PlanEntry>(
onWillAcceptWithDetails: (_) {
_flipping ??= Timer.periodic(const Duration(milliseconds: 800), (_) => widget.onFlip());
return true;
},
onLeave: (_) => _stop(),
onAcceptWithDetails: (d) {
_stop();
widget.onDrop(d.data);
},
builder: (context, candidates, _) {
final icon = Icon(widget.icon, size: 20, color: c.green);
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.surface : c.navBar,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: c.brand, width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.iconAfter) icon,
Text(
widget.label,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 14, color: c.green),
),
if (widget.iconAfter) icon,
],
),
),
);
},
);
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../plan_repository.dart';
/// "+" on a Meal: search the collection for a Recipe, or keep what was typed as a note.
Future<void> showAddEntrySheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _AddEntrySheet(day: day, meal: meal),
);
class _AddEntrySheet extends StatefulWidget {
const _AddEntrySheet({required this.day, required this.meal});
final DateTime day;
final Meal meal;
@override
State<_AddEntrySheet> createState() => _AddEntrySheetState();
}
class _AddEntrySheetState extends State<_AddEntrySheet> {
final _query = TextEditingController();
/// Only Recipes of this Course; null shows all.
String? _courseId;
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _add({String? recipeId, String? note}) async {
final plan = context.read<PlanRepository>();
Navigator.pop(context);
await plan.addEntry(widget.day, widget.meal.id, recipeId: recipeId, note: note);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final provider = context.watch<RecipeListProvider>();
final text = _query.text.trim();
final recipes = provider.allRecipes
.where((r) => _courseId == null || r.courseId == _courseId)
.where((r) => r.name.toLowerCase().contains(text.toLowerCase()))
.toList();
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addEntryTitle(widget.meal.name, dayLabel(l10n, widget.day, dayOf(DateTime.now()))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
const SizedBox(height: 12),
TextField(
controller: _query,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.done,
onChanged: (_) => setState(() {}),
onSubmitted: (value) {
if (value.trim().isNotEmpty) _add(note: value.trim());
},
decoration: InputDecoration(
hintText: l10n.addEntryHint,
prefixIcon: Icon(Icons.search_rounded, color: c.green),
fillColor: c.background,
),
),
],
),
),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
for (final course in [null, ...provider.courses])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(course == null ? l10n.filterAll : course.label),
selected: _courseId == course?.id,
showCheckmark: false,
onSelected: (_) => setState(() => _courseId = course?.id),
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
if (text.isNotEmpty)
ListTile(
leading: CircleAvatar(
backgroundColor: c.tiles[0],
child: Icon(Icons.sticky_note_2_outlined, color: c.green),
),
title: Text(l10n.addAsNote(text), style: const TextStyle(fontWeight: FontWeight.w800)),
onTap: () => _add(note: text),
),
for (final recipe in recipes)
ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 44,
height: 44,
child: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: provider.courseOf(recipe)?.emoji,
cacheWidth: 132,
),
),
),
title: Text(recipe.name),
subtitle: provider.courseOf(recipe) == null ? null : Text(provider.courseOf(recipe)!.name),
onTap: () => _add(recipeId: recipe.id),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../cooking_mode/cooking_mode_view.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/time_format.dart';
import '../../../recipes/views/recipe_detail_view.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../week_plan_provider.dart';
import 'add_entry_sheet.dart';
import 'plan_actions.dart';
/// Tapping a filled cell of the Week Plan grid: its Plan Entries with what can
/// be done to each, and "Weiteres hinzufügen".
Future<void> showCellSheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _CellSheet(day: day, meal: meal, host: context),
);
class _CellSheet extends StatelessWidget {
const _CellSheet({required this.day, required this.meal, required this.host});
final DateTime day;
final Meal meal;
/// The Week Plan's context, which outlives this sheet.
final BuildContext host;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final entries = context.watch<WeekPlanProvider>().entriesFor(day, meal.id);
final recipes = context.watch<RecipeListProvider>();
final byId = {for (final r in recipes.allRecipes) r.id: r};
final navigator = Navigator.of(context);
if (entries.isEmpty) {
// The last entry was removed or moved away.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator.canPop()) navigator.pop();
});
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addEntryTitle(meal.name, dayLabel(l10n, day, dayOf(DateTime.now()))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
for (final entry in entries)
Builder(
builder: (context) {
final recipe = byId[entry.recipeId];
final name = recipe?.name ?? entry.note ?? '';
final details = [
if (recipe?.totalMinutes != null) formatMinutes(l10n, recipe!.totalMinutes!),
if (recipe != null && recipes.courseOf(recipe) != null) recipes.courseOf(recipe)!.name,
].join(' · ');
void close() => navigator.pop();
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 56,
height: 56,
child: recipe == null
? ColoredBox(
color: c.noteFill,
child: Icon(Icons.sticky_note_2_outlined, color: c.noteInk),
)
: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: recipes.courseOf(recipe)?.emoji,
cacheWidth: 168,
iconSize: 26,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 18,
color: c.ink,
),
),
if (details.isNotEmpty)
Text(
details,
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 4,
children: [
if (recipe != null) ...[
TextButton.icon(
onPressed: () {
close();
navigator.pushNamed(RecipeDetailView.routeName, arguments: recipe.id);
},
icon: const Icon(Icons.menu_book_rounded),
label: Text(l10n.decisionOpen),
),
TextButton.icon(
onPressed: () {
close();
navigator.pushNamed(CookingModeView.routeName, arguments: recipe.id);
},
icon: const Icon(Icons.soup_kitchen_outlined),
label: Text(l10n.cookStart),
),
TextButton.icon(
onPressed: () => rerollEntry(context, entry, recipe),
icon: const Icon(Icons.casino_outlined),
label: Text(l10n.entryReroll),
),
],
if (recipe == null)
TextButton.icon(
onPressed: () => editNote(context, entry),
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.entryEdit),
),
TextButton.icon(
onPressed: () => moveEntryWithPicker(context, entry),
icon: const Icon(Icons.swap_vert_rounded),
label: Text(l10n.entryMove),
),
TextButton.icon(
// Closes the sheet first: a SnackBar's time only runs once nothing covers it.
onPressed: () {
close();
removeEntryWithUndo(host, entry, name);
},
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.entryRemove),
),
],
),
],
),
);
},
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () {
navigator.pop();
showAddEntrySheet(host, day, meal);
},
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addAnother),
),
],
),
),
);
}
}
@@ -0,0 +1,178 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/decision.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../day.dart';
import '../../plan_repository.dart';
import '../../week_fill.dart';
import '../../week_plan_provider.dart';
/// "Woche füllen": which Meal, which Courses, how much time – then random
/// Recipes for that Meal's empty days from today on.
Future<void> showFillWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _FillWeekSheet(weekStart: weekStart),
);
class _FillWeekSheet extends StatefulWidget {
const _FillWeekSheet({required this.weekStart});
final DateTime weekStart;
@override
State<_FillWeekSheet> createState() => _FillWeekSheetState();
}
class _FillWeekSheetState extends State<_FillWeekSheet> {
late String? _mealId = _defaultMeal();
late Set<String> _courseIds = _defaultCourses();
var _time = TimeLimit.any;
String? _defaultMeal() {
final meals = context.read<WeekPlanProvider>().meals;
for (final meal in meals) {
if (meal.name.toLowerCase() == 'abend') return meal.id;
}
return meals.isEmpty ? null : meals.last.id;
}
Set<String> _defaultCourses() => {
for (final course in context.read<RecipeListProvider>().courses)
if (course.name.toLowerCase() == 'hauptgericht') course.id,
};
Future<void> _fill() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final plan = context.read<PlanRepository>();
final week = context.read<WeekPlanProvider>();
final recipes = context.read<RecipeListProvider>().allRecipes;
final mealId = _mealId!;
final today = dayOf(DateTime.now());
final weekEnd = addDays(widget.weekStart, 6);
final emptyDays = [
for (final day in weekDays(widget.weekStart))
if (!day.isBefore(today) && week.entriesFor(day, mealId).isEmpty) day,
];
final filled = fillWeek(
emptyDays: emptyDays,
candidates: DecisionChoice(courseIds: _courseIds, time: _time).matching(recipes),
inWeek: week.recipeIdsBetween(widget.weekStart, weekEnd),
recent: week.recipeIdsBetween(addDays(widget.weekStart, -7 * recentWeeks), addDays(widget.weekStart, -1)),
random: Random(),
);
Navigator.pop(context);
await plan.addEntries([
for (final MapEntry(key: day, value: recipe) in filled.entries)
(day: day, mealId: mealId, recipeId: recipe.id, note: null),
]);
final left = emptyDays.length - filled.length;
final message = emptyDays.isEmpty
? l10n.fillNothingEmpty
: [l10n.fillResult(filled.length), if (left > 0) l10n.fillLeftEmpty(left)].join(' ');
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final meals = context.watch<WeekPlanProvider>().meals;
final courses = context.watch<RecipeListProvider>().courses;
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 18, color: c.ink);
final times = {
TimeLimit.upTo30: l10n.decisionUpTo30,
TimeLimit.upTo60: l10n.decisionUpTo60,
TimeLimit.any: l10n.decisionAnyTime,
};
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.fillWeek,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 4),
Text(l10n.fillWeekHint, style: TextStyle(color: c.inkMuted)),
const SizedBox(height: 16),
Text(l10n.fillWhichMeal, style: heading),
const SizedBox(height: 8),
if (meals.isEmpty)
Text(l10n.noMeals, style: TextStyle(color: c.inkMuted))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final meal in meals)
ChoiceChip(
label: Text(meal.name),
selected: _mealId == meal.id,
onSelected: (_) => setState(() => _mealId = meal.id),
),
],
),
const SizedBox(height: 16),
Text(l10n.decisionCourseQuestion, style: heading),
Text(l10n.decisionCourseHint, style: TextStyle(color: c.inkMuted)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final course in courses)
FilterChip(
label: Text(course.name),
selected: _courseIds.contains(course.id),
onSelected: (on) => setState(() {
_courseIds = {..._courseIds};
on ? _courseIds.add(course.id) : _courseIds.remove(course.id);
}),
),
],
),
const SizedBox(height: 16),
Text(l10n.decisionTimeQuestion, style: heading),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final MapEntry(key: limit, value: label) in times.entries)
ChoiceChip(
label: Text(label),
selected: _time == limit,
onSelected: (_) => setState(() => _time = limit),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: _mealId == null ? null : _fill,
icon: const Icon(Icons.casino_outlined),
label: Text(l10n.fillAction),
),
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../settings/settings_controller.dart';
import '../../day.dart';
import '../../models/plan_entry.dart';
import '../../plan_repository.dart';
import '../../week_fill.dart';
import '../../week_plan_provider.dart';
import 'plan_slot_sheet.dart';
/// "Neu würfeln": another Recipe of the same Course, by the rules of "Woche füllen".
Future<void> rerollEntry(BuildContext context, PlanEntry entry, Recipe current) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final week = context.read<WeekPlanProvider>();
final recipes = context.read<RecipeListProvider>().allRecipes;
final plan = context.read<PlanRepository>();
final weekStart = weekStartOf(entry.day, context.read<SettingsController>().weekStart);
final replacement = pickReplacement(
candidates: [
for (final r in recipes)
if (current.courseId == null || r.courseId == current.courseId) r,
],
inWeek: week.recipeIdsBetween(weekStart, addDays(weekStart, 6)),
recent: week.recipeIdsBetween(addDays(weekStart, -7 * recentWeeks), addDays(weekStart, -1)),
random: Random(),
);
if (replacement == null) {
messenger.showSnackBar(SnackBar(content: Text(l10n.noOtherRecipe)));
return;
}
await plan.replaceRecipe(entry.id, replacement.id);
}
/// "Verschieben …": asks for a day and Meal.
Future<void> moveEntryWithPicker(BuildContext context, PlanEntry entry) async {
final l10n = AppLocalizations.of(context);
final plan = context.read<PlanRepository>();
final slot = await showPlanSlotSheet(
context,
action: l10n.moveEntryTitle,
initialDay: entry.day,
initialMealId: entry.mealId,
);
if (slot == null) return;
final (day, mealId) = slot;
await plan.moveEntry(entry.id, day, mealId);
}
/// Removes the entry, with "Rückgängig" in the SnackBar.
Future<void> removeEntryWithUndo(BuildContext context, PlanEntry entry, String name) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final plan = context.read<PlanRepository>();
await plan.removeEntry(entry.id);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.entryRemoved(name)),
duration: const Duration(milliseconds: 1500),
action: SnackBarAction(
label: l10n.undo,
onPressed: () => plan.addEntry(entry.day, entry.mealId, recipeId: entry.recipeId, note: entry.note),
),
),
);
}
/// "Bearbeiten" on a note: a small dialog with its text.
Future<void> editNote(BuildContext context, PlanEntry entry) async {
final plan = context.read<PlanRepository>();
final text = await showDialog<String>(
context: context,
builder: (_) => _NoteDialog(note: entry.note ?? ''),
);
if (text != null && text.isNotEmpty) await plan.updateNote(entry.id, text);
}
class _NoteDialog extends StatefulWidget {
const _NoteDialog({required this.note});
final String note;
@override
State<_NoteDialog> createState() => _NoteDialogState();
}
class _NoteDialogState extends State<_NoteDialog> {
late final _text = TextEditingController(text: widget.note);
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _save() => Navigator.pop(context, _text.text.trim());
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.editNoteTitle),
content: TextField(
controller: _text,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => _save(),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: _save, child: Text(l10n.save)),
],
);
}
}
@@ -0,0 +1,390 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/text_breaks.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../models/plan_entry.dart';
import '../../plan_repository.dart';
import '../../week_plan_provider.dart';
import 'add_entry_sheet.dart';
import 'cell_sheet.dart';
/// One week of the Week Plan as a grid: a row per day, a column per Meal.
/// Entries can be long-pressed and dragged onto another cell (they join what
/// is there). Up to two Meals the cells show the Course's icon; from four on
/// the Meal columns scroll sideways.
class PlanGrid extends StatelessWidget {
const PlanGrid({super.key, required this.weekStart, required this.today, required this.onDragStarted});
final DateTime weekStart;
final DateTime today;
final VoidCallback onDragStarted;
static const _rowHeight = 60.0;
/// Several entries in one cell: one slim line each, and that day's row grows.
static const compactHeight = 30.0;
static const compactGap = 4.0;
static const _headerHeight = 26.0;
static const _dayWidth = 46.0;
static const _scrollingColumnWidth = 112.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final plan = context.watch<WeekPlanProvider>();
final days = weekDays(weekStart);
final meals = plan.mealsDuring(days);
final showIcons = meals.length <= 2;
final scrolls = meals.length >= 4;
if (meals.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(
l10n.noMeals,
textAlign: TextAlign.center,
style: TextStyle(color: c.inkMuted),
),
);
}
Color? tintOf(DateTime day) => day == today
? c.tiles[0]
: day.weekday >= DateTime.saturday
? c.weekendTint
: null;
double heightOf(DateTime day) {
final most = meals.fold(0, (n, meal) => max(n, plan.entriesFor(day, meal.id).length));
return most <= 1 ? _rowHeight : most * (compactHeight + compactGap);
}
Widget row(DateTime day, Widget child, {required bool start}) => Opacity(
opacity: day.isBefore(today) ? 0.55 : 1,
child: Container(
height: heightOf(day),
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: tintOf(day),
borderRadius: start
? const BorderRadius.horizontal(left: Radius.circular(16))
: const BorderRadius.horizontal(right: Radius.circular(16)),
),
child: child,
),
);
final mealColumns = Column(
children: [
SizedBox(
height: _headerHeight,
child: Row(
children: [
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Text(
meal.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.inkMuted),
),
),
),
],
),
),
for (final day in days)
row(
day,
start: false,
Row(
children: [
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.5),
child: _PlanCell(
key: ValueKey('${dayKey(day)}-${meal.id}'),
day: day,
meal: meal,
entries: plan.entriesFor(day, meal.id),
showIcon: showIcons,
onDragStarted: onDragStarted,
),
),
),
],
),
),
],
);
return Container(
padding: const EdgeInsets.fromLTRB(6, 6, 6, 8),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(22),
border: c.cardBorder == null ? null : Border.all(color: c.cardBorder!),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _dayWidth,
child: Column(
children: [
const SizedBox(height: _headerHeight),
for (final day in days) row(day, start: true, _DayLabel(day: day, isToday: day == today)),
],
),
),
Expanded(
child: scrolls
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(width: meals.length * _scrollingColumnWidth, child: mealColumns),
)
: mealColumns,
),
],
),
);
}
}
class _DayLabel extends StatelessWidget {
const _DayLabel({required this.day, required this.isToday});
final DateTime day;
final bool isToday;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Center(
child: Container(
width: 40,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: isToday ? BoxDecoration(color: c.sage, borderRadius: BorderRadius.circular(12)) : null,
// Scales down rather than wrapping with large system fonts.
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
weekdayShort(day).replaceAll('.', ''),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 15, color: c.ink),
),
Text(
'${day.day}.${day.month}.',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
),
),
),
);
}
}
class _PlanCell extends StatelessWidget {
const _PlanCell({
super.key,
required this.day,
required this.meal,
required this.entries,
required this.showIcon,
required this.onDragStarted,
});
final DateTime day;
final Meal meal;
final List<PlanEntry> entries;
final bool showIcon;
final VoidCallback onDragStarted;
/// One entry of the cell: tap for the cell's sheet, long-press to drag it elsewhere.
Widget _draggable(BuildContext context, PlanEntry entry, {required bool compact}) {
final c = CookItColors.of(context);
final radius = BorderRadius.circular(compact ? 10 : 14);
// Fills its slot: the whole cell, or one slim line of it.
final tile = SizedBox.expand(
child: _EntryTile(entry: entry, showIcon: showIcon, compact: compact),
);
return LayoutBuilder(
builder: (context, constraints) => LongPressDraggable<PlanEntry>(
data: entry,
onDragStarted: onDragStarted,
feedback: Material(
color: Colors.transparent,
child: Transform.rotate(
angle: -0.06,
child: Container(
width: constraints.maxWidth,
height: constraints.maxHeight,
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 18, offset: const Offset(0, 8)),
],
),
child: tile,
),
),
),
childWhenDragging: DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.brand, width: 2),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(borderRadius: radius, onTap: () => showCellSheet(context, day, meal), child: tile),
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final radius = BorderRadius.circular(14);
return DragTarget<PlanEntry>(
onWillAcceptWithDetails: (d) => !meal.retired && !(d.data.day == day && d.data.mealId == meal.id),
onAcceptWithDetails: (d) => context.read<PlanRepository>().moveEntry(d.data.id, day, meal.id),
builder: (context, candidates, _) {
final Widget content;
if (entries.isEmpty) {
content = Tooltip(
message: l10n.addEntry,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: radius,
onTap: meal.retired ? null : () => showAddEntrySheet(context, day, meal),
child: Ink(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.navBar, width: 2),
),
child: meal.retired ? null : Center(child: Icon(Icons.add_rounded, size: 18, color: c.green)),
),
),
),
);
} else if (entries.length == 1) {
content = _draggable(context, entries.single, compact: false);
} else {
content = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, entry) in entries.indexed) ...[
if (i > 0) const SizedBox(height: PlanGrid.compactGap),
SizedBox(height: PlanGrid.compactHeight, child: _draggable(context, entry, compact: true)),
],
],
);
}
if (candidates.isEmpty) return content;
return Stack(
fit: StackFit.expand,
children: [
content,
DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.brand, width: 3),
color: c.lime.withValues(alpha: 0.45),
),
child: entries.isEmpty
? null
: Align(
alignment: Alignment.bottomCenter,
child: Text(
l10n.dropAdd,
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w900, color: CookItColors.light.onBrand),
),
),
),
],
);
},
);
}
}
/// One Plan Entry in a cell: a Recipe with its Course's icon, or a note with
/// the note icon on its warm ground – in the same type, since a note is just
/// as settled as a Recipe. [compact] is the slim one-line form for a cell
/// holding several entries.
class _EntryTile extends StatelessWidget {
const _EntryTile({required this.entry, required this.showIcon, required this.compact});
final PlanEntry entry;
final bool showIcon;
final bool compact;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final recipes = context.watch<RecipeListProvider>();
final Recipe? recipe = entry.recipeId == null
? null
: recipes.allRecipes.where((r) => r.id == entry.recipeId).firstOrNull;
final note = recipe == null;
final emoji = recipe == null ? null : recipes.courseOf(recipe)?.emoji;
final iconSize = compact ? 20.0 : 26.0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: note ? c.noteFill : c.planCell,
borderRadius: BorderRadius.circular(compact ? 10 : 14),
border: note ? Border.all(color: c.noteBorder, width: 1.5) : null,
),
child: Row(
children: [
if (note)
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(Icons.sticky_note_2_outlined, size: compact ? 14 : 16, color: c.noteInk),
)
else if (showIcon)
Container(
width: iconSize,
height: iconSize,
margin: const EdgeInsets.only(right: 6),
alignment: Alignment.center,
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(compact ? 6 : 8)),
child: Text(emoji ?? '🍽️', style: TextStyle(fontSize: compact ? 12 : 15)),
),
Expanded(
child: Text(
withNaturalBreaks(recipe?.name ?? entry.note ?? ''),
maxLines: compact ? 1 : (showIcon ? 2 : 3),
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.2, fontWeight: FontWeight.w800, color: c.ink),
),
),
],
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../week_plan_provider.dart';
/// Asks for a day and a Meal: for "Einplanen" on a Recipe and for "Verschieben"
/// of a Plan Entry. Returns null when the cook backs out.
Future<(DateTime, String)?> showPlanSlotSheet(
BuildContext context, {
required String action,
DateTime? initialDay,
String? initialMealId,
}) => showModalBottomSheet<(DateTime, String)>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _PlanSlotSheet(action: action, initialDay: initialDay, initialMealId: initialMealId),
);
class _PlanSlotSheet extends StatefulWidget {
const _PlanSlotSheet({required this.action, this.initialDay, this.initialMealId});
final String action;
final DateTime? initialDay;
final String? initialMealId;
@override
State<_PlanSlotSheet> createState() => _PlanSlotSheetState();
}
class _PlanSlotSheetState extends State<_PlanSlotSheet> {
static const _quickDays = 14;
final _today = dayOf(DateTime.now());
late DateTime _day = widget.initialDay ?? _today;
late String? _mealId = widget.initialMealId;
List<DateTime> get _days => [for (var i = 0; i < _quickDays; i++) addDays(_today, i)];
Future<void> _pickOtherDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _day,
firstDate: addDays(_today, -365),
lastDate: addDays(_today, 2 * 365),
);
if (picked != null) setState(() => _day = dayOf(picked));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final meals = context.watch<WeekPlanProvider>().meals;
final mealId = _mealId ?? (meals.isEmpty ? null : meals.last.id);
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 20, color: c.ink);
final otherDay = !_days.contains(_day);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.planWhichDay, style: heading),
const SizedBox(height: 10),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final day in _days)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(dayLabel(l10n, day, _today)),
selected: _day == day,
onSelected: (_) => setState(() => _day = day),
),
),
ChoiceChip(
avatar: const Icon(Icons.calendar_month_rounded, size: 18),
label: Text(otherDay ? dayLabel(l10n, _day, _today) : l10n.planOtherDate),
selected: otherDay,
onSelected: (_) => _pickOtherDate(),
),
],
),
),
const SizedBox(height: 20),
Text(l10n.planWhichMeal, style: heading),
const SizedBox(height: 10),
if (meals.isEmpty)
Text(l10n.noMeals, style: TextStyle(color: c.inkMuted))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final meal in meals)
ChoiceChip(
label: Text(meal.name),
selected: mealId == meal.id,
onSelected: (_) => setState(() => _mealId = meal.id),
),
],
),
const SizedBox(height: 20),
FilledButton(
onPressed: mealId == null ? null : () => Navigator.pop(context, (_day, mealId)),
child: Text(widget.action),
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/ingredients_text.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/unit_catalog.dart';
import '../../../recipes/views/widgets/share_or_copy_buttons.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../ingredient_totals.dart';
import '../../week_plan_provider.dart';
/// "Zutaten der Woche teilen": the added-up ingredients of the Recipes planned
/// on the chosen days, handed to Android's share menu (e.g. to Bring!).
Future<void> showShareWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _ShareWeekSheet(weekStart: weekStart),
);
class _ShareWeekSheet extends StatefulWidget {
const _ShareWeekSheet({required this.weekStart});
final DateTime weekStart;
@override
State<_ShareWeekSheet> createState() => _ShareWeekSheetState();
}
class _ShareWeekSheetState extends State<_ShareWeekSheet> {
final _today = dayOf(DateTime.now());
/// Days with a Recipe planned, from today on.
late final Set<DateTime> _days = {
for (final day in weekDays(widget.weekStart))
if (!day.isBefore(_today) && _recipesOn(day).isNotEmpty) day,
};
List<Recipe> _recipesOn(DateTime day) {
final byId = {for (final r in context.read<RecipeListProvider>().allRecipes) r.id: r};
return [
for (final entry in context.read<WeekPlanProvider>().entriesOn(day))
if (byId[entry.recipeId] case final recipe?) recipe,
];
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final units = context.watch<UnitCatalog>();
context.watch<WeekPlanProvider>();
final chosen = weekDays(widget.weekStart).where(_days.contains).toList();
final lines = ingredientTotals(chosen.expand(_recipesOn));
final text = ingredientsAsText([IngredientGroup(lines: lines)], (key) => units.label(l10n, key));
final subject = chosen.isEmpty ? '' : l10n.shareWeekSubject(dayRange(chosen.first, chosen.last));
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.shareWeek,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 4),
Text(l10n.shareWeekHint, style: TextStyle(fontSize: 14, color: c.inkMuted)),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final day in weekDays(widget.weekStart))
FilterChip(
label: Text('${weekdayShort(day)} ${day.day}.'),
selected: _days.contains(day),
onSelected: _recipesOn(day).isEmpty
? null
: (on) => setState(() => on ? _days.add(day) : _days.remove(day)),
),
],
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.35),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: c.background, borderRadius: BorderRadius.circular(16)),
child: SingleChildScrollView(
child: Text(
lines.isEmpty ? l10n.shareWeekEmpty : text,
style: TextStyle(fontSize: 15, height: 1.45, color: lines.isEmpty ? c.inkMuted : c.ink),
),
),
),
),
const SizedBox(height: 16),
ShareOrCopyButtons(text: lines.isEmpty ? null : text, subject: subject),
],
),
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../cooking_mode/cooking_mode_view.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/views/recipe_detail_view.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../week_plan_provider.dart';
/// Above the Week Plan grid: everything planned for today, one line per entry,
/// with the Recipe's photo and "Kochen". Nothing when today is empty.
class TodayBanner extends StatelessWidget {
const TodayBanner({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final plan = context.watch<WeekPlanProvider>();
final recipes = context.watch<RecipeListProvider>();
final now = DateTime.now();
final entries = plan.entriesToday(now);
if (entries.isEmpty) return const SizedBox.shrink();
final byId = {for (final r in recipes.allRecipes) r.id: r};
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
decoration: BoxDecoration(color: c.sage, borderRadius: BorderRadius.circular(22)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.todayBanner(DateFormat('EEEE', 'de').format(dayOf(now))).toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.7, color: c.ink),
),
for (final entry in entries)
Builder(
builder: (context) {
final recipe = byId[entry.recipeId];
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: recipe == null
? null
: () => Navigator.pushNamed(context, RecipeDetailView.routeName, arguments: recipe.id),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 44,
height: 44,
child: recipe == null
? ColoredBox(
color: c.noteFill,
child: Icon(Icons.sticky_note_2_outlined, color: c.noteInk, size: 22),
)
: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: recipes.courseOf(recipe)?.emoji,
cacheWidth: 132,
iconSize: 22,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
plan.mealById(entry.mealId)?.name ?? '',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.ink),
),
Text(
recipe?.name ?? entry.note ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 16,
color: c.ink,
),
),
],
),
),
if (recipe != null)
FilledButton(
onPressed: () =>
Navigator.pushNamed(context, CookingModeView.routeName, arguments: recipe.id),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: CookItColors.light.green,
visualDensity: VisualDensity.compact,
),
child: Text(l10n.cook),
),
],
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../../localization/app_localizations.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../plan_share_service.dart';
import 'share_week_sheet.dart';
/// "Teilen" in the week bar: the week as a file for another CookIt, or its
/// added-up ingredients as text.
Future<void> showWeekShareMenu(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final l10n = AppLocalizations.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.calendar_month_rounded),
title: Text(l10n.shareWeekPlan),
subtitle: Text(l10n.shareWeekPlanHint),
onTap: () {
Navigator.pop(sheetContext);
_shareWeekFile(context, weekStart);
},
),
ListTile(
leading: const Icon(Icons.shopping_basket_outlined),
title: Text(l10n.shareWeek),
subtitle: Text(l10n.shareWeekIngredientsHint),
onTap: () {
Navigator.pop(sheetContext);
showShareWeekSheet(context, weekStart);
},
),
],
),
),
);
},
);
Future<void> _shareWeekFile(BuildContext context, DateTime weekStart) async {
final l10n = AppLocalizations.of(context);
final bytes = await context.read<PlanShareService>().weekFile(weekStart);
final range = dayRange(weekStart, addDays(weekStart, 6)).replaceAll(RegExp(r'\.$'), '');
final file = File('${(await getTemporaryDirectory()).path}/${l10n.weekPlanFileName(range)}.cookit');
await file.writeAsBytes(bytes);
await SharePlus.instance.share(ShareParams(files: [XFile(file.path, mimeType: 'application/zip')]));
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:math';
import '../recipes/models/recipe.dart';
/// How far back "planned recently" reaches, in weeks.
const recentWeeks = 4;
/// "Woche füllen": one Recipe for each of [emptyDays], drawn from [candidates].
/// Never repeats a Recipe already in the week ([inWeek]) or within the fill, and
/// takes Recipes planned recently ([recent]) only once the others run out. Days
/// left over when the candidates run out stay empty.
Map<DateTime, Recipe> fillWeek({
required List<DateTime> emptyDays,
required List<Recipe> candidates,
required Set<String> inWeek,
required Set<String> recent,
required Random random,
}) {
final pool = _preferFresh(candidates.where((r) => !inWeek.contains(r.id)), recent, random);
return {
for (final (i, day) in emptyDays.indexed)
if (i < pool.length) day: pool[i],
};
}
/// "Tauschen": another Recipe for one Plan Entry, by the same rules as [fillWeek].
Recipe? pickReplacement({
required List<Recipe> candidates,
required Set<String> inWeek,
required Set<String> recent,
required Random random,
}) {
final pool = _preferFresh(candidates.where((r) => !inWeek.contains(r.id)), recent, random);
return pool.isEmpty ? null : pool.first;
}
List<Recipe> _preferFresh(Iterable<Recipe> recipes, Set<String> recent, Random random) {
final fresh = recipes.where((r) => !recent.contains(r.id)).toList()..shuffle(random);
final again = recipes.where((r) => recent.contains(r.id)).toList()..shuffle(random);
return [...fresh, ...again];
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'day.dart';
import 'models/meal.dart';
import 'models/plan_entry.dart';
import 'plan_repository.dart';
/// The Week Plan as the screens see it: all Meals and Plan Entries, kept
/// current by the database's watch streams.
class WeekPlanProvider extends ChangeNotifier {
WeekPlanProvider(PlanRepository plan) {
_subscriptions = [
plan.watchMeals().listen((value) {
_meals = value;
notifyListeners();
}),
plan.watchEntries().listen((value) {
_byDay = {};
for (final entry in value) {
(_byDay[entry.day] ??= []).add(entry);
}
notifyListeners();
}),
];
}
late final List<StreamSubscription<Object?>> _subscriptions;
List<Meal> _meals = const [];
Map<DateTime, List<PlanEntry>> _byDay = {};
/// The Meals the cook plans for now, in order.
List<Meal> get meals => _meals.where((m) => !m.retired).toList();
Meal? mealById(String id) {
for (final meal in _meals) {
if (meal.id == id) return meal;
}
return null;
}
List<PlanEntry> entriesOn(DateTime day) => _byDay[day] ?? const [];
/// The Meals shown for [day]: the current ones, plus retired ones its entries still use.
List<Meal> mealsOn(DateTime day) {
final used = {for (final e in entriesOn(day)) e.mealId};
return _meals.where((m) => !m.retired || used.contains(m.id)).toList();
}
/// The grid columns for [days]: the current Meals, plus retired ones their entries still use.
List<Meal> mealsDuring(Iterable<DateTime> days) {
final used = {
for (final day in days)
for (final e in entriesOn(day)) e.mealId,
};
return _meals.where((m) => !m.retired || used.contains(m.id)).toList();
}
List<PlanEntry> entriesFor(DateTime day, String mealId) => entriesOn(day).where((e) => e.mealId == mealId).toList();
/// Recipes planned on any day from [from] up to and including [to].
Set<String> recipeIdsBetween(DateTime from, DateTime to) => {
for (final MapEntry(key: day, value: entries) in _byDay.entries)
if (!day.isBefore(from) && !day.isAfter(to))
for (final e in entries)
if (e.recipeId != null) e.recipeId!,
};
/// Today's entries in Meal order, for the home screen.
List<PlanEntry> entriesToday(DateTime now) {
final today = dayOf(now);
final order = {for (final (i, m) in _meals.indexed) m.id: i};
return [...entriesOn(today)]..sort((a, b) {
final byMeal = (order[a.mealId] ?? 0).compareTo(order[b.mealId] ?? 0);
return byMeal != 0 ? byMeal : a.position.compareTo(b.position);
});
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:intl/intl.dart';
/// Amount and optional upper end of a range, e.g. "2-3" -> (2, 3).
typedef Amount = ({double? amount, double? amountMax});
const _fractionGlyphs = {'½': 0.5, '¼': 0.25, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3, '⅛': 0.125};
/// Reads what the cook typed into an amount field: "2", "1,5", "½", "1 1/2", "2-3".
/// Returns null when the text isn't an amount; an empty text is "no amount".
Amount? parseAmount(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return (amount: null, amountMax: null);
final parts = trimmed.split(RegExp(r'\s*(?:-|–|bis)\s*'));
if (parts.length == 2) {
final low = _single(parts[0]);
final high = _single(parts[1]);
if (low != null && high != null) return (amount: low, amountMax: high);
return null;
}
final single = _single(trimmed);
return single == null ? null : (amount: single, amountMax: null);
}
double? _single(String text) {
final t = text.trim();
if (_fractionGlyphs.containsKey(t)) return _fractionGlyphs[t];
var m = RegExp(r'^(\d+)\s*([½¼¾⅓⅔⅛])$').firstMatch(t);
if (m != null) return int.parse(m[1]!) + _fractionGlyphs[m[2]!]!;
m = RegExp(r'^(?:(\d+)\s+)?(\d+)\s*/\s*(\d+)$').firstMatch(t);
if (m != null && int.parse(m[3]!) != 0) {
return int.parse(m[1] ?? '0') + int.parse(m[2]!) / int.parse(m[3]!);
}
if (RegExp(r'^\d+(?:[.,]\d+)?$').hasMatch(t)) {
return double.parse(t.replaceAll(',', '.'));
}
return null;
}
final _decimal = NumberFormat('#,##0.##', 'de');
/// German display of a number, using fraction glyphs for common fractions: 1.5 -> "1½".
String formatNumber(double value) {
final whole = value.floor();
final fraction = value - whole;
for (final MapEntry(key: glyph, value: v) in _fractionGlyphs.entries) {
if ((fraction - v).abs() < 0.01) return whole == 0 ? glyph : '$whole$glyph';
}
return _decimal.format(value);
}
String formatAmount(double? amount, double? amountMax) {
if (amount == null) return '';
if (amountMax == null) return formatNumber(amount);
return '${formatNumber(amount)}–${formatNumber(amountMax)}';
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import 'models/course.dart';
class CourseRepository {
CourseRepository(this._db);
final AppDatabase _db;
Stream<List<Course>> watchCourses() {
final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]);
return query.watch().map((rows) => rows.map(_toCourse).toList());
}
Future<List<Course>> allCourses() async {
final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]);
return (await query.get()).map(_toCourse).toList();
}
Future<Course> addCourse(String name, {String? emoji}) async {
final position = (await allCourses()).length;
final course = Course(id: const Uuid().v4(), name: name, emoji: emoji, position: position);
await _db
.into(_db.courses)
.insert(
CoursesCompanion.insert(
id: course.id,
name: course.name,
emoji: Value(course.emoji),
position: course.position,
),
);
return course;
}
/// The Course with this name, created at the end of the list if it doesn't exist yet.
Future<Course> findOrCreate(String name) async {
for (final course in await allCourses()) {
if (course.name.toLowerCase() == name.toLowerCase()) return course;
}
return addCourse(name);
}
Future<void> updateCourse(String id, {required String name, String? emoji}) => (_db.update(
_db.courses,
)..where((c) => c.id.equals(id))).write(CoursesCompanion(name: Value(name), emoji: Value(emoji)));
Future<void> reorder(List<Course> ordered) => _db.batch((b) {
for (final (i, course) in ordered.indexed) {
b.update(_db.courses, CoursesCompanion(position: Value(i)), where: (c) => c.id.equals(course.id));
}
});
/// Recipes of a deleted Course keep existing without one.
Future<void> deleteCourse(String id) => (_db.delete(_db.courses)..where((c) => c.id.equals(id))).go();
Future<int> recipeCount(String courseId) async {
final count = _db.recipes.id.count();
final query = _db.selectOnly(_db.recipes)
..addColumns([count])
..where(_db.recipes.courseId.equals(courseId));
return (await query.getSingle()).read(count) ?? 0;
}
static Course _toCourse(CourseRow row) =>
Course(id: row.id, name: row.name, emoji: row.emoji, position: row.position);
}
+36
View File
@@ -0,0 +1,36 @@
import 'models/recipe.dart';
/// "Wie viel Zeit hast du?" in the decision helper.
enum TimeLimit {
upTo30(30),
upTo60(60),
any(null);
const TimeLimit(this.minutes);
final int? minutes;
}
/// The cook's answers in the decision helper ("Hilf mir entscheiden").
class DecisionChoice {
const DecisionChoice({this.courseIds = const {}, this.time = TimeLimit.any});
/// Courses the cook fancies; empty means any.
final Set<String> courseIds;
final TimeLimit time;
/// Recipes without a recorded time only match when time doesn't matter.
bool matches(Recipe recipe) {
if (courseIds.isNotEmpty && !courseIds.contains(recipe.courseId)) return false;
final limit = time.minutes;
if (limit == null) return true;
final total = recipe.totalMinutes;
return total != null && total <= limit;
}
List<Recipe> matching(Iterable<Recipe> recipes) => recipes.where(matches).toList();
DecisionChoice withoutTimeLimit() => DecisionChoice(courseIds: courseIds);
DecisionChoice withAllCourses() => DecisionChoice(time: time);
}
+19
View File
@@ -0,0 +1,19 @@
import 'amount.dart';
import 'models/recipe.dart';
/// Ingredient Groups as plain text, one Ingredient Line per line (e.g.
/// "250 g Mehl"), so a shopping list app can take each line as one item.
/// Groups are separated by an empty line; their names are left out because
/// they would turn into items of their own.
String ingredientsAsText(List<IngredientGroup> groups, String Function(String unitKey) unitLabel) => [
for (final group in groups)
[
for (final line in group.lines)
[
formatAmount(line.amount, line.amountMax),
if (line.unitKey != null) unitLabel(line.unitKey!),
line.name,
if (line.remark != null) '(${line.remark})',
].where((part) => part.isNotEmpty).join(' '),
].join('\n'),
].where((group) => group.isNotEmpty).join('\n\n');
+29
View File
@@ -0,0 +1,29 @@
/// The single kind of dish a Recipe is, such as Hauptgericht or Dessert.
class Course {
const Course({required this.id, required this.name, this.emoji, required this.position});
final String id;
final String name;
final String? emoji;
final int position;
String get label => emoji == null ? name : '$emoji $name';
Course copyWith({String? name, String? emoji, int? position}) =>
Course(id: id, name: name ?? this.name, emoji: emoji ?? this.emoji, position: position ?? this.position);
}
/// The Courses a fresh install starts with (taken from the original workbook).
const defaultCourses = <(String, String)>[
('Hauptgericht', '🍝'),
('Auflauf', '🥘'),
('Beilage', '🥔'),
('Salat', '🥗'),
('Vorspeise', '🍤'),
('Snacks/ Fingerfood', '🍢'),
('Sandwich', '🥪'),
('Brot', '🥖'),
('Dip', '🥣'),
('Dessert', '🍰'),
('Getränk', '🥤'),
];
+192
View File
@@ -0,0 +1,192 @@
import 'unit.dart';
/// A dish the cook can prepare. See GLOSSARY.md for the vocabulary.
class Recipe {
const Recipe({
required this.id,
required this.name,
this.courseId,
this.tags = const [],
this.servings,
this.prepMinutes,
this.cookMinutes,
this.calories,
this.ingredientGroups = const [],
this.stepSections = const [],
this.photos = const [],
this.sources = const [],
this.notes,
this.favourite = false,
this.reviewReasons = const [],
required this.createdAt,
required this.updatedAt,
});
final String id;
final String name;
final String? courseId;
final List<String> tags;
final double? servings;
final int? prepMinutes;
final int? cookMinutes;
final Calories? calories;
final List<IngredientGroup> ingredientGroups;
final List<StepSection> stepSections;
/// Photo file names in display order; the first is the main Photo.
final List<String> photos;
final List<String> sources;
final String? notes;
final bool favourite;
/// Why the cook should check this Recipe. Empty means no Review Flag.
final List<String> reviewReasons;
final DateTime createdAt;
final DateTime updatedAt;
bool get needsReview => reviewReasons.isNotEmpty;
String? get mainPhoto => photos.isEmpty ? null : photos.first;
int? get totalMinutes {
if (prepMinutes == null && cookMinutes == null) return null;
return (prepMinutes ?? 0) + (cookMinutes ?? 0);
}
Iterable<IngredientLine> get ingredientLines => ingredientGroups.expand((g) => g.lines);
Recipe copyWith({
String? name,
String? Function()? courseId,
List<String>? tags,
double? Function()? servings,
int? Function()? prepMinutes,
int? Function()? cookMinutes,
Calories? Function()? calories,
List<IngredientGroup>? ingredientGroups,
List<StepSection>? stepSections,
List<String>? photos,
List<String>? sources,
String? Function()? notes,
bool? favourite,
List<String>? reviewReasons,
DateTime? updatedAt,
}) {
return Recipe(
id: id,
name: name ?? this.name,
courseId: courseId != null ? courseId() : this.courseId,
tags: tags ?? this.tags,
servings: servings != null ? servings() : this.servings,
prepMinutes: prepMinutes != null ? prepMinutes() : this.prepMinutes,
cookMinutes: cookMinutes != null ? cookMinutes() : this.cookMinutes,
calories: calories != null ? calories() : this.calories,
ingredientGroups: ingredientGroups ?? this.ingredientGroups,
stepSections: stepSections ?? this.stepSections,
photos: photos ?? this.photos,
sources: sources ?? this.sources,
notes: notes != null ? notes() : this.notes,
favourite: favourite ?? this.favourite,
reviewReasons: reviewReasons ?? this.reviewReasons,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
enum CaloriesBasis {
perServing('perServing'),
wholeRecipe('wholeRecipe');
const CaloriesBasis(this.key);
final String key;
static CaloriesBasis fromKey(String key) => values.firstWhere((b) => b.key == key, orElse: () => perServing);
}
class Calories {
const Calories(this.kcal, this.basis);
final int kcal;
final CaloriesBasis basis;
Map<String, dynamic> toJson() => {'kcal': kcal, 'basis': basis.key};
static Calories fromJson(Map<String, dynamic> json) =>
Calories((json['kcal'] as num).round(), CaloriesBasis.fromKey(json['basis'] as String));
}
/// A subset of Ingredient Lines that belong together. [name] null = an unnamed break.
class IngredientGroup {
const IngredientGroup({this.name, required this.lines});
final String? name;
final List<IngredientLine> lines;
Map<String, dynamic> toJson() => {
'name': name,
'lines': [for (final l in lines) l.toJson()],
};
static IngredientGroup fromJson(Map<String, dynamic> json) => IngredientGroup(
name: json['name'] as String?,
lines: [for (final l in json['lines'] as List) IngredientLine.fromJson(l as Map<String, dynamic>)],
);
}
class IngredientLine {
const IngredientLine({this.amount, this.amountMax, this.unitKey, required this.name, this.remark});
final double? amount;
/// Upper end of a range such as "2–3".
final double? amountMax;
/// Key of a built-in [Unit] or of one of the cook's own units.
final String? unitKey;
final String name;
final String? remark;
Map<String, dynamic> toJson() => {
'amount': amount,
'amountMax': amountMax,
'unit': unitKey,
'name': name,
'remark': remark,
};
static IngredientLine fromJson(Map<String, dynamic> json) {
return IngredientLine(
amount: (json['amount'] as num?)?.toDouble(),
amountMax: (json['amountMax'] as num?)?.toDouble(),
unitKey: json['unit'] as String?,
name: json['name'] as String,
remark: json['remark'] as String?,
);
}
/// This line for [factor] times the servings ("Kochmodus" scaling).
IngredientLine scaled(double factor) => factor == 1
? this
: IngredientLine(
amount: amount == null ? null : amount! * factor,
amountMax: amountMax == null ? null : amountMax! * factor,
unitKey: unitKey,
name: name,
remark: remark,
);
}
/// Consecutive Steps under an optional heading such as "Vorteig".
class StepSection {
const StepSection({this.title, required this.steps});
final String? title;
final List<String> steps;
Map<String, dynamic> toJson() => {'title': title, 'steps': steps};
static StepSection fromJson(Map<String, dynamic> json) =>
StepSection(title: json['title'] as String?, steps: [for (final s in json['steps'] as List) s as String]);
}
+48
View File
@@ -0,0 +1,48 @@
/// The built-in measures an Ingredient Line's amount can be given in. The cook
/// can add their own next to these (see `UnitDefinition`).
///
/// [key] is what the database and Recipe Bundles store; labels live in the
/// localization files. [common] units start out offered directly in the picker,
/// the rest under "Weitere Einheiten" — the cook can change that in the settings.
enum Unit {
gram('gram', common: true),
kilogram('kilogram', common: true),
milliliter('milliliter', common: true),
liter('liter', common: true),
tablespoon('tablespoon', common: true),
teaspoon('teaspoon', common: true),
pinch('pinch', common: true),
knifeTip('knifeTip', common: true),
pack('pack', common: true),
piece('piece'),
slice('slice'),
clove('clove'),
sprig('sprig'),
bunch('bunch'),
handful('handful'),
cup('cup'),
can('can'),
jar('jar'),
tub('tub'),
bag('bag'),
carton('carton'),
head('head'),
grain('grain'),
small('small'),
medium('medium'),
large('large'),
some('some');
const Unit(this.key, {this.common = false});
final String key;
final bool common;
static Unit? fromKey(String? key) {
if (key == null) return null;
for (final unit in values) {
if (unit.key == key) return unit;
}
return null;
}
}
@@ -0,0 +1,26 @@
import 'unit.dart';
/// A Unit as the cook's list knows it: a built-in [Unit] or one of their own,
/// shown directly in the picker or under "Weitere Einheiten", in their order.
class UnitDefinition {
const UnitDefinition({
required this.key,
this.label,
required this.builtIn,
required this.direct,
required this.position,
});
/// Prefix of the keys of the cook's own units.
static const ownPrefix = 'own-';
final String key;
/// The name of one of the cook's own units; null for built-in ones (their names are localized).
final String? label;
final bool builtIn;
final bool direct;
final int position;
Unit? get builtInUnit => Unit.fromKey(key);
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Keeps Photo files in the app's private storage. Recipes reference them by file name.
class PhotoStore {
PhotoStore(this.directory);
final Directory directory;
static Future<PhotoStore> open() async {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory(p.join(docs.path, 'photos'));
await dir.create(recursive: true);
return PhotoStore(dir);
}
File file(String name) => File(p.join(directory.path, name));
Future<Uint8List> read(String name) => file(name).readAsBytes();
Future<String> saveBytes(Uint8List bytes) async {
final name = '${const Uuid().v4()}.jpg';
await file(name).writeAsBytes(bytes, flush: true);
return name;
}
/// Copies a picked image (already downscaled by the picker) into the store.
Future<String> importFile(String path) async {
final name = '${const Uuid().v4()}${p.extension(path).toLowerCase()}';
await File(path).copy(file(name).path);
return name;
}
Future<void> delete(Iterable<String> names) async {
for (final name in names) {
final f = file(name);
if (await f.exists()) await f.delete();
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import 'amount.dart';
import 'models/recipe.dart';
/// The ingredient list as the edit form shows it: one flat, reorderable list
/// where a [GroupEntry] starts a new Ingredient Group.
sealed class IngredientEntry {
IngredientEntry() : key = const Uuid().v4();
final String key;
void dispose();
}
class GroupEntry extends IngredientEntry {
GroupEntry([String? name]) : name = TextEditingController(text: name);
final TextEditingController name;
@override
void dispose() => name.dispose();
}
class LineEntry extends IngredientEntry {
LineEntry([IngredientLine? line])
: amount = TextEditingController(text: formatAmount(line?.amount, line?.amountMax)),
unitKey = line?.unitKey,
name = TextEditingController(text: line?.name),
remark = TextEditingController(text: line?.remark),
showRemark = line?.remark != null;
final TextEditingController amount;
String? unitKey;
final TextEditingController name;
final TextEditingController remark;
/// The remark field is only shown for lines that have one, or once the cook asks for it.
bool showRemark;
@override
void dispose() {
amount.dispose();
name.dispose();
remark.dispose();
}
}
/// The steps as the edit form shows them; a [SectionEntry] starts a Step Section.
sealed class StepEntry {
StepEntry() : key = const Uuid().v4();
final String key;
TextEditingController get text;
void dispose() => text.dispose();
}
class SectionEntry extends StepEntry {
SectionEntry([String? title]) : text = TextEditingController(text: title);
@override
final TextEditingController text;
}
class StepTextEntry extends StepEntry {
StepTextEntry([String? step]) : text = TextEditingController(text: step);
@override
final TextEditingController text;
}
List<IngredientEntry> ingredientEntriesFrom(List<IngredientGroup> groups) => [
for (final (i, group) in groups.indexed) ...[
if (i > 0 || group.name != null) GroupEntry(group.name),
for (final line in group.lines) LineEntry(line),
],
];
/// Builds the Ingredient Groups back from the form. Lines without a name are left out,
/// as are groups that end up empty. Amounts must already be validated.
List<IngredientGroup> ingredientGroupsFrom(List<IngredientEntry> entries) {
final groups = <({String? name, List<IngredientLine> lines})>[(name: null, lines: [])];
for (final entry in entries) {
switch (entry) {
case GroupEntry(:final name):
groups.add((name: _orNull(name.text), lines: []));
case LineEntry():
final name = _orNull(entry.name.text);
if (name == null) continue;
final amount = parseAmount(entry.amount.text) ?? (amount: null, amountMax: null);
groups.last.lines.add(
IngredientLine(
amount: amount.amount,
amountMax: amount.amountMax,
unitKey: entry.unitKey,
name: name,
remark: _orNull(entry.remark.text),
),
);
}
}
return [
for (final g in groups)
if (g.lines.isNotEmpty) IngredientGroup(name: g.name, lines: g.lines),
];
}
List<StepEntry> stepEntriesFrom(List<StepSection> sections) => [
for (final section in sections) ...[
if (section.title != null) SectionEntry(section.title),
for (final step in section.steps) StepTextEntry(step),
],
];
List<StepSection> stepSectionsFrom(List<StepEntry> entries) {
final sections = <({String? title, List<String> steps})>[(title: null, steps: [])];
for (final entry in entries) {
final text = _orNull(entry.text.text);
switch (entry) {
case SectionEntry():
sections.add((title: text, steps: []));
case StepTextEntry():
if (text != null) sections.last.steps.add(text);
}
}
return [
for (final s in sections)
if (s.steps.isNotEmpty) StepSection(title: s.title, steps: s.steps),
];
}
/// "vegan, schnell, Vegan" -> ["vegan", "schnell"]
List<String> tagsFrom(String text) {
final seen = <String>{};
return [
for (final tag in text.split(',').map((t) => t.trim()))
if (tag.isNotEmpty && seen.add(tag.toLowerCase())) tag,
];
}
List<String> linesFrom(String text) => [
for (final line in text.split('\n').map((l) => l.trim()))
if (line.isNotEmpty) line,
];
String? _orNull(String text) {
final trimmed = text.trim();
return trimmed.isEmpty ? null : trimmed;
}
+44
View File
@@ -0,0 +1,44 @@
import 'models/recipe.dart';
/// What the home screen currently shows: a search text plus filter toggles.
class RecipeFilter {
const RecipeFilter({this.query = '', this.courseId, this.favouritesOnly = false, this.reviewOnly = false});
final String query;
final String? courseId;
final bool favouritesOnly;
final bool reviewOnly;
bool get isActive => query.trim().isNotEmpty || courseId != null || favouritesOnly || reviewOnly;
RecipeFilter copyWith({String? query, String? Function()? courseId, bool? favouritesOnly, bool? reviewOnly}) =>
RecipeFilter(
query: query ?? this.query,
courseId: courseId != null ? courseId() : this.courseId,
favouritesOnly: favouritesOnly ?? this.favouritesOnly,
reviewOnly: reviewOnly ?? this.reviewOnly,
);
bool matches(Recipe recipe) {
if (courseId != null && recipe.courseId != courseId) return false;
if (favouritesOnly && !recipe.favourite) return false;
if (reviewOnly && !recipe.needsReview) return false;
final words = normalizeForSearch(query).split(' ').where((w) => w.isNotEmpty);
if (words.isEmpty) return true;
final haystack = normalizeForSearch(
[recipe.name, ...recipe.tags, ...recipe.ingredientLines.map((l) => l.name)].join(' '),
);
return words.every(haystack.contains);
}
}
/// Lowercase, umlauts folded ("Käse" -> "kase"), whitespace collapsed.
String normalizeForSearch(String text) {
const folds = {'ä': 'a', 'ö': 'o', 'ü': 'u', 'ß': 'ss', 'é': 'e', 'è': 'e', 'ê': 'e', 'á': 'a', 'à': 'a'};
final lower = text.toLowerCase();
final buffer = StringBuffer();
for (final char in lower.split('')) {
buffer.write(folds[char] ?? char);
}
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'course_repository.dart';
import 'models/course.dart';
import 'models/recipe.dart';
import 'recipe_filter.dart';
import 'recipe_repository.dart';
/// The collection as the home screen sees it: all Recipes and Courses, kept
/// current by the database's watch streams, plus the active [RecipeFilter].
class RecipeListProvider extends ChangeNotifier {
RecipeListProvider(RecipeRepository recipes, CourseRepository courses, {DateTime Function()? clock, Random? random})
: _clock = clock ?? DateTime.now,
_random = random ?? Random() {
_subscriptions = [
recipes.watchRecipes().listen((value) {
_recipes = value;
_loaded = true;
notifyListeners();
}),
courses.watchCourses().listen((value) {
_courses = value;
if (_filter.courseId != null && !value.any((c) => c.id == _filter.courseId)) {
_filter = _filter.copyWith(courseId: () => null);
}
notifyListeners();
}),
];
}
final DateTime Function() _clock;
final Random _random;
late final List<StreamSubscription<Object?>> _subscriptions;
/// "Andere Idee": the index into [allRecipes] picked on that day. It is
/// forgotten the next day, when the new day's own draw takes over.
({int day, int index})? _shuffled;
List<Recipe> _recipes = const [];
List<Course> _courses = const [];
RecipeFilter _filter = const RecipeFilter();
bool _loaded = false;
bool get loaded => _loaded;
List<Recipe> get allRecipes => _recipes;
List<Course> get courses => _courses;
RecipeFilter get filter => _filter;
List<Recipe> get visibleRecipes => _recipes.where(_filter.matches).toList();
int get reviewCount => _recipes.where((r) => r.needsReview).length;
/// "Idee für heute": a random Recipe, drawn anew each day and the same all
/// day long, until the cook asks for another.
Recipe? get suggestion {
if (_recipes.isEmpty) return null;
final day = _dayNumber(_clock());
final shuffled = _shuffled;
if (shuffled != null && shuffled.day == day) return _recipes[shuffled.index % _recipes.length];
// Seeded with the day: random from one day to the next, stable within it.
return _recipes[Random(day).nextInt(_recipes.length)];
}
void shuffleSuggestion() {
final count = _recipes.length;
if (count < 2) return;
final current = _recipes.indexOf(suggestion!);
final next = _random.nextInt(count - 1);
_shuffled = (day: _dayNumber(_clock()), index: next >= current ? next + 1 : next);
notifyListeners();
}
static int _dayNumber(DateTime now) =>
DateTime.utc(now.year, now.month, now.day).millisecondsSinceEpoch ~/ Duration.millisecondsPerDay;
Course? courseOf(Recipe recipe) {
for (final course in _courses) {
if (course.id == recipe.courseId) return course;
}
return null;
}
void setQuery(String query) => _update(_filter.copyWith(query: query));
void toggleCourse(String courseId) =>
_update(_filter.copyWith(courseId: () => _filter.courseId == courseId ? null : courseId));
void toggleFavourites() => _update(_filter.copyWith(favouritesOnly: !_filter.favouritesOnly));
void toggleReview() => _update(_filter.copyWith(reviewOnly: !_filter.reviewOnly));
void resetFilter() => _update(const RecipeFilter());
void _update(RecipeFilter filter) {
_filter = filter;
notifyListeners();
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:drift/drift.dart';
import '../database/database.dart';
import 'models/recipe.dart';
import 'photo_store.dart';
class RecipeRepository {
RecipeRepository(this._db, this._photos);
final AppDatabase _db;
final PhotoStore _photos;
Stream<List<Recipe>> watchRecipes() {
final query = _db.select(_db.recipes)..orderBy([(r) => OrderingTerm.asc(r.name.collate(Collate.noCase))]);
return query.watch().map((rows) => rows.map(_toRecipe).toList());
}
Stream<Recipe?> watchRecipe(String id) {
final query = _db.select(_db.recipes)..where((r) => r.id.equals(id));
return query.watchSingleOrNull().map((row) => row == null ? null : _toRecipe(row));
}
Future<Recipe?> getRecipe(String id) async {
final query = _db.select(_db.recipes)..where((r) => r.id.equals(id));
final row = await query.getSingleOrNull();
return row == null ? null : _toRecipe(row);
}
Future<List<Recipe>> allRecipes() async => (await _db.select(_db.recipes).get()).map(_toRecipe).toList();
Future<Set<String>> existingIds() async {
final query = _db.selectOnly(_db.recipes)..addColumns([_db.recipes.id]);
return {for (final row in await query.get()) row.read(_db.recipes.id)!};
}
/// Inserts or replaces the whole Recipe.
Future<void> saveRecipe(Recipe recipe) => _db.into(_db.recipes).insertOnConflictUpdate(_toCompanion(recipe));
Future<void> insertAll(List<Recipe> recipes) => _db.batch((b) {
b.insertAll(_db.recipes, recipes.map(_toCompanion));
});
/// Deletes the Recipe and its Photo files. Plan Entries that planned it
/// become notes with its name, so the Week Plan's history still reads right.
Future<void> deleteRecipe(Recipe recipe) async {
await _db.transaction(() async {
await (_db.update(_db.planEntries)..where((e) => e.recipeId.equals(recipe.id))).write(
PlanEntriesCompanion(recipeId: const Value(null), note: Value(recipe.name)),
);
await (_db.delete(_db.recipes)..where((r) => r.id.equals(recipe.id))).go();
});
await _photos.delete(recipe.photos);
}
Future<void> setFavourite(String id, bool favourite) =>
(_db.update(_db.recipes)..where((r) => r.id.equals(id))).write(RecipesCompanion(favourite: Value(favourite)));
/// The cook has checked the Recipe: removes its Review Flag.
Future<void> clearReview(String id) =>
(_db.update(_db.recipes)..where((r) => r.id.equals(id))).write(const RecipesCompanion(reviewReasons: Value([])));
static Recipe _toRecipe(RecipeRow row) => Recipe(
id: row.id,
name: row.name,
courseId: row.courseId,
tags: row.tags,
servings: row.servings,
prepMinutes: row.prepMinutes,
cookMinutes: row.cookMinutes,
calories: row.kcal == null ? null : Calories(row.kcal!, CaloriesBasis.fromKey(row.caloriesBasis ?? '')),
ingredientGroups: row.ingredientGroups,
stepSections: row.stepSections,
photos: row.photos,
sources: row.sources,
notes: row.notes,
favourite: row.favourite,
reviewReasons: row.reviewReasons,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
);
static RecipesCompanion _toCompanion(Recipe r) => RecipesCompanion.insert(
id: r.id,
name: r.name,
courseId: Value(r.courseId),
tags: r.tags,
servings: Value(r.servings),
prepMinutes: Value(r.prepMinutes),
cookMinutes: Value(r.cookMinutes),
kcal: Value(r.calories?.kcal),
caloriesBasis: Value(r.calories?.basis.key),
ingredientGroups: r.ingredientGroups,
stepSections: r.stepSections,
photos: r.photos,
sources: r.sources,
notes: Value(r.notes),
favourite: Value(r.favourite),
reviewReasons: r.reviewReasons,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
}
+8
View File
@@ -0,0 +1,8 @@
/// [text] for display with line-break opportunities where a name naturally
/// divides: after "-", "&" and "/", and between words written together
/// ("ApfelWeißkohlPfanne" breaks as "ApfelWeißkohl|Pfanne"). Without them
/// Flutter cuts a long word wherever the line runs out ("Hähnchenf|ilet").
/// Adds only invisible zero-width spaces; the text reads the same.
String withNaturalBreaks(String text) => text
.replaceAllMapped(RegExp(r'([-&/])(?=\p{L})', unicode: true), (m) => '${m[1]}​')
.replaceAllMapped(RegExp(r'(\p{Ll})(?=\p{Lu})', unicode: true), (m) => '${m[1]}​');
+20
View File
@@ -0,0 +1,20 @@
import '../localization/app_localizations.dart';
/// 45 -> "45 Min.", 60 -> "1 Std.", 70 -> "1 Std. 10 Min."
String formatMinutes(AppLocalizations l10n, int minutes) {
final hours = minutes ~/ 60;
final rest = minutes % 60;
if (hours == 0) return l10n.durationMinutes(rest);
if (rest == 0) return l10n.durationHours(hours);
return '${l10n.durationHours(hours)} ${l10n.durationMinutes(rest)}';
}
String greeting(AppLocalizations l10n, DateTime now) {
final minutes = now.hour * 60 + now.minute;
if (minutes < 5 * 60) return l10n.greetingLate;
if (minutes < 11 * 60 + 30) return l10n.greetingMorning;
if (minutes < 14 * 60) return l10n.greetingLunch;
if (minutes < 17 * 60) return l10n.greetingDay;
if (minutes < 22 * 60) return l10n.greetingEvening;
return l10n.greetingLate;
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../localization/app_localizations.dart';
import 'models/unit.dart';
import 'models/unit_definition.dart';
import 'unit_label.dart';
import 'unit_repository.dart';
/// The cook's list of units, kept current from the database: what the unit
/// picker offers directly, what sits under "Weitere Einheiten", and every label.
class UnitCatalog extends ChangeNotifier {
UnitCatalog(UnitRepository units) {
_subscription = units.watchUnits().listen((value) {
_units = value;
notifyListeners();
});
}
late final StreamSubscription<List<UnitDefinition>> _subscription;
List<UnitDefinition>? _units;
/// Until the database answers, the built-in defaults.
List<UnitDefinition> get units =>
_units ??
[
for (final (i, u) in Unit.values.indexed)
UnitDefinition(key: u.key, builtIn: true, direct: u.common, position: i),
];
Iterable<UnitDefinition> get direct => units.where((u) => u.direct);
Iterable<UnitDefinition> get more => units.where((u) => !u.direct);
UnitDefinition? byKey(String key) {
for (final unit in units) {
if (unit.key == key) return unit;
}
return null;
}
String label(AppLocalizations l10n, String key) {
final builtIn = Unit.fromKey(key);
if (builtIn != null) return unitLabel(l10n, builtIn);
return byKey(key)?.label ?? key;
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
+32
View File
@@ -0,0 +1,32 @@
import '../localization/app_localizations.dart';
import 'models/unit.dart';
String unitLabel(AppLocalizations l10n, Unit unit) => switch (unit) {
Unit.gram => l10n.unitGram,
Unit.kilogram => l10n.unitKilogram,
Unit.milliliter => l10n.unitMilliliter,
Unit.liter => l10n.unitLiter,
Unit.tablespoon => l10n.unitTablespoon,
Unit.teaspoon => l10n.unitTeaspoon,
Unit.pinch => l10n.unitPinch,
Unit.knifeTip => l10n.unitKnifeTip,
Unit.pack => l10n.unitPack,
Unit.piece => l10n.unitPiece,
Unit.slice => l10n.unitSlice,
Unit.clove => l10n.unitClove,
Unit.sprig => l10n.unitSprig,
Unit.bunch => l10n.unitBunch,
Unit.handful => l10n.unitHandful,
Unit.cup => l10n.unitCup,
Unit.can => l10n.unitCan,
Unit.jar => l10n.unitJar,
Unit.tub => l10n.unitTub,
Unit.bag => l10n.unitBag,
Unit.carton => l10n.unitCarton,
Unit.head => l10n.unitHead,
Unit.grain => l10n.unitGrain,
Unit.small => l10n.unitSmall,
Unit.medium => l10n.unitMedium,
Unit.large => l10n.unitLarge,
Unit.some => l10n.unitSome,
};
+102
View File
@@ -0,0 +1,102 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import 'models/recipe.dart';
import 'models/unit_definition.dart';
class UnitRepository {
UnitRepository(this._db);
final AppDatabase _db;
Stream<List<UnitDefinition>> watchUnits() {
final query = _db.select(_db.units)..orderBy([(u) => OrderingTerm.asc(u.position)]);
return query.watch().map((rows) => rows.map(_toUnit).toList());
}
Future<List<UnitDefinition>> allUnits() async {
final query = _db.select(_db.units)..orderBy([(u) => OrderingTerm.asc(u.position)]);
return (await query.get()).map(_toUnit).toList();
}
/// Adds one of the cook's own units at the end of the list, offered directly.
Future<UnitDefinition> addOwn(String label, {String? key, bool direct = true}) async {
final unit = UnitDefinition(
key: key ?? '${UnitDefinition.ownPrefix}${const Uuid().v4()}',
label: label,
builtIn: false,
direct: direct,
position: (await allUnits()).length,
);
await _db
.into(_db.units)
.insert(
UnitsCompanion.insert(
key: unit.key,
label: Value(unit.label),
builtIn: false,
direct: unit.direct,
position: unit.position,
),
);
return unit;
}
Future<void> rename(String key, String label) => (_db.update(
_db.units,
)..where((u) => u.key.equals(key) & u.builtIn.equals(false))).write(UnitsCompanion(label: Value(label)));
Future<void> setDirect(String key, bool direct) =>
(_db.update(_db.units)..where((u) => u.key.equals(key))).write(UnitsCompanion(direct: Value(direct)));
Future<void> reorder(List<UnitDefinition> ordered) => _db.batch((b) {
for (final (i, unit) in ordered.indexed) {
b.update(_db.units, UnitsCompanion(position: Value(i)), where: (u) => u.key.equals(unit.key));
}
});
/// Number of Recipes that use the unit in at least one Ingredient Line.
Future<int> recipeCount(String key) async => (await _recipesUsing(key)).length;
/// Deletes one of the cook's own units. Ingredient Lines that used it keep
/// their amount and get the unit's name in front of theirs: "2 Würfel Brühe".
Future<void> deleteOwn(UnitDefinition unit) => _db.transaction(() async {
if (unit.builtIn) throw ArgumentError('Built-in units cannot be deleted');
for (final row in await _recipesUsing(unit.key)) {
final groups = [
for (final g in row.ingredientGroups)
IngredientGroup(
name: g.name,
lines: [
for (final l in g.lines)
l.unitKey == unit.key
? IngredientLine(
amount: l.amount,
amountMax: l.amountMax,
name: '${unit.label} ${l.name}',
remark: l.remark,
)
: l,
],
),
];
await (_db.update(_db.recipes)..where((r) => r.id.equals(row.id))).write(
RecipesCompanion(ingredientGroups: Value(groups), updatedAt: Value(DateTime.now())),
);
}
await (_db.delete(_db.units)..where((u) => u.key.equals(unit.key))).go();
});
Future<List<RecipeRow>> _recipesUsing(String key) async {
// Cheap pre-filter on the stored JSON, then an exact check.
final candidates = await (_db.select(_db.recipes)..where((r) => r.ingredientGroups.like('%"$key"%'))).get();
return [
for (final row in candidates)
if (row.ingredientGroups.any((g) => g.lines.any((l) => l.unitKey == key))) row,
];
}
static UnitDefinition _toUnit(UnitRow row) =>
UnitDefinition(key: row.key, label: row.label, builtIn: row.builtIn, direct: row.direct, position: row.position);
}
@@ -0,0 +1,665 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../cooking_mode/cooking_mode_view.dart';
import '../../design/cookit_colors.dart';
import '../../design/herb_pattern.dart';
import '../../design/leaf_painter.dart';
import '../../localization/app_localizations.dart';
import '../../planning/day.dart';
import '../../planning/day_format.dart';
import '../../planning/plan_repository.dart';
import '../../planning/views/widgets/plan_slot_sheet.dart';
import '../../planning/week_plan_provider.dart';
import '../../sharing/views/share_recipe_view.dart';
import '../amount.dart';
import '../models/course.dart';
import '../models/recipe.dart';
import '../recipe_list_provider.dart';
import '../recipe_repository.dart';
import '../time_format.dart';
import 'recipe_edit_view.dart';
import 'widgets/ingredient_checklist.dart';
import 'widgets/recipe_photo.dart';
import 'widgets/share_ingredients_sheet.dart';
class RecipeDetailView extends StatefulWidget {
const RecipeDetailView({super.key, required this.recipeId});
static const routeName = '/recipe';
final String recipeId;
@override
State<RecipeDetailView> createState() => _RecipeDetailViewState();
}
class _RecipeDetailViewState extends State<RecipeDetailView> {
late final Stream<Recipe?> _recipe = context.read<RecipeRepository>().watchRecipe(widget.recipeId);
@override
Widget build(BuildContext context) {
return StreamBuilder<Recipe?>(
stream: _recipe,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final recipe = snapshot.data;
if (recipe == null) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text(AppLocalizations.of(context).recipeNotFound)),
);
}
final course = context.select<RecipeListProvider, Course?>((p) => p.courseOf(recipe));
return _RecipeDetail(recipe: recipe, course: course);
},
);
}
}
enum _Tab { ingredients, steps }
class _RecipeDetail extends StatefulWidget {
const _RecipeDetail({required this.recipe, required this.course});
final Recipe recipe;
final Course? course;
@override
State<_RecipeDetail> createState() => _RecipeDetailState();
}
class _RecipeDetailState extends State<_RecipeDetail> {
_Tab _tab = _Tab.ingredients;
/// Ingredient Lines ticked off while cooking ("group:line"); not saved.
final _checked = <String>{};
final _scroll = ScrollController();
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Recipe get recipe => widget.recipe;
Future<void> _delete() async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteRecipeTitle),
content: Text(l10n.deleteRecipeBody(recipe.name)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.delete)),
],
),
);
if (confirmed != true || !mounted) return;
final repository = context.read<RecipeRepository>();
Navigator.pop(context);
await repository.deleteRecipe(recipe);
}
/// "Einplanen": puts the Recipe on a day and Meal of the Week Plan.
Future<void> _plan() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final plan = context.read<PlanRepository>();
final meals = context.read<WeekPlanProvider>();
final slot = await showPlanSlotSheet(context, action: l10n.planRecipe);
if (slot == null) return;
final (day, mealId) = slot;
await plan.addEntry(day, mealId, recipeId: recipe.id);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.planned(dayLabel(l10n, day, dayOf(DateTime.now())), meals.mealById(mealId)?.name ?? '')),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final repository = context.read<RecipeRepository>();
final stepCount = recipe.stepSections.fold(0, (n, s) => n + s.steps.length);
return Scaffold(
backgroundColor: c.background,
body: Stack(
children: [
Positioned.fill(child: ParallaxEdgePattern(controller: _scroll)),
CustomScrollView(
controller: _scroll,
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 300,
backgroundColor: c.background,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
leading: Padding(
padding: const EdgeInsets.only(left: 8),
child: _RoundButton(
tooltip: l10n.back,
icon: Icons.arrow_back_rounded,
onPressed: () => Navigator.maybePop(context),
),
),
actions: [
_RoundButton(
tooltip: recipe.favourite ? l10n.removeFavourite : l10n.favourite,
icon: recipe.favourite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
color: CookItColors.light.favourite,
onPressed: () => repository.setFavourite(recipe.id, !recipe.favourite),
),
const SizedBox(width: 8),
_RoundButton(
tooltip: l10n.shareRecipe,
icon: Icons.qr_code_2_rounded,
onPressed: () => Navigator.pushNamed(context, ShareRecipeView.routeName, arguments: recipe.id),
),
const SizedBox(width: 8),
_RoundButton(tooltip: l10n.planRecipe, icon: Icons.event_available_rounded, onPressed: _plan),
const SizedBox(width: 8),
_RoundButton(
tooltip: l10n.edit,
icon: Icons.edit_outlined,
onPressed: () => Navigator.pushNamed(context, RecipeEditView.routeName, arguments: recipe.id),
),
const SizedBox(width: 8),
PopupMenuButton<void>(
tooltip: l10n.more,
style: IconButton.styleFrom(backgroundColor: Colors.white.withValues(alpha: 0.92)),
iconColor: CookItColors.light.ink,
itemBuilder: (_) => [PopupMenuItem(onTap: _delete, child: Text(l10n.delete))],
),
const SizedBox(width: 12),
],
flexibleSpace: FlexibleSpaceBar(
background: _PhotoPager(photos: recipe.photos, seed: recipe.id, emoji: widget.course?.emoji),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(28),
child: Container(
height: 28,
decoration: BoxDecoration(
color: c.background,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 120),
sliver: SliverList.list(
children: [
if (widget.course != null)
Text(
widget.course!.name.toUpperCase(),
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: c.green),
),
const SizedBox(height: 4),
Text(
recipe.name,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 32,
height: 1.05,
color: c.ink,
),
),
if (recipe.tags.isNotEmpty) ...[
const SizedBox(height: 10),
Wrap(spacing: 6, runSpacing: 6, children: [for (final tag in recipe.tags) _Pill(text: '#$tag')]),
],
const SizedBox(height: 16),
_Facts(recipe: recipe),
if (recipe.needsReview) ...[const SizedBox(height: 16), _ReviewBanner(recipe: recipe)],
const SizedBox(height: 16),
_TabSwitch(
selected: _tab,
ingredientsLabel: l10n.tabIngredients(recipe.ingredientLines.length),
stepsLabel: l10n.tabSteps(stepCount),
onChanged: (tab) => setState(() => _tab = tab),
),
const SizedBox(height: 12),
if (_tab == _Tab.ingredients) ..._ingredients(context) else ..._steps(context),
..._notesAndSources(context),
],
),
),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _CookButton(
onPressed: () => Navigator.pushNamed(context, CookingModeView.routeName, arguments: recipe.id),
),
),
],
),
);
}
List<Widget> _ingredients(BuildContext context) => [
IngredientChecklist(
groups: recipe.ingredientGroups,
checked: _checked,
onToggle: (key) => setState(() => _checked.contains(key) ? _checked.remove(key) : _checked.add(key)),
),
if (recipe.ingredientLines.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => showShareIngredients(context, recipe),
icon: const Icon(Icons.share_rounded),
label: Text(AppLocalizations.of(context).shareIngredients),
),
),
),
];
List<Widget> _steps(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
var number = 0;
return [
if (recipe.stepSections.isEmpty) Text(l10n.noSteps),
for (final section in recipe.stepSections) ...[
if (section.title != null)
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Text(section.title!, style: _heading(c)),
),
for (final step in section.steps)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(color: c.sage, shape: BoxShape.circle),
child: Text(
'${++number}',
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, color: c.ink),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(step, style: TextStyle(fontSize: 16, height: 1.45, color: c.ink)),
),
],
),
),
],
];
}
/// Notes and Sources, shown under whichever tab is open.
List<Widget> _notesAndSources(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return [
if (recipe.notes != null) ...[
Padding(
padding: const EdgeInsets.only(top: 20, bottom: 6),
child: Text(l10n.notes, style: _heading(c)),
),
SelectableText(recipe.notes!, style: TextStyle(fontSize: 16, height: 1.45, color: c.ink)),
],
if (recipe.sources.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(top: 20, bottom: 2),
child: Text(l10n.sources, style: _heading(c)),
),
for (final source in recipe.sources)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.link_rounded, color: c.green),
title: Text(source, maxLines: 2, overflow: TextOverflow.ellipsis),
onTap: () => launchUrl(Uri.parse(source), mode: LaunchMode.externalApplication),
),
],
];
}
static TextStyle _heading(CookItColors c) =>
TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w800, fontSize: 16, color: c.green);
}
class _RoundButton extends StatelessWidget {
const _RoundButton({required this.tooltip, required this.icon, required this.onPressed, this.color});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final Color? color;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip,
onPressed: onPressed,
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.92),
foregroundColor: color ?? CookItColors.light.ink,
fixedSize: const Size(44, 44),
),
icon: Icon(icon, size: 22),
);
}
}
class _PhotoPager extends StatelessWidget {
const _PhotoPager({required this.photos, required this.seed, this.emoji});
final List<String> photos;
final String seed;
final String? emoji;
@override
Widget build(BuildContext context) {
if (photos.length <= 1) {
return RecipePhoto(photo: photos.firstOrNull, seed: seed, placeholderEmoji: emoji, iconSize: 88);
}
return PageView(
children: [for (final photo in photos) RecipePhoto(photo: photo, seed: seed, placeholderEmoji: emoji)],
);
}
}
class _Pill extends StatelessWidget {
const _Pill({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(10)),
child: Text(
text,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w800, color: c.green),
),
);
}
}
class _Facts extends StatelessWidget {
const _Facts({required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tiles = CookItColors.of(context).tiles;
final calories = recipe.calories;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Fact(
icon: Icons.timer_outlined,
tint: tiles[0],
label: l10n.factTotal,
value: recipe.totalMinutes == null ? '–' : formatMinutes(l10n, recipe.totalMinutes!),
detail: recipe.prepMinutes != null && recipe.cookMinutes != null
? '${l10n.durationMinutes(recipe.prepMinutes!)} + ${l10n.durationMinutes(recipe.cookMinutes!)}'
: null,
),
const SizedBox(width: 8),
_Fact(
icon: Icons.people_alt_outlined,
tint: tiles[1],
label: l10n.factServings,
value: recipe.servings == null ? '–' : formatNumber(recipe.servings!),
),
const SizedBox(width: 8),
_Fact(
icon: Icons.local_fire_department_outlined,
tint: tiles[2],
label: l10n.factCalories,
value: calories == null ? '–' : l10n.kcalValue(calories.kcal),
detail: calories == null
? null
: calories.basis == CaloriesBasis.perServing
? l10n.basisPerServing
: l10n.basisWholeRecipe,
),
],
),
);
}
}
/// A tinted tile with an icon badge and a small leaf in its corner.
class _Fact extends StatelessWidget {
const _Fact({required this.icon, required this.tint, required this.label, required this.value, this.detail});
final IconData icon;
final Color tint;
final String label;
final String value;
final String? detail;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Expanded(
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(color: tint, borderRadius: BorderRadius.circular(20)),
child: Stack(
children: [
Positioned(
right: -10,
bottom: -12,
child: CustomPaint(size: const Size(44, 37), painter: LeafPainter(c.surface.withValues(alpha: 0.55))),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 10, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: c.surface, shape: BoxShape.circle),
child: Icon(icon, size: 18, color: c.green),
),
const SizedBox(height: 8),
Text(
label.toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, letterSpacing: 0.4, color: c.inkMuted),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 17, color: c.ink),
),
if (detail != null)
Text(
detail!,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
),
),
],
),
),
);
}
}
class _ReviewBanner extends StatelessWidget {
const _ReviewBanner({required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
decoration: BoxDecoration(color: c.honey, borderRadius: BorderRadius.circular(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error_outline_rounded, color: c.onHoney, size: 20),
const SizedBox(width: 8),
Text(
l10n.reviewTitle,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 16, color: c.onHoney),
),
],
),
const SizedBox(height: 6),
for (final reason in recipe.reviewReasons)
Padding(
padding: const EdgeInsets.only(bottom: 4, right: 8),
child: Text(
'• $reason',
style: TextStyle(color: c.onHoney, fontWeight: FontWeight.w600),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: c.onHoney),
onPressed: () => context.read<RecipeRepository>().clearReview(recipe.id),
icon: const Icon(Icons.check_rounded),
label: Text(l10n.reviewDone),
),
),
],
),
);
}
}
class _TabSwitch extends StatelessWidget {
const _TabSwitch({
required this.selected,
required this.ingredientsLabel,
required this.stepsLabel,
required this.onChanged,
});
final _Tab selected;
final String ingredientsLabel;
final String stepsLabel;
final ValueChanged<_Tab> onChanged;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
Widget tab(_Tab tab, String label) {
final active = tab == selected;
return Expanded(
child: Semantics(
selected: active,
button: true,
child: Material(
color: active ? c.surface : Colors.transparent,
borderRadius: BorderRadius.circular(18),
elevation: active ? 1 : 0,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => onChanged(tab),
child: SizedBox(
height: 40,
child: Center(
child: Text(
label,
style: TextStyle(
fontWeight: active ? FontWeight.w900 : FontWeight.w700,
fontSize: 15,
color: active ? c.ink : c.inkMuted,
),
),
),
),
),
),
),
);
}
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: c.navBar, borderRadius: BorderRadius.circular(22)),
child: Row(
children: [tab(_Tab.ingredients, ingredientsLabel), const SizedBox(width: 4), tab(_Tab.steps, stepsLabel)],
),
);
}
}
/// "Kochmodus starten", pinned at the bottom of the Recipe and fading into the page above it.
class _CookButton extends StatelessWidget {
const _CookButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [c.background.withValues(alpha: 0), c.background],
stops: const [0, 0.35],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
child: SizedBox(
height: 56,
child: FilledButton.icon(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: c.brand,
foregroundColor: c.onBrand,
shape: const StadiumBorder(),
elevation: 4,
shadowColor: c.green.withValues(alpha: 0.4),
textStyle: const TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 17),
),
icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: Text(l10n.cookStart),
),
),
),
),
);
}
}
+313
View File
@@ -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),
),
],
),
),
),
);
}
}
+631
View File
@@ -0,0 +1,631 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../design/cookit_nav_bar.dart';
import '../../design/herb_pattern.dart';
import '../../design/leaf_painter.dart';
import '../../localization/app_localizations.dart';
import '../../planning/models/plan_entry.dart';
import '../../planning/week_plan_provider.dart';
import '../../settings/settings_view.dart';
import '../../sharing/views/scan_recipe_view.dart';
import '../models/recipe.dart';
import '../recipe_list_provider.dart';
import '../recipe_repository.dart';
import '../time_format.dart';
import 'recipe_detail_view.dart';
import 'recipe_edit_view.dart';
import 'widgets/decision_helper_sheet.dart';
import 'widgets/recipe_card.dart';
import 'widgets/recipe_photo.dart';
/// The Rezepte tab: greeting, search, today's plan or idea, Review Flag banner, Course chips and the Recipe grid.
class RecipeListView extends StatefulWidget {
const RecipeListView({super.key});
@override
State<RecipeListView> createState() => _RecipeListViewState();
}
class _RecipeListViewState extends State<RecipeListView> {
late final _search = TextEditingController(text: context.read<RecipeListProvider>().filter.query);
final _scroll = ScrollController();
/// The cook asked for an idea although something is planned for today.
var _showIdea = false;
@override
void dispose() {
_search.dispose();
_scroll.dispose();
super.dispose();
}
void _openRecipe(Recipe recipe) => Navigator.pushNamed(context, RecipeDetailView.routeName, arguments: recipe.id);
void _decide() => showDecisionHelper(
context,
onOpen: (recipe) {
Navigator.pop(context);
_openRecipe(recipe);
},
);
/// "Heute geplant": today's first planned Recipe (or note) instead of the idea.
Widget _plannedCard(BuildContext context, RecipeListProvider provider, List<PlanEntry> planned) {
final l10n = AppLocalizations.of(context);
final byId = {for (final r in provider.allRecipes) r.id: r};
final first = planned.firstWhere((e) => byId.containsKey(e.recipeId), orElse: () => planned.first);
final recipe = byId[first.recipeId];
final meal = context.read<WeekPlanProvider>().mealById(first.mealId);
return _SuggestionCard(
label: l10n.plannedToday,
title: recipe?.name ?? first.note ?? '',
details: [
if (meal != null) meal.name,
if (recipe?.totalMinutes != null) formatMinutes(l10n, recipe!.totalMinutes!),
if (planned.length > 1) l10n.plannedTodayMore(planned.length - 1),
].join(' · '),
recipe: recipe,
onTap: recipe == null ? null : () => _openRecipe(recipe),
shuffleTooltip: l10n.showIdea,
onShuffle: () => setState(() => _showIdea = true),
onDecide: _decide,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final provider = context.watch<RecipeListProvider>();
final repository = context.read<RecipeRepository>();
final recipes = provider.visibleRecipes;
final filter = provider.filter;
final suggestion = provider.suggestion;
final planned = _showIdea ? const <PlanEntry>[] : context.watch<WeekPlanProvider>().entriesToday(DateTime.now());
final bottomInset = MediaQuery.paddingOf(context).bottom;
final topInset = MediaQuery.paddingOf(context).top;
// No Scaffold of its own: HomeView's is the one the SnackBars appear in.
return ColoredBox(
color: c.background,
child: Stack(
children: [
// Edge herbs drift at a third of the scroll speed; the header's own herbs scroll with it.
Positioned.fill(child: ParallaxEdgePattern(controller: _scroll)),
Positioned.fill(
child: !provider.loaded
? const Center(child: CircularProgressIndicator())
: CustomScrollView(
controller: _scroll,
slivers: [
SliverToBoxAdapter(
child: Stack(
children: [
const Positioned(top: 0, left: 0, right: 0, child: HeaderDecor()),
Padding(
padding: EdgeInsets.fromLTRB(20, topInset + 16, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(
greeting: greeting(l10n, DateTime.now()),
onSettings: () => Navigator.pushNamed(context, SettingsView.routeName),
),
const SizedBox(height: 16),
_SearchField(controller: _search, onChanged: provider.setQuery),
if (planned.isNotEmpty) ...[
const SizedBox(height: 16),
_plannedCard(context, provider, planned),
] else if (suggestion != null) ...[
const SizedBox(height: 16),
_SuggestionCard(
label: l10n.suggestionLabel,
title: suggestion.name,
details: [
if (suggestion.totalMinutes != null)
formatMinutes(l10n, suggestion.totalMinutes!),
if (provider.courseOf(suggestion) case final course?) course.name,
].join(' · '),
recipe: suggestion,
onTap: () => _openRecipe(suggestion),
shuffleTooltip: l10n.anotherIdea,
onShuffle: provider.shuffleSuggestion,
onDecide: _decide,
),
],
if (provider.reviewCount > 0 || filter.reviewOnly) ...[
const SizedBox(height: 16),
_ReviewBanner(
text: filter.reviewOnly
? l10n.reviewBannerActive
: l10n.reviewBanner(provider.reviewCount),
active: filter.reviewOnly,
onTap: provider.toggleReview,
),
],
const SizedBox(height: 16),
],
),
),
],
),
),
SliverToBoxAdapter(
child: SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
_CourseChip(
label: l10n.filterAll,
selected: filter.courseId == null && !filter.favouritesOnly,
onTap: () {
if (filter.courseId != null) provider.toggleCourse(filter.courseId!);
if (filter.favouritesOnly) provider.toggleFavourites();
},
),
_CourseChip(
label: l10n.filterFavourites,
icon: Icons.favorite_rounded,
selected: filter.favouritesOnly,
onTap: provider.toggleFavourites,
),
for (final course in provider.courses)
_CourseChip(
label: course.name,
selected: filter.courseId == course.id,
onTap: () => provider.toggleCourse(course.id),
),
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
if (recipes.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: provider.allRecipes.isEmpty
? _EmptyState(title: l10n.emptyCollectionTitle, body: l10n.emptyCollectionBody)
: _EmptyState(
title: l10n.noMatchesTitle,
body: l10n.noMatchesBody,
action: TextButton(
onPressed: () {
_search.clear();
provider.resetFilter();
},
child: Text(l10n.resetFilters),
),
),
)
else
SliverPadding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 120 + bottomInset),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 240,
mainAxisExtent: RecipeCard.height,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: recipes.length,
itemBuilder: (context, i) {
final recipe = recipes[i];
return RecipeCard(
recipe: recipe,
course: provider.courseOf(recipe),
onTap: () => _openRecipe(recipe),
onToggleFavourite: () => repository.setFavourite(recipe.id, !recipe.favourite),
);
},
),
),
],
),
),
// Keeps scrolled content from running under the status bar.
Positioned(
top: 0,
left: 0,
right: 0,
height: topInset,
child: ColoredBox(color: c.background.withValues(alpha: 0.94)),
),
Positioned(
right: 20,
bottom: 16 + bottomInset + CookItNavBar.height + 16,
child: _AddButton(
tooltip: l10n.newRecipe,
onPressed: () => Navigator.pushNamed(context, RecipeEditView.routeName),
),
),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.greeting, required this.onSettings});
final String greeting;
final VoidCallback onSettings;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
greeting,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: c.inkMuted),
),
const SizedBox(height: 2),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 240),
child: Text(
l10n.homeTitle,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 32,
height: 1.05,
color: c.ink,
),
),
),
],
),
),
IconButton(
tooltip: l10n.scanRecipe,
onPressed: () => Navigator.pushNamed(context, ScanRecipeView.routeName),
style: IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(44, 44),
elevation: 2,
shadowColor: c.green.withValues(alpha: 0.3),
),
icon: const Icon(Icons.qr_code_scanner_rounded, size: 20),
),
const SizedBox(width: 8),
IconButton(
tooltip: l10n.settings,
onPressed: onSettings,
style: IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(44, 44),
elevation: 2,
shadowColor: c.green.withValues(alpha: 0.3),
),
icon: const Icon(Icons.tune_rounded, size: 20),
),
],
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({required this.controller, required this.onChanged});
final TextEditingController controller;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
const pill = OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(26)), borderSide: BorderSide.none);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(26),
boxShadow: [BoxShadow(color: c.green.withValues(alpha: 0.08), blurRadius: 12, offset: const Offset(0, 4))],
),
child: TextField(
controller: controller,
onChanged: onChanged,
textInputAction: TextInputAction.search,
style: TextStyle(fontSize: 16, color: c.ink),
decoration: InputDecoration(
hintText: l10n.searchHint,
filled: true,
fillColor: c.surface,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 18, right: 10),
child: Icon(Icons.search_rounded, color: c.green),
),
prefixIconConstraints: const BoxConstraints(minHeight: 52),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
border: pill,
enabledBorder: pill,
focusedBorder: pill.copyWith(borderSide: BorderSide(color: c.brand, width: 2)),
),
),
);
}
}
/// The sage card at the top: "Idee für heute" or "Heute geplant".
class _SuggestionCard extends StatelessWidget {
const _SuggestionCard({
required this.label,
required this.title,
required this.details,
required this.recipe,
required this.onTap,
required this.shuffleTooltip,
required this.onShuffle,
required this.onDecide,
});
final String label;
final String title;
final String details;
/// Shows its photo; null for a planned note.
final Recipe? recipe;
final VoidCallback? onTap;
final String shuffleTooltip;
final VoidCallback onShuffle;
final VoidCallback onDecide;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final round = IconButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: CookItColors.light.green,
fixedSize: const Size(44, 44),
);
final recipe = this.recipe;
return Material(
color: c.sage,
borderRadius: BorderRadius.circular(24),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Stack(
children: [
Positioned(
right: -16,
bottom: -26,
child: CustomPaint(size: const Size(90, 76), painter: LeafPainter(c.lime)),
),
Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(18),
child: SizedBox(
width: 80,
height: 80,
child: recipe == null || recipe.mainPhoto == null
? ColoredBox(
color: Colors.white,
child: Icon(
recipe == null ? Icons.sticky_note_2_outlined : Icons.dinner_dining_rounded,
size: 38,
color: CookItColors.light.green,
),
)
: RecipePhoto(photo: recipe.mainPhoto, seed: recipe.id, cacheWidth: 240),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, letterSpacing: 0.7, color: c.ink),
),
const SizedBox(height: 4),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 19,
height: 1.2,
color: c.ink,
),
),
if (details.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
details,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: c.ink),
),
],
],
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: shuffleTooltip,
onPressed: onShuffle,
style: round,
icon: const Icon(Icons.shuffle_rounded, size: 20),
),
const SizedBox(height: 6),
IconButton(
tooltip: l10n.decisionButton,
onPressed: onDecide,
style: round,
icon: const Icon(Icons.quiz_outlined, size: 20),
),
],
),
],
),
),
],
),
),
);
}
}
class _ReviewBanner extends StatelessWidget {
const _ReviewBanner({required this.text, required this.active, required this.onTap});
final String text;
final bool active;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Material(
color: c.honey,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(active ? Icons.close_rounded : Icons.error_outline_rounded, size: 20, color: c.onHoney),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: c.onHoney),
),
),
if (!active) Icon(Icons.chevron_right_rounded, color: c.onHoney),
],
),
),
),
);
}
}
class _CourseChip extends StatelessWidget {
const _CourseChip({required this.label, required this.selected, required this.onTap, this.icon});
final String label;
final bool selected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Semantics(
selected: selected,
button: true,
child: Material(
color: selected ? c.lime : c.surface,
shape: StadiumBorder(side: BorderSide(color: selected ? c.brand : c.chipBorder, width: 2)),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: CookItColors.light.favourite),
const SizedBox(width: 6),
],
Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w900 : FontWeight.w800,
fontSize: 14,
color: selected ? CookItColors.light.onBrand : c.ink,
),
),
],
),
),
),
),
),
);
}
}
class _AddButton extends StatelessWidget {
const _AddButton({required this.tooltip, required this.onPressed});
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Tooltip(
message: tooltip,
child: Material(
color: c.brand,
elevation: 6,
shadowColor: c.green.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onPressed,
child: SizedBox(
width: 60,
height: 60,
child: Icon(Icons.add_rounded, size: 30, color: c.onBrand, semanticLabel: tooltip),
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.title, required this.body, this.action});
final String title;
final String body;
final Widget? action;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final c = CookItColors.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(32, 32, 32, 140),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.eco_rounded, size: 64, color: c.brand),
const SizedBox(height: 16),
Text(title, style: theme.textTheme.titleLarge, textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(body, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
if (action != null) ...[const SizedBox(height: 12), action!],
],
),
);
}
}
@@ -0,0 +1,271 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../decision.dart';
import '../../models/recipe.dart';
import '../../recipe_list_provider.dart';
import '../../time_format.dart';
import 'recipe_photo.dart';
/// "Hilf mir entscheiden": two quick questions, then one matching Recipe at a time.
Future<void> showDecisionHelper(BuildContext context, {required ValueChanged<Recipe> onOpen}) =>
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _DecisionHelperSheet(onOpen: onOpen),
);
enum _Step { course, time, result }
class _DecisionHelperSheet extends StatefulWidget {
const _DecisionHelperSheet({required this.onOpen});
final ValueChanged<Recipe> onOpen;
@override
State<_DecisionHelperSheet> createState() => _DecisionHelperSheetState();
}
class _DecisionHelperSheetState extends State<_DecisionHelperSheet> {
final _random = Random();
var _step = _Step.course;
var _courseIds = <String>{};
var _time = TimeLimit.any;
Recipe? _pick;
DecisionChoice get _choice => DecisionChoice(courseIds: _courseIds, time: _time);
void _showResult([DecisionChoice? choice]) {
if (choice != null) {
_courseIds = {...choice.courseIds};
_time = choice.time;
}
final matches = _choice.matching(context.read<RecipeListProvider>().allRecipes);
setState(() {
_step = _Step.result;
_pick = _pickFrom(matches);
});
}
Recipe? _pickFrom(List<Recipe> matches) {
if (matches.isEmpty) return null;
final others = matches.where((r) => r.id != _pick?.id).toList();
final pool = others.isEmpty ? matches : others;
return pool[_random.nextInt(pool.length)];
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: switch (_step) {
_Step.course => _courseStep(context),
_Step.time => _timeStep(context),
_Step.result => _resultStep(context),
},
),
),
);
}
Widget _courseStep(BuildContext context) {
final l10n = AppLocalizations.of(context);
final courses = context.read<RecipeListProvider>().courses;
return _Question(
title: l10n.decisionCourseQuestion,
hint: l10n.decisionCourseHint,
options: [
for (final course in courses)
FilterChip(
label: Text(course.name),
selected: _courseIds.contains(course.id),
onSelected: (on) => setState(() => on ? _courseIds.add(course.id) : _courseIds.remove(course.id)),
),
],
action: FilledButton(onPressed: () => setState(() => _step = _Step.time), child: Text(l10n.decisionNext)),
);
}
Widget _timeStep(BuildContext context) {
final l10n = AppLocalizations.of(context);
final labels = {
TimeLimit.upTo30: l10n.decisionUpTo30,
TimeLimit.upTo60: l10n.decisionUpTo60,
TimeLimit.any: l10n.decisionAnyTime,
};
return _Question(
title: l10n.decisionTimeQuestion,
options: [
for (final MapEntry(key: limit, value: label) in labels.entries)
ChoiceChip(label: Text(label), selected: _time == limit, onSelected: (_) => setState(() => _time = limit)),
],
onBack: () => setState(() => _step = _Step.course),
action: FilledButton(onPressed: _showResult, child: Text(l10n.decisionShow)),
);
}
Widget _resultStep(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final provider = context.read<RecipeListProvider>();
final matches = _choice.matching(provider.allRecipes);
final pick = _pick;
final startOver = TextButton(
onPressed: () => setState(() => _step = _Step.course),
child: Text(l10n.decisionStartOver),
);
if (pick == null) {
final withoutTime = _choice.withoutTimeLimit();
final allCourses = _choice.withAllCourses();
final withoutTimeCount = withoutTime.matching(provider.allRecipes).length;
final allCoursesCount = allCourses.matching(provider.allRecipes).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.search_off_rounded, size: 48, color: c.brand),
const SizedBox(height: 8),
Text(l10n.decisionNoneTitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (_time != TimeLimit.any && withoutTimeCount > 0)
OutlinedButton(
onPressed: () => _showResult(withoutTime),
child: Text(l10n.decisionWithoutTime(withoutTimeCount)),
),
if (_courseIds.isNotEmpty && allCoursesCount > 0) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _showResult(allCourses),
child: Text(l10n.decisionAllCourses(allCoursesCount)),
),
],
const SizedBox(height: 8),
startOver,
],
);
}
final course = provider.courseOf(pick);
final details = [
if (pick.totalMinutes != null) formatMinutes(l10n, pick.totalMinutes!),
if (course != null) course.name,
].join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.decisionMatches(matches.length),
style: TextStyle(fontWeight: FontWeight.w800, color: c.inkMuted),
),
const SizedBox(height: 10),
Material(
color: c.sage,
borderRadius: BorderRadius.circular(24),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => widget.onOpen(pick),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 170,
child: RecipePhoto(
photo: pick.mainPhoto,
seed: pick.id,
placeholderEmoji: course?.emoji,
cacheWidth: 800,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pick.name,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
if (details.isNotEmpty)
Text(
details,
style: TextStyle(fontWeight: FontWeight.w700, color: c.ink),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: matches.length > 1 ? () => setState(() => _pick = _pickFrom(matches)) : null,
icon: const Icon(Icons.refresh_rounded),
label: Text(l10n.decisionAnother),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(onPressed: () => widget.onOpen(pick), child: Text(l10n.decisionOpen)),
),
],
),
const SizedBox(height: 4),
startOver,
],
);
}
}
class _Question extends StatelessWidget {
const _Question({required this.title, required this.options, required this.action, this.hint, this.onBack});
final String title;
final String? hint;
final List<Widget> options;
final Widget action;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
if (hint != null) ...[const SizedBox(height: 4), Text(hint!, style: TextStyle(color: c.inkMuted))],
const SizedBox(height: 16),
Wrap(spacing: 8, runSpacing: 8, children: options),
const SizedBox(height: 20),
Row(
children: [
if (onBack != null) ...[
IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
onPressed: onBack,
icon: const Icon(Icons.arrow_back_rounded),
),
const SizedBox(width: 8),
],
Expanded(child: SizedBox(height: 52, child: action)),
],
),
],
);
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../amount.dart';
import '../../models/recipe.dart';
import '../../unit_catalog.dart';
/// Ingredient Groups as a list the cook can tick off. Ticks are keyed "group:line".
class IngredientChecklist extends StatelessWidget {
const IngredientChecklist({
super.key,
required this.groups,
required this.checked,
required this.onToggle,
this.large = false,
});
final List<IngredientGroup> groups;
final Set<String> checked;
final ValueChanged<String> onToggle;
/// Bigger type for Cooking Mode.
final bool large;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final units = context.watch<UnitCatalog>();
if (groups.isEmpty) return Text(l10n.noIngredients);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (g, group) in groups.indexed) ...[
if (group.name != null)
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Text(
group.name!,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w800,
fontSize: large ? 18 : 16,
color: c.green,
),
),
)
else if (g > 0)
const SizedBox(height: 16),
for (final (i, line) in group.lines.indexed)
_IngredientRow(
line: line,
large: large,
checked: checked.contains('$g:$i'),
amountText: [
formatAmount(line.amount, line.amountMax),
if (line.unitKey != null) units.label(l10n, line.unitKey!),
].where((s) => s.isNotEmpty).join(' '),
onTap: () => onToggle('$g:$i'),
),
],
],
);
}
}
class _IngredientRow extends StatelessWidget {
const _IngredientRow({
required this.line,
required this.large,
required this.checked,
required this.amountText,
required this.onTap,
});
final IngredientLine line;
final bool large;
final bool checked;
final String amountText;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final faded = checked ? c.inkMuted.withValues(alpha: 0.6) : c.ink;
final decoration = checked ? TextDecoration.lineThrough : null;
final size = large ? 18.0 : 15.0;
return InkWell(
onTap: onTap,
child: Container(
constraints: BoxConstraints(minHeight: large ? 54 : 46),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: c.chipBorder.withValues(alpha: 0.5))),
),
child: Row(
children: [
Checkbox(
value: checked,
onChanged: (_) => onTap(),
activeColor: c.brand,
checkColor: c.onBrand,
side: BorderSide(color: c.chipBorder, width: 2),
),
SizedBox(
width: large ? 118 : 104,
child: Text(
amountText,
style: TextStyle(fontWeight: FontWeight.w800, fontSize: size, color: faded, decoration: decoration),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text.rich(
TextSpan(
text: line.name,
children: [
if (line.remark != null)
TextSpan(
text: ' ${line.remark}',
style: TextStyle(color: c.inkMuted),
),
],
),
style: TextStyle(fontSize: size, color: faded, decoration: decoration),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../localization/app_localizations.dart';
import '../../amount.dart';
import '../../recipe_draft.dart';
import '../../unit_catalog.dart';
/// Reorderable Ingredient Lines and group headings; drag a line across a heading to regroup it.
class IngredientEditor extends StatelessWidget {
const IngredientEditor({super.key, required this.entries, required this.onChanged});
final List<IngredientEntry> entries;
/// Called after the list itself changed (added, removed, moved, unit picked).
final VoidCallback onChanged;
void _add(IngredientEntry entry) {
entries.add(entry);
onChanged();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: entries.length,
onReorder: (from, to) {
if (to > from) to--;
entries.insert(to, entries.removeAt(from));
onChanged();
},
itemBuilder: (context, i) {
final entry = entries[i];
void remove() {
final removed = entries.removeAt(i);
onChanged();
// Its fields are still mounted until the next frame.
WidgetsBinding.instance.addPostFrameCallback((_) => removed.dispose());
}
return switch (entry) {
GroupEntry() => _GroupRow(key: ValueKey(entry.key), index: i, entry: entry, onRemove: remove),
LineEntry() => _LineRow(
key: ValueKey(entry.key),
index: i,
entry: entry,
onRemove: remove,
onChanged: onChanged,
),
};
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _add(LineEntry()),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addIngredient),
),
OutlinedButton.icon(
onPressed: () => _add(GroupEntry()),
icon: const Icon(Icons.playlist_add_rounded),
label: Text(l10n.addIngredientGroup),
),
],
),
],
);
}
}
class _GroupRow extends StatelessWidget {
const _GroupRow({super.key, required this.index, required this.entry, required this.onRemove});
final int index;
final GroupEntry entry;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: Row(
children: [
_Handle(index: index),
Expanded(
child: TextFormField(
controller: entry.name,
style: theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary),
decoration: InputDecoration(hintText: l10n.groupName),
textCapitalization: TextCapitalization.sentences,
),
),
IconButton(tooltip: l10n.removeItem, onPressed: onRemove, icon: const Icon(Icons.close_rounded)),
],
),
);
}
}
class _LineRow extends StatelessWidget {
const _LineRow({
super.key,
required this.index,
required this.entry,
required this.onRemove,
required this.onChanged,
});
final int index;
final LineEntry entry;
final VoidCallback onRemove;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
const dense = InputDecoration(isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 12));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: _Handle(index: index),
),
Expanded(
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 64,
child: TextFormField(
controller: entry.amount,
decoration: dense.copyWith(
hintText: l10n.ingredientAmount,
hintStyle: const TextStyle(fontSize: 12),
errorMaxLines: 3,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => parseAmount(v ?? '') == null ? l10n.invalidAmount : null,
),
),
const SizedBox(width: 4),
SizedBox(
width: 84,
child: _UnitField(
unitKey: entry.unitKey,
decoration: dense,
onChanged: (key) {
entry.unitKey = key;
onChanged();
},
),
),
const SizedBox(width: 4),
Expanded(
child: TextFormField(
controller: entry.name,
decoration: dense.copyWith(hintText: l10n.ingredientName),
textCapitalization: TextCapitalization.sentences,
),
),
],
),
if (entry.showRemark) ...[
const SizedBox(height: 4),
TextFormField(
controller: entry.remark,
autofocus: entry.remark.text.isEmpty,
style: Theme.of(context).textTheme.bodySmall,
decoration: dense.copyWith(hintText: l10n.ingredientRemark),
),
],
],
),
),
PopupMenuButton<void>(
padding: EdgeInsets.zero,
itemBuilder: (_) => [
if (!entry.showRemark)
PopupMenuItem(
onTap: () {
entry.showRemark = true;
onChanged();
},
child: Text(l10n.addRemark),
),
PopupMenuItem(onTap: onRemove, child: Text(l10n.removeItem)),
],
),
],
),
);
}
}
/// Looks like the other fields; opens a sheet with the cook's direct units and,
/// behind "Weitere Einheiten", all the others.
class _UnitField extends StatelessWidget {
const _UnitField({required this.unitKey, required this.decoration, required this.onChanged});
final String? unitKey;
final InputDecoration decoration;
final ValueChanged<String?> onChanged;
Future<void> _pick(BuildContext context) async {
final picked = await showModalBottomSheet<({String? key})>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => _UnitSheet(current: unitKey),
);
if (picked != null && picked.key != unitKey) onChanged(picked.key);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final units = context.watch<UnitCatalog>();
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => _pick(context),
child: InputDecorator(
decoration: decoration.copyWith(hintText: l10n.ingredientUnit),
isEmpty: false,
child: Row(
children: [
Expanded(
child: Text(
unitKey == null ? l10n.noUnit : units.label(l10n, unitKey!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down_rounded, size: 20),
],
),
),
);
}
}
class _UnitSheet extends StatefulWidget {
const _UnitSheet({required this.current});
final String? current;
@override
State<_UnitSheet> createState() => _UnitSheetState();
}
class _UnitSheetState extends State<_UnitSheet> {
late bool _showMore = () {
final current = widget.current;
if (current == null) return false;
return !(context.read<UnitCatalog>().byKey(current)?.direct ?? false);
}();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final units = context.watch<UnitCatalog>();
Widget chip(String? key) => ChoiceChip(
label: Text(key == null ? l10n.noUnit : units.label(l10n, key)),
selected: key == widget.current,
onSelected: (_) => Navigator.pop(context, (key: key)),
);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.ingredientUnit, style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [chip(null), for (final u in units.direct) chip(u.key)]),
const SizedBox(height: 12),
if (_showMore)
Wrap(spacing: 8, runSpacing: 8, children: [for (final u in units.more) chip(u.key)])
else if (units.more.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() => _showMore = true),
icon: const Icon(Icons.expand_more_rounded),
label: Text(l10n.moreUnits),
),
),
],
),
),
);
}
}
class _Handle extends StatelessWidget {
const _Handle({required this.index});
final int index;
@override
Widget build(BuildContext context) => ReorderableDragStartListener(
index: index,
child: const Padding(padding: EdgeInsets.symmetric(horizontal: 4), child: Icon(Icons.drag_indicator_rounded)),
);
}
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../../../localization/app_localizations.dart';
import '../../photo_store.dart';
import 'recipe_photo.dart';
/// Picker settings that match the Excel import: at most 1600 px, JPEG ~80 %.
const _maxEdge = 1600.0;
const _quality = 82;
/// Horizontal, reorderable Photo strip; the first Photo is the main one.
class PhotoEditor extends StatelessWidget {
const PhotoEditor({super.key, required this.photos, required this.onAdded, required this.onChanged});
final List<String> photos;
/// Photo files newly stored while editing (so they can be cleaned up on discard).
final ValueChanged<List<String>> onAdded;
final VoidCallback onChanged;
Future<void> _pick(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final source = await showModalBottomSheet<ImageSource>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: Text(l10n.photoFromCamera),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: Text(l10n.photoFromGallery),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null || !context.mounted) return;
final store = context.read<PhotoStore>();
final picker = ImagePicker();
final picked = source == ImageSource.camera
? [?await picker.pickImage(source: source, maxWidth: _maxEdge, maxHeight: _maxEdge, imageQuality: _quality)]
: await picker.pickMultiImage(maxWidth: _maxEdge, maxHeight: _maxEdge, imageQuality: _quality);
if (picked.isEmpty) return;
final names = [for (final file in picked) await store.importFile(file.path)];
photos.addAll(names);
onAdded(names);
onChanged();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 112,
child: Row(
children: [
_AddTile(label: l10n.addPhoto, onTap: () => _pick(context)),
const SizedBox(width: 8),
Expanded(
child: ReorderableListView.builder(
scrollDirection: Axis.horizontal,
buildDefaultDragHandles: true,
itemCount: photos.length,
onReorder: (from, to) {
if (to > from) to--;
photos.insert(to, photos.removeAt(from));
onChanged();
},
itemBuilder: (context, i) => Padding(
key: ValueKey(photos[i]),
padding: const EdgeInsets.only(right: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: 112,
child: Stack(
fit: StackFit.expand,
children: [
RecipePhoto(photo: photos[i], cacheWidth: 300),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(onTap: () => _showPhotoMenu(context, i)),
),
),
if (i == 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: scheme.primary.withValues(alpha: 0.85),
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
l10n.mainPhoto,
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onPrimary, fontSize: 12),
),
),
),
Positioned(
top: 4,
right: 4,
child: IconButton.filledTonal(
tooltip: l10n.removePhoto,
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: () {
photos.removeAt(i);
onChanged();
},
icon: const Icon(Icons.close_rounded),
),
),
],
),
),
),
),
),
),
],
),
),
if (photos.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6, left: 4),
child: Text(
l10n.photoHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
),
),
],
);
}
/// Tap on a Photo: make it the main Photo or remove it, without dragging.
Future<void> _showPhotoMenu(BuildContext context, int index) async {
final l10n = AppLocalizations.of(context);
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (index > 0)
ListTile(
leading: const Icon(Icons.vertical_align_top_rounded),
title: Text(l10n.setMainPhoto),
onTap: () => Navigator.pop(context, 'main'),
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: Text(l10n.removePhoto),
onTap: () => Navigator.pop(context, 'remove'),
),
],
),
),
);
switch (action) {
case 'main':
photos.insert(0, photos.removeAt(index));
onChanged();
case 'remove':
photos.removeAt(index);
onChanged();
}
}
}
class _AddTile extends StatelessWidget {
const _AddTile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: SizedBox(
width: 96,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo_outlined, color: scheme.onPrimaryContainer),
const SizedBox(height: 6),
Text(
label,
textAlign: TextAlign.center,
style: TextStyle(color: scheme.onPrimaryContainer, fontSize: 12),
),
],
),
),
),
);
}
}
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../models/course.dart';
import '../../models/recipe.dart';
import '../../text_breaks.dart';
import '../../time_format.dart';
import 'recipe_photo.dart';
class RecipeCard extends StatelessWidget {
const RecipeCard({
super.key,
required this.recipe,
this.course,
required this.onTap,
required this.onToggleFavourite,
});
/// Height the grid gives each card.
static const height = 210.0;
final Recipe recipe;
final Course? course;
final VoidCallback onTap;
final VoidCallback onToggleFavourite;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final total = recipe.totalMinutes;
return Material(
color: c.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: c.cardBorder == null ? BorderSide.none : BorderSide(color: c.cardBorder!),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 118,
child: Stack(
fit: StackFit.expand,
children: [
RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: course?.emoji,
cacheWidth: 500,
),
if (course != null)
Positioned(
left: 8,
bottom: 8,
right: 48,
child: Align(
alignment: Alignment.centerLeft,
child: _Tag(text: course!.name),
),
),
Positioned(
top: 4,
right: 4,
child: _FavouriteButton(
favourite: recipe.favourite,
tooltip: recipe.favourite ? l10n.removeFavourite : l10n.favourite,
onPressed: onToggleFavourite,
),
),
if (recipe.needsReview)
Positioned(
top: 10,
left: 10,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(color: c.honey, shape: BoxShape.circle),
child: Icon(Icons.priority_high_rounded, size: 16, color: c.onHoney),
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
withNaturalBreaks(recipe.name),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 15,
height: 1.2,
color: c.ink,
),
),
const Spacer(),
if (total != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: c.background, borderRadius: BorderRadius.circular(8)),
child: Text(
formatMinutes(l10n, total),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.ink),
),
),
],
),
),
),
],
),
),
);
}
}
/// White label with green text on a photo (also in dark mode, since it sits on the picture).
class _Tag extends StatelessWidget {
const _Tag({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: CookItColors.light.green, height: 1.3),
),
);
}
}
class _FavouriteButton extends StatelessWidget {
const _FavouriteButton({required this.favourite, required this.tooltip, required this.onPressed});
final bool favourite;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip,
onPressed: onPressed,
style: IconButton.styleFrom(
backgroundColor: Colors.white,
fixedSize: const Size(36, 36),
minimumSize: const Size(36, 36),
padding: EdgeInsets.zero,
),
icon: Icon(
favourite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
size: 18,
color: CookItColors.light.favourite,
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../photo_store.dart';
/// A stored Photo, or a soft-coloured plate placeholder when there is none.
class RecipePhoto extends StatelessWidget {
const RecipePhoto({
super.key,
required this.photo,
this.seed = '',
this.placeholderEmoji,
this.cacheWidth,
this.fit = BoxFit.cover,
this.iconSize = 44,
});
final String? photo;
/// Picks the placeholder's tint, so a Recipe keeps the same colour everywhere.
final String seed;
final String? placeholderEmoji;
/// Decode width in pixels; keeps grids light on memory.
final int? cacheWidth;
final BoxFit fit;
final double iconSize;
@override
Widget build(BuildContext context) {
final name = photo;
final placeholder = _Placeholder(seed: seed, emoji: placeholderEmoji, iconSize: iconSize);
if (name == null) return placeholder;
return Image.file(
context.read<PhotoStore>().file(name),
fit: fit,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => placeholder,
);
}
}
const _tints = [
(Color(0xFFBFE3A8), Color(0xFF1F4D27)),
(Color(0xFFF6E28A), Color(0xFF5C4300)),
(Color(0xFFF7CDB5), Color(0xFF6E2A10)),
(Color(0xFFC8E6DD), Color(0xFF1E4A40)),
];
class _Placeholder extends StatelessWidget {
const _Placeholder({required this.seed, this.emoji, required this.iconSize});
final String seed;
final String? emoji;
final double iconSize;
@override
Widget build(BuildContext context) {
final (tint, ink) = _tints[seed.codeUnits.fold(0, (a, b) => a + b) % _tints.length];
return ColoredBox(
color: tint,
child: Center(
child: emoji != null
? Text(emoji!, style: TextStyle(fontSize: iconSize * 0.9))
: Icon(Icons.dinner_dining_rounded, size: iconSize, color: ink.withValues(alpha: 0.7)),
),
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../amount.dart';
/// "Portionen – 4 +": picks how many servings to cook or shop for.
class ServingsStepper extends StatelessWidget {
const ServingsStepper({super.key, required this.servings, required this.onFewer, required this.onMore});
final double servings;
final VoidCallback? onFewer;
final VoidCallback onMore;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final button = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
disabledBackgroundColor: c.surface.withValues(alpha: 0.5),
fixedSize: const Size(48, 48),
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(color: c.tiles[1], borderRadius: BorderRadius.circular(20)),
child: Row(
children: [
Icon(Icons.people_alt_outlined, color: c.green),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.factServings,
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: c.ink),
),
),
IconButton(
tooltip: l10n.servingsFewer,
onPressed: onFewer,
style: button,
icon: const Icon(Icons.remove_rounded),
),
SizedBox(
width: 52,
child: Text(
formatNumber(servings),
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
),
IconButton(tooltip: l10n.servingsMore, onPressed: onMore, style: button, icon: const Icon(Icons.add_rounded)),
],
),
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../cooking_mode/cooking_session.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../amount.dart';
import '../../ingredients_text.dart';
import '../../models/recipe.dart';
import '../../unit_catalog.dart';
import 'servings_stepper.dart';
import 'share_or_copy_buttons.dart';
/// "Zutaten teilen": the Recipe's ingredients as plain text, for as many
/// servings as the cook wants, handed to Android's share menu (e.g. to Bring!).
Future<void> showShareIngredients(BuildContext context, Recipe recipe) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _ShareIngredientsSheet(recipe: recipe),
);
class _ShareIngredientsSheet extends StatefulWidget {
const _ShareIngredientsSheet({required this.recipe});
final Recipe recipe;
@override
State<_ShareIngredientsSheet> createState() => _ShareIngredientsSheetState();
}
class _ShareIngredientsSheetState extends State<_ShareIngredientsSheet> {
late final _session = CookingSession(widget.recipe);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final units = context.watch<UnitCatalog>();
final text = ingredientsAsText(_session.ingredientGroups, (key) => units.label(l10n, key));
final servings = _session.servings;
final subject = servings == null
? l10n.shareIngredientsSubject(widget.recipe.name)
: l10n.shareIngredientsSubjectServings(widget.recipe.name, formatNumber(servings));
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.shareIngredients,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 4),
Text(l10n.shareIngredientsHint, style: TextStyle(fontSize: 14, color: c.inkMuted)),
if (_session.canScale) ...[
const SizedBox(height: 16),
ServingsStepper(
servings: servings!,
onFewer: servings > 1 ? () => setState(_session.fewerServings) : null,
onMore: () => setState(_session.moreServings),
),
],
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.35),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: c.background, borderRadius: BorderRadius.circular(16)),
child: SingleChildScrollView(
child: Text(text, style: TextStyle(fontSize: 15, height: 1.45, color: c.ink)),
),
),
),
const SizedBox(height: 16),
ShareOrCopyButtons(text: text, subject: subject),
],
),
),
);
}
}
@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../localization/app_localizations.dart';
/// "Kopieren" and "Teilen" for a shopping text. Copying is for apps that
/// don't take shared text, such as Bring! – paste it there instead.
class ShareOrCopyButtons extends StatefulWidget {
const ShareOrCopyButtons({super.key, required this.text, required this.subject});
/// Null disables both.
final String? text;
final String subject;
@override
State<ShareOrCopyButtons> createState() => _ShareOrCopyButtonsState();
}
class _ShareOrCopyButtonsState extends State<ShareOrCopyButtons> {
var _copied = false;
Timer? _reset;
@override
void dispose() {
_reset?.cancel();
super.dispose();
}
Future<void> _copy(String text) async {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
setState(() => _copied = true);
_reset?.cancel();
_reset = Timer(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _copied = false);
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final text = widget.text;
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: text == null ? null : () => _copy(text),
icon: Icon(_copied ? Icons.check_rounded : Icons.copy_rounded),
label: Text(_copied ? l10n.copied : l10n.copy, maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: text == null
? null
: () => SharePlus.instance.share(ShareParams(text: text, subject: widget.subject)),
icon: const Icon(Icons.share_rounded),
label: Text(l10n.share),
),
),
],
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../../localization/app_localizations.dart';
import '../../recipe_draft.dart';
/// Reorderable Steps and Step Section headings.
class StepEditor extends StatelessWidget {
const StepEditor({super.key, required this.entries, required this.onChanged});
final List<StepEntry> entries;
final VoidCallback onChanged;
void _add(StepEntry entry) {
entries.add(entry);
onChanged();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
var number = 0;
final numbers = [for (final e in entries) e is StepTextEntry ? ++number : 0];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: entries.length,
onReorder: (from, to) {
if (to > from) to--;
entries.insert(to, entries.removeAt(from));
onChanged();
},
itemBuilder: (context, i) {
final entry = entries[i];
final isSection = entry is SectionEntry;
return Padding(
key: ValueKey(entry.key),
padding: EdgeInsets.only(top: isSection ? 12 : 4, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ReorderableDragStartListener(
index: i,
child: const Padding(
padding: EdgeInsets.fromLTRB(4, 12, 4, 0),
child: Icon(Icons.drag_indicator_rounded),
),
),
Expanded(
child: TextFormField(
controller: entry.text,
minLines: isSection ? 1 : 2,
maxLines: isSection ? 1 : null,
textCapitalization: TextCapitalization.sentences,
style: isSection ? theme.textTheme.titleMedium?.copyWith(color: theme.colorScheme.primary) : null,
decoration: InputDecoration(
hintText: isSection ? l10n.sectionTitle : null,
labelText: isSection ? null : l10n.stepText(numbers[i]),
alignLabelWithHint: true,
),
),
),
IconButton(
tooltip: l10n.removeItem,
onPressed: () {
final removed = entries.removeAt(i);
onChanged();
// Its field is still mounted until the next frame.
WidgetsBinding.instance.addPostFrameCallback((_) => removed.dispose());
},
icon: const Icon(Icons.close_rounded),
),
],
),
);
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _add(StepTextEntry()),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addStep),
),
OutlinedButton.icon(
onPressed: () => _add(SectionEntry()),
icon: const Icon(Icons.title_rounded),
label: Text(l10n.addStepSection),
),
],
),
],
);
}
}
+169
View File
@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../localization/app_localizations.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/course.dart';
import '../recipes/recipe_list_provider.dart';
/// Add, rename, reorder and delete Courses.
class CoursesView extends StatelessWidget {
const CoursesView({super.key});
static const routeName = '/settings/courses';
Future<void> _edit(BuildContext context, {Course? course}) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<CourseRepository>();
final existing = context.read<RecipeListProvider>().courses;
final result = await showDialog<(String, String?)>(
context: context,
builder: (context) => _CourseDialog(
title: course == null ? l10n.addCourse : l10n.editCourse,
course: course,
takenNames: {
for (final c in existing)
if (c.id != course?.id) c.name.toLowerCase(),
},
),
);
if (result == null) return;
final (name, emoji) = result;
if (course == null) {
await repository.addCourse(name, emoji: emoji);
} else {
await repository.updateCourse(course.id, name: name, emoji: emoji);
}
}
Future<void> _delete(BuildContext context, Course course) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<CourseRepository>();
final count = await repository.recipeCount(course.id);
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteCourseTitle),
content: Text(l10n.deleteCourseBody(course.name, count)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.delete)),
],
),
);
if (confirmed == true) await repository.deleteCourse(course.id);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final courses = context.watch<RecipeListProvider>().courses;
return Scaffold(
appBar: AppBar(title: Text(l10n.coursesTitle)),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _edit(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addCourse),
),
body: ReorderableListView.builder(
padding: const EdgeInsets.only(bottom: 96),
itemCount: courses.length,
onReorder: (from, to) {
if (to > from) to--;
final ordered = [...courses];
ordered.insert(to, ordered.removeAt(from));
context.read<CourseRepository>().reorder(ordered);
},
itemBuilder: (context, i) {
final course = courses[i];
return ListTile(
key: ValueKey(course.id),
leading: Text(course.emoji ?? '🍽️', style: const TextStyle(fontSize: 24)),
title: Text(course.name),
onTap: () => _edit(context, course: course),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _delete(context, course),
),
ReorderableDragStartListener(index: i, child: const Icon(Icons.drag_handle_rounded)),
],
),
);
},
),
);
}
}
class _CourseDialog extends StatefulWidget {
const _CourseDialog({required this.title, this.course, required this.takenNames});
final String title;
final Course? course;
final Set<String> takenNames;
@override
State<_CourseDialog> createState() => _CourseDialogState();
}
class _CourseDialogState extends State<_CourseDialog> {
final _form = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.course?.name);
late final _emoji = TextEditingController(text: widget.course?.emoji);
@override
void dispose() {
_name.dispose();
_emoji.dispose();
super.dispose();
}
void _submit() {
if (!_form.currentState!.validate()) return;
final emoji = _emoji.text.trim();
Navigator.pop(context, (_name.text.trim(), emoji.isEmpty ? null : emoji));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.title),
content: Form(
key: _form,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.courseName),
validator: (v) {
final name = (v ?? '').trim();
if (name.isEmpty) return l10n.fieldNameRequired;
if (widget.takenNames.contains(name.toLowerCase())) return l10n.courseNameTaken;
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _emoji,
decoration: InputDecoration(labelText: l10n.courseEmoji),
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: _submit, child: Text(l10n.save)),
],
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../localization/app_localizations.dart';
import '../planning/day.dart';
import '../planning/models/meal.dart';
import '../planning/plan_repository.dart';
import '../planning/week_plan_provider.dart';
/// Add, rename, reorder and delete the Meals of the Week Plan.
class MealsView extends StatelessWidget {
const MealsView({super.key});
static const routeName = '/settings/meals';
Future<void> _edit(BuildContext context, {Meal? meal}) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<PlanRepository>();
final existing = context.read<WeekPlanProvider>().meals;
final name = await showDialog<String>(
context: context,
builder: (context) => _MealDialog(
title: meal == null ? l10n.addMeal : l10n.editMeal,
meal: meal,
takenNames: {
for (final m in existing)
if (m.id != meal?.id) m.name.toLowerCase(),
},
),
);
if (name == null) return;
if (meal == null) {
await repository.addMeal(name);
} else {
await repository.renameMeal(meal.id, name);
}
}
Future<void> _delete(BuildContext context, Meal meal) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<PlanRepository>();
final today = dayOf(DateTime.now());
final count = await repository.upcomingEntryCount(meal.id, today);
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteMealTitle),
content: Text(l10n.deleteMealBody(meal.name, count)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.delete)),
],
),
);
if (confirmed == true) await repository.deleteMeal(meal.id, today);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final meals = context.watch<WeekPlanProvider>().meals;
return Scaffold(
appBar: AppBar(title: Text(l10n.mealsTitle)),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _edit(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addMeal),
),
body: ReorderableListView.builder(
padding: const EdgeInsets.only(bottom: 96),
itemCount: meals.length,
onReorder: (from, to) {
if (to > from) to--;
final ordered = [...meals];
ordered.insert(to, ordered.removeAt(from));
context.read<PlanRepository>().reorderMeals(ordered);
},
itemBuilder: (context, i) {
final meal = meals[i];
return ListTile(
key: ValueKey(meal.id),
leading: const Icon(Icons.schedule_rounded),
title: Text(meal.name),
onTap: () => _edit(context, meal: meal),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _delete(context, meal),
),
ReorderableDragStartListener(index: i, child: const Icon(Icons.drag_handle_rounded)),
],
),
);
},
),
);
}
}
class _MealDialog extends StatefulWidget {
const _MealDialog({required this.title, this.meal, required this.takenNames});
final String title;
final Meal? meal;
final Set<String> takenNames;
@override
State<_MealDialog> createState() => _MealDialogState();
}
class _MealDialogState extends State<_MealDialog> {
final _form = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.meal?.name);
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _submit() {
if (_form.currentState!.validate()) Navigator.pop(context, _name.text.trim());
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.title),
content: Form(
key: _form,
child: TextFormField(
controller: _name,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.courseName),
onFieldSubmitted: (_) => _submit(),
validator: (v) {
final name = (v ?? '').trim();
if (name.isEmpty) return l10n.fieldNameRequired;
if (widget.takenNames.contains(name.toLowerCase())) return l10n.mealNameTaken;
return null;
},
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: _submit, child: Text(l10n.save)),
],
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stores the cook's app preferences on the device.
class SettingsService {
static const _themeModeKey = 'themeMode';
static const _weekStartKey = 'weekStart';
/// Light unless the cook chose otherwise: the dark theme is opt-in.
Future<ThemeMode> themeMode() async {
final stored = (await SharedPreferences.getInstance()).getString(_themeModeKey);
return ThemeMode.values.firstWhere((m) => m.name == stored, orElse: () => ThemeMode.light);
}
Future<void> updateThemeMode(ThemeMode mode) async =>
(await SharedPreferences.getInstance()).setString(_themeModeKey, mode.name);
/// The weekday a Week Plan week starts on (`DateTime.monday` … `DateTime.sunday`); Monday by default.
Future<int> weekStart() async => (await SharedPreferences.getInstance()).getInt(_weekStartKey) ?? DateTime.monday;
Future<void> updateWeekStart(int weekday) async =>
(await SharedPreferences.getInstance()).setInt(_weekStartKey, weekday);
}
class SettingsController extends ChangeNotifier {
SettingsController(this._service);
final SettingsService _service;
ThemeMode _themeMode = ThemeMode.light;
int _weekStart = DateTime.monday;
ThemeMode get themeMode => _themeMode;
int get weekStart => _weekStart;
Future<void> loadSettings() async {
_themeMode = await _service.themeMode();
_weekStart = await _service.weekStart();
notifyListeners();
}
Future<void> updateWeekStart(int weekday) async {
if (weekday == _weekStart) return;
_weekStart = weekday;
notifyListeners();
await _service.updateWeekStart(weekday);
}
Future<void> updateThemeMode(ThemeMode mode) async {
if (mode == _themeMode) return;
_themeMode = mode;
notifyListeners();
await _service.updateThemeMode(mode);
}
}
+187
View File
@@ -0,0 +1,187 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../bundle/bundle_codec.dart';
import '../bundle/bundle_service.dart';
import '../localization/app_localizations.dart';
import 'courses_view.dart';
import 'meals_view.dart';
import 'settings_controller.dart';
import 'units_view.dart';
class SettingsView extends StatelessWidget {
const SettingsView({super.key});
static const routeName = '/settings';
Future<void> _import(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final service = context.read<BundleService>();
final picked = await FilePicker.pickFiles(type: FileType.any);
final path = picked?.files.single.path;
if (path == null || !context.mounted) return;
final result = await _withProgress(context, l10n.importing, () async {
return service.importBundle(await File(path).readAsBytes());
});
switch (result) {
case (final ImportResult r?, null):
messenger.showSnackBar(SnackBar(content: Text(l10n.importResult(r.added, r.skipped))));
case (_, final Object error):
messenger.showSnackBar(SnackBar(content: Text(l10n.importFailed(_message(error)))));
default:
}
}
Future<void> _export(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final service = context.read<BundleService>();
final result = await _withProgress(context, l10n.exporting, service.exportBundle);
final (bytes, error) = result;
if (error != null || bytes == null) {
messenger.showSnackBar(SnackBar(content: Text(l10n.exportFailed(_message(error)))));
return;
}
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
try {
final saved = await FilePicker.saveFile(fileName: 'cookit-sammlung-$date.zip', bytes: bytes);
if (saved != null) messenger.showSnackBar(SnackBar(content: Text(l10n.exportDone)));
} on Exception catch (e) {
messenger.showSnackBar(SnackBar(content: Text(l10n.exportFailed(_message(e)))));
}
}
/// Runs [task] behind a blocking progress dialog; returns its value or the error.
Future<(T?, Object?)> _withProgress<T>(BuildContext context, String label, Future<T> Function() task) async {
final navigator = Navigator.of(context);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => PopScope(
canPop: false,
child: AlertDialog(
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 20),
Expanded(child: Text(label)),
],
),
),
),
);
try {
return (await task(), null);
} catch (e) {
return (null, e);
} finally {
navigator.pop();
}
}
static String _message(Object? error) => switch (error) {
BundleFormatException(:final message) => message,
FileSystemException(:final message) => message,
_ => '$error',
};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
Widget header(String text) => Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 4),
child: Text(text, style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.primary)),
);
return Scaffold(
appBar: AppBar(title: Text(l10n.settings)),
body: ListView(
children: [
header(l10n.settingsSectionAppearance),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: SegmentedButton<ThemeMode>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: ThemeMode.light,
icon: const Icon(Icons.light_mode_outlined),
label: Text(l10n.themeLight),
),
ButtonSegment(
value: ThemeMode.dark,
icon: const Icon(Icons.dark_mode_outlined),
label: Text(l10n.themeDark),
),
ButtonSegment(
value: ThemeMode.system,
icon: const Icon(Icons.phone_android_rounded),
label: Text(l10n.themeSystem),
),
],
selected: {context.watch<SettingsController>().themeMode},
onSelectionChanged: (s) => context.read<SettingsController>().updateThemeMode(s.first),
),
),
header(l10n.settingsSectionCollection),
ListTile(
leading: const Icon(Icons.restaurant_menu_rounded),
title: Text(l10n.settingsCourses),
subtitle: Text(l10n.settingsCoursesSubtitle),
onTap: () => Navigator.pushNamed(context, CoursesView.routeName),
),
ListTile(
leading: const Icon(Icons.straighten_rounded),
title: Text(l10n.settingsUnits),
subtitle: Text(l10n.settingsUnitsSubtitle),
onTap: () => Navigator.pushNamed(context, UnitsView.routeName),
),
header(l10n.settingsSectionPlanning),
ListTile(
leading: const Icon(Icons.schedule_rounded),
title: Text(l10n.settingsMeals),
subtitle: Text(l10n.settingsMealsSubtitle),
onTap: () => Navigator.pushNamed(context, MealsView.routeName),
),
ListTile(
leading: const Icon(Icons.calendar_view_week_rounded),
title: Text(l10n.settingsWeekStart),
trailing: DropdownButton<int>(
value: context.watch<SettingsController>().weekStart,
underline: const SizedBox(),
onChanged: (day) => context.read<SettingsController>().updateWeekStart(day!),
items: [
for (var day = DateTime.monday; day <= DateTime.sunday; day++)
DropdownMenuItem(
value: day,
// 2024-01-01 was a Monday.
child: Text(DateFormat('EEEE', 'de').format(DateTime.utc(2024, 1, day))),
),
],
),
),
header(l10n.settingsSectionData),
ListTile(
leading: const Icon(Icons.download_rounded),
title: Text(l10n.settingsImport),
subtitle: Text(l10n.settingsImportSubtitle),
onTap: () => _import(context),
),
ListTile(
leading: const Icon(Icons.upload_rounded),
title: Text(l10n.settingsExport),
subtitle: Text(l10n.settingsExportSubtitle),
onTap: () => _export(context),
),
],
),
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../localization/app_localizations.dart';
import '../recipes/models/unit_definition.dart';
import '../recipes/unit_catalog.dart';
import '../recipes/unit_repository.dart';
/// Sort the units, choose which are offered directly, and manage the cook's own.
class UnitsView extends StatelessWidget {
const UnitsView({super.key});
static const routeName = '/settings/units';
Future<void> _edit(BuildContext context, {UnitDefinition? unit}) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<UnitRepository>();
final catalog = context.read<UnitCatalog>();
final name = await showDialog<String>(
context: context,
builder: (context) => _UnitDialog(
title: unit == null ? l10n.addUnit : l10n.editUnit,
initial: unit?.label,
takenNames: {
for (final u in catalog.units)
if (u.key != unit?.key) catalog.label(l10n, u.key).toLowerCase(),
},
),
);
if (name == null) return;
if (unit == null) {
await repository.addOwn(name);
} else {
await repository.rename(unit.key, name);
}
}
Future<void> _delete(BuildContext context, UnitDefinition unit) async {
final l10n = AppLocalizations.of(context);
final repository = context.read<UnitRepository>();
final count = await repository.recipeCount(unit.key);
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.deleteUnitTitle),
content: Text(l10n.deleteUnitBody(unit.label ?? unit.key, count)),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(l10n.cancel)),
FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(l10n.delete)),
],
),
);
if (confirmed == true) await repository.deleteOwn(unit);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final catalog = context.watch<UnitCatalog>();
final repository = context.read<UnitRepository>();
final units = catalog.units;
return Scaffold(
appBar: AppBar(title: Text(l10n.unitsTitle)),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _edit(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addUnit),
),
body: ReorderableListView.builder(
header: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Text(l10n.unitsDirectHint, style: theme.textTheme.bodyMedium),
),
padding: const EdgeInsets.only(bottom: 96),
buildDefaultDragHandles: false,
itemCount: units.length,
onReorder: (from, to) {
if (to > from) to--;
final ordered = [...units];
ordered.insert(to, ordered.removeAt(from));
repository.reorder(ordered);
},
itemBuilder: (context, i) {
final unit = units[i];
return ListTile(
key: ValueKey(unit.key),
contentPadding: const EdgeInsets.only(left: 20, right: 8),
title: Text(catalog.label(l10n, unit.key)),
subtitle: unit.builtIn ? null : Text(l10n.ownUnit),
onTap: unit.builtIn ? null : () => _edit(context, unit: unit),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!unit.builtIn)
IconButton(
tooltip: l10n.delete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _delete(context, unit),
),
Tooltip(
message: l10n.showDirectly,
child: Switch(value: unit.direct, onChanged: (on) => repository.setDirect(unit.key, on)),
),
ReorderableDragStartListener(
index: i,
child: const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.drag_handle_rounded)),
),
],
),
);
},
),
);
}
}
class _UnitDialog extends StatefulWidget {
const _UnitDialog({required this.title, this.initial, required this.takenNames});
final String title;
final String? initial;
final Set<String> takenNames;
@override
State<_UnitDialog> createState() => _UnitDialogState();
}
class _UnitDialogState extends State<_UnitDialog> {
final _form = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.initial);
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _submit() {
if (_form.currentState!.validate()) Navigator.pop(context, _name.text.trim());
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(widget.title),
content: Form(
key: _form,
child: TextFormField(
controller: _name,
autofocus: true,
decoration: InputDecoration(labelText: l10n.unitName),
onFieldSubmitted: (_) => _submit(),
validator: (v) {
final name = (v ?? '').trim();
if (name.isEmpty) return l10n.fieldNameRequired;
if (widget.takenNames.contains(name.toLowerCase())) return l10n.unitNameTaken;
return null;
},
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: _submit, child: Text(l10n.save)),
],
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/services.dart';
/// CookIt's own bit of Android (MainActivity.kt): files opened with or
/// shared to the app, and full screen brightness for showing a Recipe Code.
class CookItPlatform {
static const _channel = MethodChannel('cookit/platform');
/// Calls [onFile] for the file the app was opened with, and for every one
/// that arrives while it runs.
static void listenForFiles(ValueChanged<Uint8List> onFile) {
Future<void> take() async {
try {
final bytes = await _channel.invokeMethod<Uint8List>('takeIncomingFile');
if (bytes != null) onFile(bytes);
} on MissingPluginException {
// Tests and other platforms: no files arrive.
}
}
_channel.setMethodCallHandler((call) async {
if (call.method == 'incomingFile') await take();
});
take();
}
static Future<void> setMaxBrightness(bool on) async {
try {
await _channel.invokeMethod<void>('setMaxBrightness', on);
} on MissingPluginException {
// Tests and other platforms keep their brightness.
}
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:qr/qr.dart';
import '../bundle/bundle_codec.dart';
import '../recipes/models/recipe.dart';
import 'shared_recipe.dart';
enum RecipeCodeError {
/// Some other QR code.
notARecipeCode,
/// Written by a newer CookIt than this one.
newerVersion,
/// Starts like a Recipe Code but doesn't read.
damaged,
/// The Recipe doesn't fit into one QR code.
tooLong,
}
class RecipeCodeException implements Exception {
const RecipeCodeException(this.error);
final RecipeCodeError error;
@override
String toString() => 'RecipeCodeException($error)';
}
/// The Recipe Code format (docs/adr/0005): `COOKIT1:` followed by the Recipe's
/// JSON, deflated and written in Base45 so the whole text fits QR's compact
/// alphanumeric mode.
abstract final class RecipeCode {
static const _magic = 'COOKIT';
static const version = 1;
static String encode(
Recipe recipe, {
String? courseName,
List<BundleUnit> units = const [],
bool includeNotes = true,
}) {
final json = BundleCodec.encodeRecipe(recipe, courseName: courseName, photoPaths: const [])
// The receiver's copy is its own: new id, not a favourite, nothing to review.
..remove('id')
..remove('photos')
..remove('favourite')
..remove('reviewReasons');
if (!includeNotes) json.remove('notes');
json.removeWhere((_, value) => value == null || (value is List && value.isEmpty));
final payload = {
'recipe': json,
if (units.isNotEmpty) 'units': [for (final u in units) u.toJson()],
};
final deflated = Deflate(utf8.encode(jsonEncode(payload)), level: DeflateLevel.bestCompression).getBytes();
return '$_magic$version:${base45Encode(deflated)}';
}
/// Throws [RecipeCodeException].
static SharedRecipe decode(String text, {required DateTime now}) {
if (!text.startsWith(_magic)) throw const RecipeCodeException(RecipeCodeError.notARecipeCode);
final colon = text.indexOf(':');
final v = colon < 0 ? null : int.tryParse(text.substring(_magic.length, colon));
if (v == null) throw const RecipeCodeException(RecipeCodeError.notARecipeCode);
if (v > version) throw const RecipeCodeException(RecipeCodeError.newerVersion);
try {
final inflated = Inflate(base45Decode(text.substring(colon + 1))).getBytes();
final payload = jsonDecode(utf8.decode(inflated)) as Map<String, dynamic>;
final entry = BundleCodec.decodeRecipe(payload['recipe'] as Map<String, dynamic>, now: now);
return SharedRecipe(recipe: entry.recipe, courseName: entry.courseName, units: BundleCodec.decodeUnits(payload));
} catch (_) {
throw const RecipeCodeException(RecipeCodeError.damaged);
}
}
/// The smallest QR code holding [text], with medium error correction when it
/// fits and low otherwise. Throws [RecipeCodeException] (tooLong).
static QrCode qrCode(String text) {
for (final level in const [QrErrorCorrectLevel.M, QrErrorCorrectLevel.L]) {
for (var typeNumber = 1; typeNumber <= 40; typeNumber++) {
final code = QrCode(typeNumber, level)..addAlphaNumeric(text);
try {
QrImage(code); // lays out the modules; throws when the text doesn't fit
return code;
} on InputTooLongException {
continue;
}
}
}
throw const RecipeCodeException(RecipeCodeError.tooLong);
}
}
const _base45 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ \$%*+-./:';
/// Base45 (RFC 9285): two bytes become three characters of QR's alphanumeric set.
String base45Encode(List<int> bytes) {
final out = StringBuffer();
for (var i = 0; i < bytes.length; i += 2) {
if (i + 1 < bytes.length) {
var n = bytes[i] * 256 + bytes[i + 1];
for (var k = 0; k < 3; k++) {
out.write(_base45[n % 45]);
n ~/= 45;
}
} else {
final n = bytes[i];
out
..write(_base45[n % 45])
..write(_base45[n ~/ 45]);
}
}
return out.toString();
}
/// Throws [FormatException] for text that isn't Base45.
Uint8List base45Decode(String text) {
int value(int i) {
final v = _base45.indexOf(text[i]);
if (v < 0) throw FormatException('Not Base45', text, i);
return v;
}
if (text.length % 3 == 1) throw FormatException('Not Base45', text);
final out = BytesBuilder();
for (var i = 0; i < text.length; i += 3) {
if (i + 2 < text.length) {
final n = value(i) + value(i + 1) * 45 + value(i + 2) * 45 * 45;
if (n > 0xFFFF) throw FormatException('Not Base45', text, i);
out
..addByte(n >> 8)
..addByte(n & 0xFF);
} else {
final n = value(i) + value(i + 1) * 45;
if (n > 0xFF) throw FormatException('Not Base45', text, i);
out.addByte(n);
}
}
return out.toBytes();
}
+107
View File
@@ -0,0 +1,107 @@
import 'dart:typed_data';
import 'package:uuid/uuid.dart';
import '../bundle/bundle_service.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/recipe.dart';
import '../recipes/photo_store.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/unit_repository.dart';
import 'recipe_code.dart';
import 'shared_recipe.dart';
/// Recipes going to and coming from another cook's phone: as a Recipe Code or
/// as a one-Recipe Recipe Bundle file. Both arrive as a [SharedRecipe] that
/// the cook looks at before saving it.
class RecipeShareService {
RecipeShareService(
this._recipes,
this._courses,
this._units,
this._photos,
this._bundles, {
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
final RecipeRepository _recipes;
final CourseRepository _courses;
final UnitRepository _units;
final PhotoStore _photos;
final BundleService _bundles;
final DateTime Function() _clock;
/// The Recipe Code text for [recipe].
Future<String> recipeCode(Recipe recipe, {bool includeNotes = true}) async {
final course = recipe.courseId == null
? null
: (await _courses.allCourses()).where((c) => c.id == recipe.courseId).firstOrNull;
return RecipeCode.encode(
recipe,
courseName: course?.name,
units: await _bundles.ownUnitsOf([recipe]),
includeNotes: includeNotes,
);
}
/// "Als Datei teilen": the Recipe with its Photos as a Recipe Bundle.
Future<Uint8List> recipeFile(Recipe recipe, {bool includeNotes = true}) =>
_bundles.exportRecipe(recipe, includeNotes: includeNotes);
SharedRecipe readCode(String text) => RecipeCode.decode(text, now: _clock());
/// The Recipe in a received file, or null when the file holds a whole
/// collection (that one is imported as usual). Throws BundleFormatException.
Future<SharedRecipe?> readFile(Uint8List bytes) async => fromContents(await _bundles.read(bytes));
/// The Recipe of a one-Recipe bundle, or null for any other bundle.
SharedRecipe? fromContents(BundleContents contents) {
if (contents.recipes.length != 1 || contents.plan != null) return null;
final entry = contents.recipes.single;
return SharedRecipe(
recipe: entry.recipe,
courseName: entry.courseName,
units: contents.units,
photos: [
for (final path in entry.photoPaths)
if (contents.files[path] case final bytes?) bytes,
],
);
}
/// Whether the collection already has a Recipe called [name].
Future<bool> hasRecipeNamed(String name) async =>
(await _recipes.allRecipes()).any((r) => r.name.trim().toLowerCase() == name.trim().toLowerCase());
/// Adds [shared] as a new Recipe of its own – also when one like it exists –
/// creating its Course and own units here if needed.
Future<Recipe> save(SharedRecipe shared) async {
final known = {for (final u in await _units.allUnits()) u.key};
for (final unit in shared.units) {
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
}
final courseName = shared.courseName;
final courseId = courseName == null ? null : (await _courses.findOrCreate(courseName)).id;
final now = _clock();
final source = shared.recipe;
final recipe = Recipe(
id: const Uuid().v4(),
name: source.name,
courseId: courseId,
tags: source.tags,
servings: source.servings,
prepMinutes: source.prepMinutes,
cookMinutes: source.cookMinutes,
calories: source.calories,
ingredientGroups: source.ingredientGroups,
stepSections: source.stepSections,
photos: [for (final bytes in shared.photos) await _photos.saveBytes(bytes)],
sources: source.sources,
notes: source.notes,
createdAt: now,
updatedAt: now,
);
await _recipes.saveRecipe(recipe);
return recipe;
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'dart:typed_data';
import '../bundle/bundle_codec.dart';
import '../recipes/models/recipe.dart';
/// A Recipe arriving from another cook – scanned from a Recipe Code or opened
/// from a one-Recipe Recipe Bundle – before it gets an id, a Course and
/// Photos in this collection.
class SharedRecipe {
const SharedRecipe({required this.recipe, this.courseName, this.units = const [], this.photos = const []});
final Recipe recipe;
final String? courseName;
/// The sender's own units this Recipe uses.
final List<BundleUnit> units;
/// Photo files in order (only a Recipe Bundle carries them).
final List<Uint8List> photos;
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../../localization/app_localizations.dart';
import '../recipe_code.dart';
import '../recipe_share_service.dart';
import 'shared_recipe_view.dart';
/// "Rezept scannen": the camera, looking for a Recipe Code. The first one that
/// reads opens its preview; anything else says what is wrong and keeps looking.
class ScanRecipeView extends StatefulWidget {
const ScanRecipeView({super.key});
static const routeName = '/scan';
@override
State<ScanRecipeView> createState() => _ScanRecipeViewState();
}
class _ScanRecipeViewState extends State<ScanRecipeView> {
final _scanner = MobileScannerController(formats: const [BarcodeFormat.qrCode]);
String? _lastValue;
String? _problem;
var _done = false;
@override
void dispose() {
_scanner.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
final l10n = AppLocalizations.of(context);
final value = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull;
if (_done || value == null || value == _lastValue) return;
_lastValue = value;
try {
final shared = context.read<RecipeShareService>().readCode(value);
_done = true;
_scanner.stop();
Navigator.pushReplacementNamed(context, SharedRecipeView.routeName, arguments: shared);
} on RecipeCodeException catch (e) {
setState(
() => _problem = switch (e.error) {
RecipeCodeError.newerVersion => l10n.codeFromNewerVersion,
RecipeCodeError.damaged => l10n.codeDamaged,
RecipeCodeError.notARecipeCode || RecipeCodeError.tooLong => l10n.notARecipeCode,
},
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: Colors.black,
extendBodyBehindAppBar: true,
appBar: AppBar(
title: Text(l10n.scanRecipe),
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
// Over the camera picture, whatever the theme says.
titleTextStyle: Theme.of(context).appBarTheme.titleTextStyle?.copyWith(color: Colors.white),
),
body: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: _scanner,
onDetect: _onDetect,
errorBuilder: (context, _) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
l10n.cameraUnavailable,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
IgnorePointer(
child: Center(
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 3),
borderRadius: BorderRadius.circular(28),
),
),
),
),
Positioned(
left: 24,
right: 24,
bottom: 48 + MediaQuery.paddingOf(context).bottom,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: _problem == null ? Colors.black54 : const Color(0xFFFFE29A),
borderRadius: BorderRadius.circular(16),
),
child: Text(
_problem ?? l10n.scanHint,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: _problem == null ? Colors.white : const Color(0xFF4F3A00),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,179 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../design/cookit_colors.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/models/recipe.dart';
import '../../recipes/recipe_repository.dart';
import '../cookit_platform.dart';
import '../recipe_code.dart';
import '../recipe_share_service.dart';
/// "Rezept teilen": the Recipe Code, big and bright for another phone to scan,
/// and "Als Datei teilen" for sending it with its Photos.
class ShareRecipeView extends StatefulWidget {
const ShareRecipeView({super.key, required this.recipeId});
static const routeName = '/share';
final String recipeId;
@override
State<ShareRecipeView> createState() => _ShareRecipeViewState();
}
class _ShareRecipeViewState extends State<ShareRecipeView> {
Recipe? _recipe;
var _includeNotes = true;
QrCode? _code;
var _tooLong = false;
var _sending = false;
@override
void initState() {
super.initState();
_brightAndAwake(true);
_load();
}
@override
void dispose() {
_brightAndAwake(false);
super.dispose();
}
/// Full brightness and no screen timeout while the code is up.
Future<void> _brightAndAwake(bool on) async {
await CookItPlatform.setMaxBrightness(on);
try {
await WakelockPlus.toggle(enable: on);
} on PlatformException {
// Not fatal: the code can be scanned with the normal screen timeout too.
} on MissingPluginException {
// Widget tests and platforms without the plugin.
}
}
Future<void> _load() async {
final recipe = await context.read<RecipeRepository>().getRecipe(widget.recipeId);
if (!mounted || recipe == null) return;
_recipe = recipe;
await _makeCode();
}
Future<void> _makeCode() async {
final text = await context.read<RecipeShareService>().recipeCode(_recipe!, includeNotes: _includeNotes);
if (!mounted) return;
setState(() {
try {
_code = RecipeCode.qrCode(text);
_tooLong = false;
} on RecipeCodeException {
_code = null;
_tooLong = true;
}
});
}
Future<void> _shareFile() async {
final recipe = _recipe!;
setState(() => _sending = true);
try {
final bytes = await context.read<RecipeShareService>().recipeFile(recipe, includeNotes: _includeNotes);
final name = recipe.name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-').trim();
final file = File('${(await getTemporaryDirectory()).path}/${name.isEmpty ? 'Rezept' : name}.cookit');
await file.writeAsBytes(bytes);
await SharePlus.instance.share(ShareParams(files: [XFile(file.path, mimeType: 'application/zip')]));
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final recipe = _recipe;
return Scaffold(
appBar: AppBar(title: Text(l10n.shareRecipe)),
body: recipe == null
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
children: [
Text(
recipe.name,
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 16),
// Always black on white, also in the dark theme: that is what scanners read best.
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(24)),
child: AspectRatio(
aspectRatio: 1,
child: _code != null
? QrImageView.withQr(
qr: _code!,
backgroundColor: Colors.white,
padding: const EdgeInsets.all(8),
semanticsLabel: l10n.shareRecipe,
)
: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: _tooLong
? Text(
l10n.codeTooLong,
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w700, color: CookItColors.light.ink),
)
: const CircularProgressIndicator(),
),
),
),
),
const SizedBox(height: 12),
Text(
l10n.shareRecipeHint,
textAlign: TextAlign.center,
style: TextStyle(color: c.inkMuted),
),
if (recipe.notes != null) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.includeNotes),
value: _includeNotes,
onChanged: (value) {
setState(() => _includeNotes = value);
_makeCode();
},
),
],
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _sending ? null : _shareFile,
icon: const Icon(Icons.send_rounded),
label: Text(l10n.shareAsFile),
),
const SizedBox(height: 6),
Text(
l10n.shareAsFileHint,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: c.inkMuted),
),
],
),
);
}
}
@@ -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),
),
),
],
),
);
}
}