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