commit 7d7a0bc60bcef5b64fa34e00357957f02dd04f88 Author: Cedric Hornberger Date: Wed Oct 28 22:55:09 2026 +0100 Initial commit: CookIT recipe collection and week planner Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d2be41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Private recipe data — never commit +*.xlsm +*.xlsx +tools/excel-import/out/ +*.cookit.zip +cookit-rezepte*.zip + +# Python tooling +.venv/ +__pycache__/ +.pytest_cache/ + +# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +*.iml +.idea/ +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..58a12df --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: android + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..f5d0234 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,104 @@ +# CookIt + +A personal recipe collection and meal planner for one cook. The first version is about managing Recipes; meal planning comes later. + +## Recipes + +**Recipe** (Rezept): +A dish the cook can prepare. It has a name, Ingredient Lines, Steps, Servings and optionally a Course, Tags, Photos, Calories, Sources and Notes. +_Avoid_: Dish, sheet, card + +**Course** (Gang): +The single kind of dish a Recipe is, such as Hauptgericht, Dessert or Dip, chosen from a list the cook can edit. A Recipe has at most one. +_Avoid_: Category, Kategorie, type + +**Tag** (Stichwort): +A free word attached to a Recipe to help find it, such as "vegan" or "Airfryer". A Recipe can have any number. +_Avoid_: Keyword, label + +**Servings** (Portionen): +The number of portions a Recipe yields as written. Ingredient amounts refer to this number. +_Avoid_: Ergebnis, yield, portions + +**Calories** (Kalorien): +The energy of a Recipe in kcal, stated either per serving or for the whole Recipe. The basis is always recorded with the value. +_Avoid_: kcal, Brennwert + +**Photo** (Foto): +A picture of the finished dish. A Recipe has none, one or several; the first is its main Photo. +_Avoid_: Image, Bild + +**Source** (Quelle): +A link to where a Recipe came from, such as a blog post or video. +_Avoid_: URL, link, origin + +**Notes** (Notizen): +Free text about a Recipe that does not fit anywhere else, such as tips, pan sizes or the cook's own experience with it. +_Avoid_: Comments, Kommentare, remarks + +**Favourite** (Favorit): +A Recipe the cook has marked as one they especially like. + +**Decision Helper** (Entscheidungshilfe): +Two quick questions – which Courses, how much time – after which the app offers matching Recipes one at a time. +_Avoid_: Wizard, quiz, filter + +**Suggestion** (Idee für heute): +One Recipe from the collection offered to the cook for today. It stays the same all day unless the cook asks for another. +_Avoid_: Random recipe, recommendation + +## Ingredients + +**Ingredient Line** (Zutat): +One entry in a Recipe's ingredient list: an optional amount (a single number or a range such as 2–3), an optional Unit, the ingredient's name and an optional remark, e.g. "250 Gramm griech. Joghurt". +_Avoid_: Ingredient (when the line is meant), item + +**Unit** (Maßeinheit): +The measure an amount is given in: one of the built-in Units (Gramm, Esslöffel, Dose …) or one the cook added themselves (e.g. Würfel). Each is either offered directly or under "Weitere Einheiten", in the cook's order. +_Avoid_: Measure, Einheit + +**Ingredient Group** (Zutatengruppe): +A subset of a Recipe's Ingredient Lines that belong together, such as "Für den Teig". It may be unnamed, in which case it is just a break in the list. +_Avoid_: Ingredient section, block + +## Preparation + +**Step** (Schritt): +One instruction in a Recipe that the cook carries out as a unit. Steps are ordered. +_Avoid_: Instruction, Anleitung, direction + +**Step Section** (Abschnitt): +A heading that groups consecutive Steps, such as "Vorteig" or "Soße". +_Avoid_: Phase, part + +**Cooking Mode** (Kochmodus): +Following a Recipe while cooking it: first its ingredients to get ready, for as many servings as the cook wants to make, then one Step at a time in large type. +_Avoid_: Cook view, step mode + +## Planning + +**Week Plan** (Wochenplan): +What the cook intends to eat on each day of one week. Past weeks stay as a history; future weeks can be planned ahead. +_Avoid_: Meal plan, Speiseplan, schedule + +**Meal** (Mahlzeit): +A time of day the cook plans for, such as Mittag or Abend, chosen from a list the cook can edit. Each day of a Week Plan has the same Meals. +_Avoid_: Slot, meal time + +**Plan Entry** (Eintrag): +One thing planned for a Meal on a day: either a Recipe or a short note such as "Essen gehen". A Meal can have several. A Plan Entry whose Recipe is deleted becomes a note with the Recipe's name. +_Avoid_: Planned Meal, booking + +## Import and sharing + +**Recipe Bundle** (Rezeptpaket): +A single file holding Recipes together with their Photos. The cook imports it into the app to add those Recipes to their collection. Sending one Recipe to another cook as a file is a Recipe Bundle with that one Recipe. Sending a week of the Week Plan is a Recipe Bundle that also carries that week's Plan Entries; the receiver reuses the Recipes they already have and adds the entries to their own Week Plan. +_Avoid_: Export, backup file, seed data + +**Recipe Code** (Rezept-Code): +A QR code holding one Recipe without its Photos, shown on one phone for another cook's CookIt to scan. Works without any connection. +_Avoid_: Link, export, QR export + +**Review Flag** (Prüfhinweis): +A marker on a Recipe saying the cook should check it, with the reasons, such as text moved into Notes or a guessed Calories basis. It stays until the cook clears it. +_Avoid_: Import report, warning, log diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc59ccb --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# CookIt + +A personal recipe collection for Android, built with Flutter. Meal planning, Cooking Mode, QR sharing and AI import come later; see [instructions.md](instructions.md). + +- Vocabulary: [GLOSSARY.md](GLOSSARY.md) +- Decisions: [docs/adr/](docs/adr/) +- Recipe Bundle (import/export) format: [docs/recipe-bundle-format.md](docs/recipe-bundle-format.md) +- One-time Excel converter: [tools/excel-import/](tools/excel-import/) + +## Develop + +```sh +flutter pub get +dart run build_runner build # after changing drift tables (lib/src/database) +flutter gen-l10n # after changing lib/src/localization/app_de.arb +flutter analyze +flutter test +flutter run +``` + +On Windows, the database tests use the system's `winsqlite3.dll`. Set `COOKIT_SQLITE_DLL` to use a different SQLite library. + +`test/bundle/bundle_service_test.dart` also imports the real converted workbook (`tools/excel-import/out/cookit-rezepte.zip`) when it exists. + +## Layout + +``` +lib/src/ + app.dart MaterialApp, routes + design/ theme (seed #65A86C, Nunito headings) + localization/ app_de.arb + generated code + database/ drift tables + recipes/ models, repositories, search, edit-form draft, views + bundle/ Recipe Bundle import/export + settings/ settings and Course list +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e2658c8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "de.zendric.cookit" + // 36: the camera libraries behind the Recipe Code scanner (mobile_scanner) need it. + compileSdk = 36 + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "de.zendric.cookit" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + // 23 (Android 6): the Recipe Code scanner (mobile_scanner) needs it. + minSdk = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } + + // Compressed native libraries keep the sideloaded APK small; with minSdk 23 + // they would otherwise be stored uncompressed (about three times the file size). + packaging { + jniLibs { + useLegacyPackaging = true + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..93a56bf --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/de/zendric/cookit/MainActivity.kt b/android/app/src/main/kotlin/de/zendric/cookit/MainActivity.kt new file mode 100644 index 0000000..8bd214f --- /dev/null +++ b/android/app/src/main/kotlin/de/zendric/cookit/MainActivity.kt @@ -0,0 +1,75 @@ +package de.zendric.cookit + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.view.WindowManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/** + * Hands files opened with or shared to CookIt (a Recipe Bundle, e.g. from Quick Share) + * to Flutter, and turns the screen to full brightness while a Recipe Code is shown. + */ +class MainActivity : FlutterActivity() { + private var channel: MethodChannel? = null + private var pendingFile: Uri? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + pendingFile = fileOf(intent) + channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "cookit/platform").apply { + setMethodCallHandler { call, result -> + when (call.method) { + "takeIncomingFile" -> takeIncomingFile(result) + "setMaxBrightness" -> { + val on = call.arguments as Boolean + window.attributes = window.attributes.apply { + screenBrightness = if (on) 1f else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + result.success(null) + } + else -> result.notImplemented() + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val uri = fileOf(intent) ?: return + pendingFile = uri + channel?.invokeMethod("incomingFile", null) + } + + /** The waiting file's bytes, once; null when there is none. */ + private fun takeIncomingFile(result: MethodChannel.Result) { + val uri = pendingFile + pendingFile = null + if (uri == null) { + result.success(null) + return + } + Thread { + try { + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + runOnUiThread { result.success(bytes) } + } catch (e: Exception) { + runOnUiThread { result.error("unreadable", e.message, null) } + } + }.start() + } + + private fun fileOf(intent: Intent?): Uri? = when (intent?.action) { + Intent.ACTION_VIEW -> intent.data + Intent.ACTION_SEND -> + if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + else -> null + } +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a16012d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9b19d27 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..a39873a --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fde9eaf Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d90a809 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fa741fa Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..a39873a --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b8159ef Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..7a7242a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0a43f58 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a8bebca Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..bf2c5d2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..c6686c3 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..abd2f1b --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..c6686c3 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..66e0a10 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #65A86C + + #E3F1D5 + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..43394ed --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/fonts/Nunito-600.ttf b/assets/fonts/Nunito-600.ttf new file mode 100644 index 0000000..de10ab5 Binary files /dev/null and b/assets/fonts/Nunito-600.ttf differ diff --git a/assets/fonts/Nunito-700.ttf b/assets/fonts/Nunito-700.ttf new file mode 100644 index 0000000..7ee3ae5 Binary files /dev/null and b/assets/fonts/Nunito-700.ttf differ diff --git a/assets/fonts/Nunito-800.ttf b/assets/fonts/Nunito-800.ttf new file mode 100644 index 0000000..241b100 Binary files /dev/null and b/assets/fonts/Nunito-800.ttf differ diff --git a/assets/fonts/Nunito-900.ttf b/assets/fonts/Nunito-900.ttf new file mode 100644 index 0000000..94a3b8a Binary files /dev/null and b/assets/fonts/Nunito-900.ttf differ diff --git a/assets/fonts/NunitoSans-400.ttf b/assets/fonts/NunitoSans-400.ttf new file mode 100644 index 0000000..6005481 Binary files /dev/null and b/assets/fonts/NunitoSans-400.ttf differ diff --git a/assets/fonts/NunitoSans-600.ttf b/assets/fonts/NunitoSans-600.ttf new file mode 100644 index 0000000..9d25822 Binary files /dev/null and b/assets/fonts/NunitoSans-600.ttf differ diff --git a/assets/fonts/NunitoSans-700.ttf b/assets/fonts/NunitoSans-700.ttf new file mode 100644 index 0000000..4e5dff0 Binary files /dev/null and b/assets/fonts/NunitoSans-700.ttf differ diff --git a/assets/fonts/NunitoSans-800.ttf b/assets/fonts/NunitoSans-800.ttf new file mode 100644 index 0000000..4a3654a Binary files /dev/null and b/assets/fonts/NunitoSans-800.ttf differ diff --git a/assets/fonts/OFL-NunitoSans.txt b/assets/fonts/OFL-NunitoSans.txt new file mode 100644 index 0000000..2ae1735 --- /dev/null +++ b/assets/fonts/OFL-NunitoSans.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Sans Project Authors (https://github.com/Fonthausen/NunitoSans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..c8210f0 --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icon/icon.png b/assets/icon/icon.png new file mode 100644 index 0000000..fbcb628 Binary files /dev/null and b/assets/icon/icon.png differ diff --git a/assets/icon/icon_foreground.png b/assets/icon/icon_foreground.png new file mode 100644 index 0000000..441bb01 Binary files /dev/null and b/assets/icon/icon_foreground.png differ diff --git a/docs/adr/0001-local-first-sqlite-with-uuids.md b/docs/adr/0001-local-first-sqlite-with-uuids.md new file mode 100644 index 0000000..1a73d8b --- /dev/null +++ b/docs/adr/0001-local-first-sqlite-with-uuids.md @@ -0,0 +1,9 @@ +# Local-first storage in SQLite (drift), with UUIDs for later sync + +CookIt keeps the whole Recipe collection on the device in a SQLite database accessed through drift, with no server. One cook on one Android phone doesn't need a backend, and the Recipe Bundle export is the backup. Every record gets a UUID rather than an auto-increment integer so a sync service can be added later without rewriting IDs or references. + +## Considered Options + +- **sqflite**: no code generation, but queries aren't checked at compile time and watch streams have to be wired by hand. +- **JSON files**: simplest, but weak once Meal Plans reference Recipes. +- **Cloud backend from day one** (Supabase/Firebase): not worth running for a single user in v1. diff --git a/docs/adr/0002-recipe-parts-stored-as-json.md b/docs/adr/0002-recipe-parts-stored-as-json.md new file mode 100644 index 0000000..4c93c40 --- /dev/null +++ b/docs/adr/0002-recipe-parts-stored-as-json.md @@ -0,0 +1,7 @@ +# A Recipe's parts are stored as JSON columns on its row + +Ingredient Groups and Lines, Step Sections, Photos, Tags, Sources and Review Flag reasons are stored as JSON in the `recipes` table rather than in their own tables. A Recipe is always loaded, edited and saved as a whole, and search runs in memory over a collection of a few hundred Recipes. Separate tables would add joins and position bookkeeping and gain nothing yet. + +## Consequences + +If a later feature needs to query across ingredients in SQL (e.g. a shopping list that sums the same ingredient over a Meal Plan), extract them into their own table with a drift migration at that point. diff --git a/docs/adr/0003-units-are-data.md b/docs/adr/0003-units-are-data.md new file mode 100644 index 0000000..78386b4 --- /dev/null +++ b/docs/adr/0003-units-are-data.md @@ -0,0 +1,9 @@ +# Units are data: built-in ones plus the cook's own + +Units used to be a fixed list in code. The cook wants to add their own (e.g. "Würfel"), choose which ones the picker offers directly and in what order, so units now live in a `units` table (schema version 2). The 27 built-in units are seeded from the `Unit` enum and keep their keys and localized names; the cook's own units get an `own-` key and a stored name. Ingredient Lines keep storing just the unit's key, so existing recipes and version 1 Recipe Bundles stay valid. + +## Consequences + +- Recipe Bundles are version 2: they carry the cook's own units (`units`), and an import creates the ones that are missing. Version 1 bundles (the Excel converter's) still import. +- Built-in units cannot be renamed or deleted: their keys are shared by every bundle and by the Excel converter. +- Deleting an own unit rewrites the Ingredient Lines that used it ("2 Würfel Brühe": the name moves into the ingredient), so no recipe points at a unit that no longer exists. diff --git a/docs/adr/0005-recipe-code-format.md b/docs/adr/0005-recipe-code-format.md new file mode 100644 index 0000000..f04cfd8 --- /dev/null +++ b/docs/adr/0005-recipe-code-format.md @@ -0,0 +1,10 @@ +# Recipe Codes: compressed JSON in Base45, one QR code per Recipe + +Cooks who meet in person should be able to pass a Recipe from phone to phone without any connection, so a Recipe Code is a QR code that carries the Recipe itself rather than a link. Its text is `COOKIT1:` followed by the Recipe's Recipe Bundle JSON (without id, Photos, Favourite and Review Flag, plus the own units it uses), deflated and written in Base45 (RFC 9285). Base45 uses only the characters of QR's alphanumeric mode, which packs them densely enough that the text costs about as much space as the raw bytes would; this is the scheme the EU's vaccination certificates use. Measured on the original 214 Recipes, every one fits a single code (median QR version 23, largest 38), so there are no multi-part codes. Photos don't fit and travel only in a one-Recipe Recipe Bundle file instead. + +## Consequences + +- Only CookIt can read a Recipe Code; a plain camera app shows meaningless text. Readable plain text was considered and dropped: about a third of the Recipes wouldn't fit, and the structure would have to be guessed back. +- The number after `COOKIT` is the format version. A reader refuses higher numbers with "please update CookIt", and must keep reading every older version, because codes may live on as screenshots. +- The scanner uses ML Kit with its model bundled into the app (`mobile_scanner`'s default), not the Play-services download, so scanning works on a fresh install without internet. This adds about 3 MB to the APK. +- A received Recipe is always saved as a new Recipe with a new id, even if the same one exists – unlike a full Recipe Bundle import, which skips Recipes it already has. diff --git a/docs/recipe-bundle-format.md b/docs/recipe-bundle-format.md new file mode 100644 index 0000000..63696d9 --- /dev/null +++ b/docs/recipe-bundle-format.md @@ -0,0 +1,91 @@ +# Recipe Bundle format (version 3) + +A Recipe Bundle is a `.zip` file. The Excel converter (`tools/excel-import`) writes one, the app's "Rezepte importieren" reads it, and "Sammlung exportieren" writes the same format. + +``` +bundle.json +photos/-1.jpg +photos/-2.jpg +``` + +Photos are JPEG, at most 1600 px on the long side. + +"Als Datei teilen" sends one Recipe as a Recipe Bundle named `.cookit`, without Favourite, Review Flag and (if the cook says so) Notes. Opened in CookIt, a bundle with exactly one Recipe shows that Recipe's preview and saves it as a new copy; a bundle with several is imported as usual. A Recipe Code (QR) carries the same Recipe JSON without Photos – see docs/adr/0005. + +## bundle.json + +```jsonc +{ + "format": "cookit.recipe-bundle", // reject anything else + "version": 2, // reject versions newer than the app understands; 1 = no "units", 3 = has "plan" + "units": [ // the cook's own units (version 2); built-in ones are never listed + { "key": "own-5c1e…", "label": "Würfel", "direct": true } + ], + "createdAt": "2026-10-20T14:00:00Z", + "recipes": [ + { + "id": "3f0c…", // UUID; a Recipe with this id already in the collection is skipped on import + "name": "Focaccia", + "course": "Brot", // Course name, or null; unknown names are added to the Course list + "tags": ["Langosch", "Airfryer"], + "servings": 4, // number (may be fractional, e.g. 3.5) or null + "prepMinutes": 30, // or null + "cookMinutes": 20, // or null + "calories": { "kcal": 775, "basis": "perServing" }, // basis: "perServing" | "wholeRecipe"; or null + "ingredientGroups": [ + { + "name": null, // null = unnamed group (a visual break in the list) + "lines": [ + { + "amount": 2, // number or null + "amountMax": 3, // number or null; set for ranges like "2-3" + "unit": "tablespoon", // built-in unit key (see below), an own unit's key from "units", or null + "name": "Kakaopulver", + "remark": null // e.g. "optional", or a note from the original + } + ] + } + ], + "stepSections": [ + { "title": "Vorteig", "steps": ["Verrühre die Zutaten …", "…"] } // title null = no heading + ], + "photos": ["photos/3f0c…-1.jpg"], // paths inside the zip; the first is the main Photo + "sources": ["https://sallys-blog.de/…"], + "notes": "22x22 cm eckige Form", // or null + "favourite": false, + "reviewReasons": ["Portionen fehlen."] // empty = no Review Flag + } + ], + "plan": { // version 3 only: a week of the Week Plan ("Wochenplan teilen") + "weekStart": "2026-10-19", + "meals": ["Mittag", "Abend"], // the sender's Meals the entries use, in order + "entries": [ + { "day": "2026-10-19", "meal": "Abend", "recipe": "3f0c…", "note": null }, // "recipe": an id in "recipes" + { "day": "2026-10-21", "meal": "Mittag", "recipe": null, "note": "Essen gehen" } + ] + } +} +``` + +A bundle is written with the lowest version its content needs: 2 normally, 3 only when it carries a `plan`. On receiving a `plan`, CookIt shows the week and, on "Übernehmen", reuses Recipes it already has (same id, else same name), adds the others with their id, matches Meals by name (creating missing ones) and adds the entries next to what is planned that week. + +## Unit keys + +| key | German | key | German | +|---|---|---|---| +| `gram` | Gramm | `bunch` | Bund | +| `kilogram` | Kilogramm | `handful` | Handvoll | +| `milliliter` | Milliliter | `cup` | Tasse | +| `liter` | Liter | `can` | Dose | +| `tablespoon` | Esslöffel | `jar` | Glas | +| `teaspoon` | Teelöffel | `tub` | Becher | +| `pinch` | Prise | `bag` | Beutel | +| `knifeTip` | Messerspitze | `carton` | Karton | +| `pack` | Packung | `head` | Kopf | +| `piece` | Stück | `grain` | Korn | +| `slice` | Scheibe | `small` | klein | +| `clove` | Zehe | `medium` | mittel | +| `sprig` | Zweig | `large` | groß | +| | | `some` | etwas | + +These keys are built in. A fresh app offers `gram` to `pack` directly and the rest under "Weitere Einheiten"; the cook can change that and add their own units (see `units` above and docs/adr/0003). diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..e2a6561 --- /dev/null +++ b/findings.md @@ -0,0 +1,8 @@ +The recipe screen looks a bit blunt in comparison to the main screen. +Maybe add the decorations there aswell. +I think the "Gesamt, Portionen and Kalorien" Block aswell +I would also like to have the Cooking Mode implemented, it should be a button at the bottom of every recipe that makes it easy to follow. + + +The measurement of ingridients has too many options: +Remove Dose, Glas, BNecher, Beutel, Karton, Bund, Kopf \ No newline at end of file diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..b3089d2 --- /dev/null +++ b/instructions.md @@ -0,0 +1,12 @@ +File with thoughts, not relevant for agents right now. +I want you to create a Recipe and Mealplanner App using Flutter. Inital version should focus on Recipes + +I will provide you an excel for inital data, this data needs to be tgransformed into a data that can imported into the app. +the Data is in this excel: C:\dev\CookIT\Wochenspeiseplan.xlsm +You can Ignore almost everything but the pages that contain recipes. +Sometimes they have a bit more notes that initaly in the recipe / these notes are often out of structure. + +Feature Addons: +Integrated AI to import a recipe from text. +or gemini integration + diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..c432b34 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/src/localization +template-arb-file: app_de.arb +output-localization-file: app_localizations.dart +output-dir: lib/src/localization +synthetic-package: false +nullable-getter: false diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..7cc69fd --- /dev/null +++ b/lib/main.dart @@ -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(), + ), + ); +} diff --git a/lib/src/app.dart b/lib/src/app.dart new file mode 100644 index 0000000..35f56fc --- /dev/null +++ b/lib/src/app.dart @@ -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 createState() => _CookItAppState(); +} + +class _CookItAppState extends State { + final _navigator = GlobalKey(); + final _messenger = GlobalKey(); + + @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 _openFile(Uint8List bytes) async { + final shares = context.read(); + final bundles = context.read(); + 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().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( + 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(), + }, + ), + ); + } +} diff --git a/lib/src/bundle/bundle_codec.dart b/lib/src/bundle/bundle_codec.dart new file mode 100644 index 0000000..a98e26a --- /dev/null +++ b/lib/src/bundle/bundle_codec.dart @@ -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 encodeRecipe( + Recipe recipe, { + required String? courseName, + required List 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 encodeManifest( + List> recipes, { + required DateTime createdAt, + List 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 || json['plan'] == null) return null; + try { + return BundlePlan.fromJson(json['plan'] as Map); + } 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 decodeUnits(Object? json) { + if (json is! Map) return const []; + try { + return [for (final u in json['units'] as List? ?? const []) BundleUnit.fromJson(u as Map)]; + } catch (e) { + throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).'); + } + } + + /// Throws [BundleFormatException] when [json] isn't a bundle this app can read. + static List decodeManifest(Object? json, {required DateTime now}) { + if (json is! Map || 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, 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 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), + ingredientGroups: [ + for (final g in json['ingredientGroups'] as List? ?? const []) + IngredientGroup.fromJson(g as Map), + ], + stepSections: [ + for (final s in json['stepSections'] as List? ?? const []) StepSection.fromJson(s as Map), + ], + 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 _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 toJson() => {'key': key, 'label': label, 'direct': direct}; + + static BundleUnit fromJson(Map 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 meals; + final List entries; + + Map toJson() => { + 'weekStart': weekStart, + 'meals': meals, + 'entries': [for (final e in entries) e.toJson()], + }; + + static BundlePlan fromJson(Map 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)], + ); +} + +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 toJson() => {'day': day, 'meal': meal, 'recipe': recipeId, 'note': note}; + + static BundlePlanEntry fromJson(Map 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 photoPaths; +} + +class BundleFormatException implements Exception { + const BundleFormatException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/lib/src/bundle/bundle_service.dart b/lib/src/bundle/bundle_service.dart new file mode 100644 index 0000000..7f075a5 --- /dev/null +++ b/lib/src/bundle/bundle_service.dart @@ -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 recipes; + final List 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 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 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 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 = {}; + 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 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 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 exportWeek(List 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> ownUnitsOf(Iterable 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 _export(List recipes, {required List units, BundlePlan? plan}) async { + final courses = {for (final c in await _courses.allCourses()) c.id: c.name}; + final files = {}; + final entries = >[]; + for (final recipe in recipes) { + final paths = []; + 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 _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 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); + } +} diff --git a/lib/src/cooking_mode/cooking_mode_view.dart b/lib/src/cooking_mode/cooking_mode_view.dart new file mode 100644 index 0000000..1074091 --- /dev/null +++ b/lib/src/cooking_mode/cooking_mode_view.dart @@ -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 createState() => _CookingModeViewState(); +} + +class _CookingModeViewState extends State { + final _pages = PageController(); + final _checked = {}; + CookingSession? _session; + int _page = 0; + + @override + void initState() { + super.initState(); + _keepScreenOn(true); + context.read().getRecipe(widget.recipeId).then((recipe) { + if (mounted && recipe != null) setState(() => _session = CookingSession(recipe)); + }); + } + + @override + void dispose() { + _keepScreenOn(false); + _pages.dispose(); + super.dispose(); + } + + Future _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( + 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 checked; + final ValueChanged 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), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/cooking_mode/cooking_session.dart b/lib/src/cooking_mode/cooking_session.dart new file mode 100644 index 0000000..3617c7a --- /dev/null +++ b/lib/src/cooking_mode/cooking_session.dart @@ -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 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 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; + } +} diff --git a/lib/src/database/database.dart b/lib/src/database/database.dart new file mode 100644 index 0000000..415cfb3 --- /dev/null +++ b/lib/src/database/database.dart @@ -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 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 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 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 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 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 _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 _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, String> { + const StringListConverter(); + + @override + List fromSql(String fromDb) => [for (final s in jsonDecode(fromDb) as List) s as String]; + + @override + String toSql(List value) => jsonEncode(value); +} + +class IngredientGroupsConverter extends TypeConverter, String> { + const IngredientGroupsConverter(); + + @override + List fromSql(String fromDb) => [ + for (final g in jsonDecode(fromDb) as List) IngredientGroup.fromJson(g as Map), + ]; + + @override + String toSql(List value) => jsonEncode([for (final g in value) g.toJson()]); +} + +class StepSectionsConverter extends TypeConverter, String> { + const StepSectionsConverter(); + + @override + List fromSql(String fromDb) => [ + for (final s in jsonDecode(fromDb) as List) StepSection.fromJson(s as Map), + ]; + + @override + String toSql(List value) => jsonEncode([for (final s in value) s.toJson()]); +} diff --git a/lib/src/database/database.g.dart b/lib/src/database/database.g.dart new file mode 100644 index 0000000..d74324e --- /dev/null +++ b/lib/src/database/database.g.dart @@ -0,0 +1,3489 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $CoursesTable extends Courses with TableInfo<$CoursesTable, CourseRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CoursesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), + ); + static const VerificationMeta _emojiMeta = const VerificationMeta('emoji'); + @override + late final GeneratedColumn emoji = GeneratedColumn( + 'emoji', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _positionMeta = const VerificationMeta('position'); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, name, emoji, position]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'courses'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('emoji')) { + context.handle(_emojiMeta, emoji.isAcceptableOrUnknown(data['emoji']!, _emojiMeta)); + } + if (data.containsKey('position')) { + context.handle(_positionMeta, position.isAcceptableOrUnknown(data['position']!, _positionMeta)); + } else if (isInserting) { + context.missing(_positionMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CourseRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CourseRow( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!, + emoji: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}emoji']), + position: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}position'])!, + ); + } + + @override + $CoursesTable createAlias(String alias) { + return $CoursesTable(attachedDatabase, alias); + } +} + +class CourseRow extends DataClass implements Insertable { + final String id; + final String name; + final String? emoji; + final int position; + const CourseRow({required this.id, required this.name, this.emoji, required this.position}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + if (!nullToAbsent || emoji != null) { + map['emoji'] = Variable(emoji); + } + map['position'] = Variable(position); + return map; + } + + CoursesCompanion toCompanion(bool nullToAbsent) { + return CoursesCompanion( + id: Value(id), + name: Value(name), + emoji: emoji == null && nullToAbsent ? const Value.absent() : Value(emoji), + position: Value(position), + ); + } + + factory CourseRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CourseRow( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + emoji: serializer.fromJson(json['emoji']), + position: serializer.fromJson(json['position']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'emoji': serializer.toJson(emoji), + 'position': serializer.toJson(position), + }; + } + + CourseRow copyWith({String? id, String? name, Value emoji = const Value.absent(), int? position}) => + CourseRow( + id: id ?? this.id, + name: name ?? this.name, + emoji: emoji.present ? emoji.value : this.emoji, + position: position ?? this.position, + ); + CourseRow copyWithCompanion(CoursesCompanion data) { + return CourseRow( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + emoji: data.emoji.present ? data.emoji.value : this.emoji, + position: data.position.present ? data.position.value : this.position, + ); + } + + @override + String toString() { + return (StringBuffer('CourseRow(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('emoji: $emoji, ') + ..write('position: $position') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, emoji, position); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CourseRow && + other.id == this.id && + other.name == this.name && + other.emoji == this.emoji && + other.position == this.position); +} + +class CoursesCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value emoji; + final Value position; + final Value rowid; + const CoursesCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.emoji = const Value.absent(), + this.position = const Value.absent(), + this.rowid = const Value.absent(), + }); + CoursesCompanion.insert({ + required String id, + required String name, + this.emoji = const Value.absent(), + required int position, + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + position = Value(position); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? emoji, + Expression? position, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (emoji != null) 'emoji': emoji, + if (position != null) 'position': position, + if (rowid != null) 'rowid': rowid, + }); + } + + CoursesCompanion copyWith({ + Value? id, + Value? name, + Value? emoji, + Value? position, + Value? rowid, + }) { + return CoursesCompanion( + id: id ?? this.id, + name: name ?? this.name, + emoji: emoji ?? this.emoji, + position: position ?? this.position, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (emoji.present) { + map['emoji'] = Variable(emoji.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CoursesCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('emoji: $emoji, ') + ..write('position: $position, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $RecipesTable extends Recipes with TableInfo<$RecipesTable, RecipeRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RecipesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _courseIdMeta = const VerificationMeta('courseId'); + @override + late final GeneratedColumn courseId = GeneratedColumn( + 'course_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES courses (id) ON DELETE SET NULL'), + ); + @override + late final GeneratedColumnWithTypeConverter, String> tags = GeneratedColumn( + 'tags', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$convertertags); + static const VerificationMeta _servingsMeta = const VerificationMeta('servings'); + @override + late final GeneratedColumn servings = GeneratedColumn( + 'servings', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _prepMinutesMeta = const VerificationMeta('prepMinutes'); + @override + late final GeneratedColumn prepMinutes = GeneratedColumn( + 'prep_minutes', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _cookMinutesMeta = const VerificationMeta('cookMinutes'); + @override + late final GeneratedColumn cookMinutes = GeneratedColumn( + 'cook_minutes', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _kcalMeta = const VerificationMeta('kcal'); + @override + late final GeneratedColumn kcal = GeneratedColumn( + 'kcal', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _caloriesBasisMeta = const VerificationMeta('caloriesBasis'); + @override + late final GeneratedColumn caloriesBasis = GeneratedColumn( + 'calories_basis', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> ingredientGroups = GeneratedColumn( + 'ingredient_groups', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$converteringredientGroups); + @override + late final GeneratedColumnWithTypeConverter, String> stepSections = GeneratedColumn( + 'step_sections', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$converterstepSections); + @override + late final GeneratedColumnWithTypeConverter, String> photos = GeneratedColumn( + 'photos', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$converterphotos); + @override + late final GeneratedColumnWithTypeConverter, String> sources = GeneratedColumn( + 'sources', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$convertersources); + static const VerificationMeta _notesMeta = const VerificationMeta('notes'); + @override + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _favouriteMeta = const VerificationMeta('favourite'); + @override + late final GeneratedColumn favourite = GeneratedColumn( + 'favourite', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("favourite" IN (0, 1))'), + defaultValue: const Constant(false), + ); + @override + late final GeneratedColumnWithTypeConverter, String> reviewReasons = GeneratedColumn( + 'review_reasons', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($RecipesTable.$converterreviewReasons); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + name, + courseId, + tags, + servings, + prepMinutes, + cookMinutes, + kcal, + caloriesBasis, + ingredientGroups, + stepSections, + photos, + sources, + notes, + favourite, + reviewReasons, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'recipes'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('course_id')) { + context.handle(_courseIdMeta, courseId.isAcceptableOrUnknown(data['course_id']!, _courseIdMeta)); + } + if (data.containsKey('servings')) { + context.handle(_servingsMeta, servings.isAcceptableOrUnknown(data['servings']!, _servingsMeta)); + } + if (data.containsKey('prep_minutes')) { + context.handle(_prepMinutesMeta, prepMinutes.isAcceptableOrUnknown(data['prep_minutes']!, _prepMinutesMeta)); + } + if (data.containsKey('cook_minutes')) { + context.handle(_cookMinutesMeta, cookMinutes.isAcceptableOrUnknown(data['cook_minutes']!, _cookMinutesMeta)); + } + if (data.containsKey('kcal')) { + context.handle(_kcalMeta, kcal.isAcceptableOrUnknown(data['kcal']!, _kcalMeta)); + } + if (data.containsKey('calories_basis')) { + context.handle( + _caloriesBasisMeta, + caloriesBasis.isAcceptableOrUnknown(data['calories_basis']!, _caloriesBasisMeta), + ); + } + if (data.containsKey('notes')) { + context.handle(_notesMeta, notes.isAcceptableOrUnknown(data['notes']!, _notesMeta)); + } + if (data.containsKey('favourite')) { + context.handle(_favouriteMeta, favourite.isAcceptableOrUnknown(data['favourite']!, _favouriteMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + RecipeRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RecipeRow( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!, + courseId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}course_id']), + tags: $RecipesTable.$convertertags.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}tags'])!, + ), + servings: attachedDatabase.typeMapping.read(DriftSqlType.double, data['${effectivePrefix}servings']), + prepMinutes: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}prep_minutes']), + cookMinutes: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}cook_minutes']), + kcal: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}kcal']), + caloriesBasis: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}calories_basis']), + ingredientGroups: $RecipesTable.$converteringredientGroups.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}ingredient_groups'])!, + ), + stepSections: $RecipesTable.$converterstepSections.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}step_sections'])!, + ), + photos: $RecipesTable.$converterphotos.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}photos'])!, + ), + sources: $RecipesTable.$convertersources.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}sources'])!, + ), + notes: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}notes']), + favourite: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}favourite'])!, + reviewReasons: $RecipesTable.$converterreviewReasons.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}review_reasons'])!, + ), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ); + } + + @override + $RecipesTable createAlias(String alias) { + return $RecipesTable(attachedDatabase, alias); + } + + static TypeConverter, String> $convertertags = const StringListConverter(); + static TypeConverter, String> $converteringredientGroups = const IngredientGroupsConverter(); + static TypeConverter, String> $converterstepSections = const StepSectionsConverter(); + static TypeConverter, String> $converterphotos = const StringListConverter(); + static TypeConverter, String> $convertersources = const StringListConverter(); + static TypeConverter, String> $converterreviewReasons = const StringListConverter(); +} + +class RecipeRow extends DataClass implements Insertable { + final String id; + final String name; + final String? courseId; + final List tags; + final double? servings; + final int? prepMinutes; + final int? cookMinutes; + final int? kcal; + final String? caloriesBasis; + final List ingredientGroups; + final List stepSections; + final List photos; + final List sources; + final String? notes; + final bool favourite; + final List reviewReasons; + final DateTime createdAt; + final DateTime updatedAt; + const RecipeRow({ + required this.id, + required this.name, + this.courseId, + required this.tags, + this.servings, + this.prepMinutes, + this.cookMinutes, + this.kcal, + this.caloriesBasis, + required this.ingredientGroups, + required this.stepSections, + required this.photos, + required this.sources, + this.notes, + required this.favourite, + required this.reviewReasons, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + if (!nullToAbsent || courseId != null) { + map['course_id'] = Variable(courseId); + } + { + map['tags'] = Variable($RecipesTable.$convertertags.toSql(tags)); + } + if (!nullToAbsent || servings != null) { + map['servings'] = Variable(servings); + } + if (!nullToAbsent || prepMinutes != null) { + map['prep_minutes'] = Variable(prepMinutes); + } + if (!nullToAbsent || cookMinutes != null) { + map['cook_minutes'] = Variable(cookMinutes); + } + if (!nullToAbsent || kcal != null) { + map['kcal'] = Variable(kcal); + } + if (!nullToAbsent || caloriesBasis != null) { + map['calories_basis'] = Variable(caloriesBasis); + } + { + map['ingredient_groups'] = Variable($RecipesTable.$converteringredientGroups.toSql(ingredientGroups)); + } + { + map['step_sections'] = Variable($RecipesTable.$converterstepSections.toSql(stepSections)); + } + { + map['photos'] = Variable($RecipesTable.$converterphotos.toSql(photos)); + } + { + map['sources'] = Variable($RecipesTable.$convertersources.toSql(sources)); + } + if (!nullToAbsent || notes != null) { + map['notes'] = Variable(notes); + } + map['favourite'] = Variable(favourite); + { + map['review_reasons'] = Variable($RecipesTable.$converterreviewReasons.toSql(reviewReasons)); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + RecipesCompanion toCompanion(bool nullToAbsent) { + return RecipesCompanion( + id: Value(id), + name: Value(name), + courseId: courseId == null && nullToAbsent ? const Value.absent() : Value(courseId), + tags: Value(tags), + servings: servings == null && nullToAbsent ? const Value.absent() : Value(servings), + prepMinutes: prepMinutes == null && nullToAbsent ? const Value.absent() : Value(prepMinutes), + cookMinutes: cookMinutes == null && nullToAbsent ? const Value.absent() : Value(cookMinutes), + kcal: kcal == null && nullToAbsent ? const Value.absent() : Value(kcal), + caloriesBasis: caloriesBasis == null && nullToAbsent ? const Value.absent() : Value(caloriesBasis), + ingredientGroups: Value(ingredientGroups), + stepSections: Value(stepSections), + photos: Value(photos), + sources: Value(sources), + notes: notes == null && nullToAbsent ? const Value.absent() : Value(notes), + favourite: Value(favourite), + reviewReasons: Value(reviewReasons), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory RecipeRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RecipeRow( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + courseId: serializer.fromJson(json['courseId']), + tags: serializer.fromJson>(json['tags']), + servings: serializer.fromJson(json['servings']), + prepMinutes: serializer.fromJson(json['prepMinutes']), + cookMinutes: serializer.fromJson(json['cookMinutes']), + kcal: serializer.fromJson(json['kcal']), + caloriesBasis: serializer.fromJson(json['caloriesBasis']), + ingredientGroups: serializer.fromJson>(json['ingredientGroups']), + stepSections: serializer.fromJson>(json['stepSections']), + photos: serializer.fromJson>(json['photos']), + sources: serializer.fromJson>(json['sources']), + notes: serializer.fromJson(json['notes']), + favourite: serializer.fromJson(json['favourite']), + reviewReasons: serializer.fromJson>(json['reviewReasons']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'courseId': serializer.toJson(courseId), + 'tags': serializer.toJson>(tags), + 'servings': serializer.toJson(servings), + 'prepMinutes': serializer.toJson(prepMinutes), + 'cookMinutes': serializer.toJson(cookMinutes), + 'kcal': serializer.toJson(kcal), + 'caloriesBasis': serializer.toJson(caloriesBasis), + 'ingredientGroups': serializer.toJson>(ingredientGroups), + 'stepSections': serializer.toJson>(stepSections), + 'photos': serializer.toJson>(photos), + 'sources': serializer.toJson>(sources), + 'notes': serializer.toJson(notes), + 'favourite': serializer.toJson(favourite), + 'reviewReasons': serializer.toJson>(reviewReasons), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + RecipeRow copyWith({ + String? id, + String? name, + Value courseId = const Value.absent(), + List? tags, + Value servings = const Value.absent(), + Value prepMinutes = const Value.absent(), + Value cookMinutes = const Value.absent(), + Value kcal = const Value.absent(), + Value caloriesBasis = const Value.absent(), + List? ingredientGroups, + List? stepSections, + List? photos, + List? sources, + Value notes = const Value.absent(), + bool? favourite, + List? reviewReasons, + DateTime? createdAt, + DateTime? updatedAt, + }) => RecipeRow( + id: id ?? this.id, + name: name ?? this.name, + courseId: courseId.present ? courseId.value : this.courseId, + tags: tags ?? this.tags, + servings: servings.present ? servings.value : this.servings, + prepMinutes: prepMinutes.present ? prepMinutes.value : this.prepMinutes, + cookMinutes: cookMinutes.present ? cookMinutes.value : this.cookMinutes, + kcal: kcal.present ? kcal.value : this.kcal, + caloriesBasis: caloriesBasis.present ? caloriesBasis.value : this.caloriesBasis, + ingredientGroups: ingredientGroups ?? this.ingredientGroups, + stepSections: stepSections ?? this.stepSections, + photos: photos ?? this.photos, + sources: sources ?? this.sources, + notes: notes.present ? notes.value : this.notes, + favourite: favourite ?? this.favourite, + reviewReasons: reviewReasons ?? this.reviewReasons, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + RecipeRow copyWithCompanion(RecipesCompanion data) { + return RecipeRow( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + courseId: data.courseId.present ? data.courseId.value : this.courseId, + tags: data.tags.present ? data.tags.value : this.tags, + servings: data.servings.present ? data.servings.value : this.servings, + prepMinutes: data.prepMinutes.present ? data.prepMinutes.value : this.prepMinutes, + cookMinutes: data.cookMinutes.present ? data.cookMinutes.value : this.cookMinutes, + kcal: data.kcal.present ? data.kcal.value : this.kcal, + caloriesBasis: data.caloriesBasis.present ? data.caloriesBasis.value : this.caloriesBasis, + ingredientGroups: data.ingredientGroups.present ? data.ingredientGroups.value : this.ingredientGroups, + stepSections: data.stepSections.present ? data.stepSections.value : this.stepSections, + photos: data.photos.present ? data.photos.value : this.photos, + sources: data.sources.present ? data.sources.value : this.sources, + notes: data.notes.present ? data.notes.value : this.notes, + favourite: data.favourite.present ? data.favourite.value : this.favourite, + reviewReasons: data.reviewReasons.present ? data.reviewReasons.value : this.reviewReasons, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('RecipeRow(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('courseId: $courseId, ') + ..write('tags: $tags, ') + ..write('servings: $servings, ') + ..write('prepMinutes: $prepMinutes, ') + ..write('cookMinutes: $cookMinutes, ') + ..write('kcal: $kcal, ') + ..write('caloriesBasis: $caloriesBasis, ') + ..write('ingredientGroups: $ingredientGroups, ') + ..write('stepSections: $stepSections, ') + ..write('photos: $photos, ') + ..write('sources: $sources, ') + ..write('notes: $notes, ') + ..write('favourite: $favourite, ') + ..write('reviewReasons: $reviewReasons, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + name, + courseId, + tags, + servings, + prepMinutes, + cookMinutes, + kcal, + caloriesBasis, + ingredientGroups, + stepSections, + photos, + sources, + notes, + favourite, + reviewReasons, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RecipeRow && + other.id == this.id && + other.name == this.name && + other.courseId == this.courseId && + other.tags == this.tags && + other.servings == this.servings && + other.prepMinutes == this.prepMinutes && + other.cookMinutes == this.cookMinutes && + other.kcal == this.kcal && + other.caloriesBasis == this.caloriesBasis && + other.ingredientGroups == this.ingredientGroups && + other.stepSections == this.stepSections && + other.photos == this.photos && + other.sources == this.sources && + other.notes == this.notes && + other.favourite == this.favourite && + other.reviewReasons == this.reviewReasons && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class RecipesCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value courseId; + final Value> tags; + final Value servings; + final Value prepMinutes; + final Value cookMinutes; + final Value kcal; + final Value caloriesBasis; + final Value> ingredientGroups; + final Value> stepSections; + final Value> photos; + final Value> sources; + final Value notes; + final Value favourite; + final Value> reviewReasons; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const RecipesCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.courseId = const Value.absent(), + this.tags = const Value.absent(), + this.servings = const Value.absent(), + this.prepMinutes = const Value.absent(), + this.cookMinutes = const Value.absent(), + this.kcal = const Value.absent(), + this.caloriesBasis = const Value.absent(), + this.ingredientGroups = const Value.absent(), + this.stepSections = const Value.absent(), + this.photos = const Value.absent(), + this.sources = const Value.absent(), + this.notes = const Value.absent(), + this.favourite = const Value.absent(), + this.reviewReasons = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + RecipesCompanion.insert({ + required String id, + required String name, + this.courseId = const Value.absent(), + required List tags, + this.servings = const Value.absent(), + this.prepMinutes = const Value.absent(), + this.cookMinutes = const Value.absent(), + this.kcal = const Value.absent(), + this.caloriesBasis = const Value.absent(), + required List ingredientGroups, + required List stepSections, + required List photos, + required List sources, + this.notes = const Value.absent(), + this.favourite = const Value.absent(), + required List reviewReasons, + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + tags = Value(tags), + ingredientGroups = Value(ingredientGroups), + stepSections = Value(stepSections), + photos = Value(photos), + sources = Value(sources), + reviewReasons = Value(reviewReasons), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? courseId, + Expression? tags, + Expression? servings, + Expression? prepMinutes, + Expression? cookMinutes, + Expression? kcal, + Expression? caloriesBasis, + Expression? ingredientGroups, + Expression? stepSections, + Expression? photos, + Expression? sources, + Expression? notes, + Expression? favourite, + Expression? reviewReasons, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (courseId != null) 'course_id': courseId, + if (tags != null) 'tags': tags, + if (servings != null) 'servings': servings, + if (prepMinutes != null) 'prep_minutes': prepMinutes, + if (cookMinutes != null) 'cook_minutes': cookMinutes, + if (kcal != null) 'kcal': kcal, + if (caloriesBasis != null) 'calories_basis': caloriesBasis, + if (ingredientGroups != null) 'ingredient_groups': ingredientGroups, + if (stepSections != null) 'step_sections': stepSections, + if (photos != null) 'photos': photos, + if (sources != null) 'sources': sources, + if (notes != null) 'notes': notes, + if (favourite != null) 'favourite': favourite, + if (reviewReasons != null) 'review_reasons': reviewReasons, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + RecipesCompanion copyWith({ + Value? id, + Value? name, + Value? courseId, + Value>? tags, + Value? servings, + Value? prepMinutes, + Value? cookMinutes, + Value? kcal, + Value? caloriesBasis, + Value>? ingredientGroups, + Value>? stepSections, + Value>? photos, + Value>? sources, + Value? notes, + Value? favourite, + Value>? reviewReasons, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return RecipesCompanion( + id: id ?? this.id, + name: name ?? this.name, + courseId: courseId ?? this.courseId, + tags: tags ?? this.tags, + servings: servings ?? this.servings, + prepMinutes: prepMinutes ?? this.prepMinutes, + cookMinutes: cookMinutes ?? this.cookMinutes, + kcal: kcal ?? this.kcal, + caloriesBasis: caloriesBasis ?? this.caloriesBasis, + ingredientGroups: ingredientGroups ?? this.ingredientGroups, + stepSections: stepSections ?? this.stepSections, + photos: photos ?? this.photos, + sources: sources ?? this.sources, + notes: notes ?? this.notes, + favourite: favourite ?? this.favourite, + reviewReasons: reviewReasons ?? this.reviewReasons, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (courseId.present) { + map['course_id'] = Variable(courseId.value); + } + if (tags.present) { + map['tags'] = Variable($RecipesTable.$convertertags.toSql(tags.value)); + } + if (servings.present) { + map['servings'] = Variable(servings.value); + } + if (prepMinutes.present) { + map['prep_minutes'] = Variable(prepMinutes.value); + } + if (cookMinutes.present) { + map['cook_minutes'] = Variable(cookMinutes.value); + } + if (kcal.present) { + map['kcal'] = Variable(kcal.value); + } + if (caloriesBasis.present) { + map['calories_basis'] = Variable(caloriesBasis.value); + } + if (ingredientGroups.present) { + map['ingredient_groups'] = Variable( + $RecipesTable.$converteringredientGroups.toSql(ingredientGroups.value), + ); + } + if (stepSections.present) { + map['step_sections'] = Variable($RecipesTable.$converterstepSections.toSql(stepSections.value)); + } + if (photos.present) { + map['photos'] = Variable($RecipesTable.$converterphotos.toSql(photos.value)); + } + if (sources.present) { + map['sources'] = Variable($RecipesTable.$convertersources.toSql(sources.value)); + } + if (notes.present) { + map['notes'] = Variable(notes.value); + } + if (favourite.present) { + map['favourite'] = Variable(favourite.value); + } + if (reviewReasons.present) { + map['review_reasons'] = Variable($RecipesTable.$converterreviewReasons.toSql(reviewReasons.value)); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RecipesCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('courseId: $courseId, ') + ..write('tags: $tags, ') + ..write('servings: $servings, ') + ..write('prepMinutes: $prepMinutes, ') + ..write('cookMinutes: $cookMinutes, ') + ..write('kcal: $kcal, ') + ..write('caloriesBasis: $caloriesBasis, ') + ..write('ingredientGroups: $ingredientGroups, ') + ..write('stepSections: $stepSections, ') + ..write('photos: $photos, ') + ..write('sources: $sources, ') + ..write('notes: $notes, ') + ..write('favourite: $favourite, ') + ..write('reviewReasons: $reviewReasons, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $UnitsTable extends Units with TableInfo<$UnitsTable, UnitRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UnitsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _builtInMeta = const VerificationMeta('builtIn'); + @override + late final GeneratedColumn builtIn = GeneratedColumn( + 'built_in', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("built_in" IN (0, 1))'), + ); + static const VerificationMeta _directMeta = const VerificationMeta('direct'); + @override + late final GeneratedColumn direct = GeneratedColumn( + 'direct', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("direct" IN (0, 1))'), + ); + static const VerificationMeta _positionMeta = const VerificationMeta('position'); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [key, label, builtIn, direct, position]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'units'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('key')) { + context.handle(_keyMeta, key.isAcceptableOrUnknown(data['key']!, _keyMeta)); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('label')) { + context.handle(_labelMeta, label.isAcceptableOrUnknown(data['label']!, _labelMeta)); + } + if (data.containsKey('built_in')) { + context.handle(_builtInMeta, builtIn.isAcceptableOrUnknown(data['built_in']!, _builtInMeta)); + } else if (isInserting) { + context.missing(_builtInMeta); + } + if (data.containsKey('direct')) { + context.handle(_directMeta, direct.isAcceptableOrUnknown(data['direct']!, _directMeta)); + } else if (isInserting) { + context.missing(_directMeta); + } + if (data.containsKey('position')) { + context.handle(_positionMeta, position.isAcceptableOrUnknown(data['position']!, _positionMeta)); + } else if (isInserting) { + context.missing(_positionMeta); + } + return context; + } + + @override + Set get $primaryKey => {key}; + @override + UnitRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UnitRow( + key: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}key'])!, + label: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}label']), + builtIn: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}built_in'])!, + direct: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}direct'])!, + position: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}position'])!, + ); + } + + @override + $UnitsTable createAlias(String alias) { + return $UnitsTable(attachedDatabase, alias); + } +} + +class UnitRow extends DataClass implements Insertable { + final String key; + final String? label; + final bool builtIn; + + /// Offered directly in the unit picker rather than under "Weitere Einheiten". + final bool direct; + final int position; + const UnitRow({required this.key, this.label, required this.builtIn, required this.direct, required this.position}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['key'] = Variable(key); + if (!nullToAbsent || label != null) { + map['label'] = Variable(label); + } + map['built_in'] = Variable(builtIn); + map['direct'] = Variable(direct); + map['position'] = Variable(position); + return map; + } + + UnitsCompanion toCompanion(bool nullToAbsent) { + return UnitsCompanion( + key: Value(key), + label: label == null && nullToAbsent ? const Value.absent() : Value(label), + builtIn: Value(builtIn), + direct: Value(direct), + position: Value(position), + ); + } + + factory UnitRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UnitRow( + key: serializer.fromJson(json['key']), + label: serializer.fromJson(json['label']), + builtIn: serializer.fromJson(json['builtIn']), + direct: serializer.fromJson(json['direct']), + position: serializer.fromJson(json['position']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'key': serializer.toJson(key), + 'label': serializer.toJson(label), + 'builtIn': serializer.toJson(builtIn), + 'direct': serializer.toJson(direct), + 'position': serializer.toJson(position), + }; + } + + UnitRow copyWith({ + String? key, + Value label = const Value.absent(), + bool? builtIn, + bool? direct, + int? position, + }) => UnitRow( + key: key ?? this.key, + label: label.present ? label.value : this.label, + builtIn: builtIn ?? this.builtIn, + direct: direct ?? this.direct, + position: position ?? this.position, + ); + UnitRow copyWithCompanion(UnitsCompanion data) { + return UnitRow( + key: data.key.present ? data.key.value : this.key, + label: data.label.present ? data.label.value : this.label, + builtIn: data.builtIn.present ? data.builtIn.value : this.builtIn, + direct: data.direct.present ? data.direct.value : this.direct, + position: data.position.present ? data.position.value : this.position, + ); + } + + @override + String toString() { + return (StringBuffer('UnitRow(') + ..write('key: $key, ') + ..write('label: $label, ') + ..write('builtIn: $builtIn, ') + ..write('direct: $direct, ') + ..write('position: $position') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(key, label, builtIn, direct, position); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UnitRow && + other.key == this.key && + other.label == this.label && + other.builtIn == this.builtIn && + other.direct == this.direct && + other.position == this.position); +} + +class UnitsCompanion extends UpdateCompanion { + final Value key; + final Value label; + final Value builtIn; + final Value direct; + final Value position; + final Value rowid; + const UnitsCompanion({ + this.key = const Value.absent(), + this.label = const Value.absent(), + this.builtIn = const Value.absent(), + this.direct = const Value.absent(), + this.position = const Value.absent(), + this.rowid = const Value.absent(), + }); + UnitsCompanion.insert({ + required String key, + this.label = const Value.absent(), + required bool builtIn, + required bool direct, + required int position, + this.rowid = const Value.absent(), + }) : key = Value(key), + builtIn = Value(builtIn), + direct = Value(direct), + position = Value(position); + static Insertable custom({ + Expression? key, + Expression? label, + Expression? builtIn, + Expression? direct, + Expression? position, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (key != null) 'key': key, + if (label != null) 'label': label, + if (builtIn != null) 'built_in': builtIn, + if (direct != null) 'direct': direct, + if (position != null) 'position': position, + if (rowid != null) 'rowid': rowid, + }); + } + + UnitsCompanion copyWith({ + Value? key, + Value? label, + Value? builtIn, + Value? direct, + Value? position, + Value? rowid, + }) { + return UnitsCompanion( + key: key ?? this.key, + label: label ?? this.label, + builtIn: builtIn ?? this.builtIn, + direct: direct ?? this.direct, + position: position ?? this.position, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (key.present) { + map['key'] = Variable(key.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + if (builtIn.present) { + map['built_in'] = Variable(builtIn.value); + } + if (direct.present) { + map['direct'] = Variable(direct.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UnitsCompanion(') + ..write('key: $key, ') + ..write('label: $label, ') + ..write('builtIn: $builtIn, ') + ..write('direct: $direct, ') + ..write('position: $position, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $MealsTable extends Meals with TableInfo<$MealsTable, MealRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MealsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _positionMeta = const VerificationMeta('position'); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _retiredMeta = const VerificationMeta('retired'); + @override + late final GeneratedColumn retired = GeneratedColumn( + 'retired', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("retired" IN (0, 1))'), + defaultValue: const Constant(false), + ); + @override + List get $columns => [id, name, position, retired]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'meals'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('name')) { + context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('position')) { + context.handle(_positionMeta, position.isAcceptableOrUnknown(data['position']!, _positionMeta)); + } else if (isInserting) { + context.missing(_positionMeta); + } + if (data.containsKey('retired')) { + context.handle(_retiredMeta, retired.isAcceptableOrUnknown(data['retired']!, _retiredMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + MealRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MealRow( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!, + position: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}position'])!, + retired: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}retired'])!, + ); + } + + @override + $MealsTable createAlias(String alias) { + return $MealsTable(attachedDatabase, alias); + } +} + +class MealRow extends DataClass implements Insertable { + final String id; + final String name; + final int position; + final bool retired; + const MealRow({required this.id, required this.name, required this.position, required this.retired}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['position'] = Variable(position); + map['retired'] = Variable(retired); + return map; + } + + MealsCompanion toCompanion(bool nullToAbsent) { + return MealsCompanion(id: Value(id), name: Value(name), position: Value(position), retired: Value(retired)); + } + + factory MealRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MealRow( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + position: serializer.fromJson(json['position']), + retired: serializer.fromJson(json['retired']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'position': serializer.toJson(position), + 'retired': serializer.toJson(retired), + }; + } + + MealRow copyWith({String? id, String? name, int? position, bool? retired}) => MealRow( + id: id ?? this.id, + name: name ?? this.name, + position: position ?? this.position, + retired: retired ?? this.retired, + ); + MealRow copyWithCompanion(MealsCompanion data) { + return MealRow( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + position: data.position.present ? data.position.value : this.position, + retired: data.retired.present ? data.retired.value : this.retired, + ); + } + + @override + String toString() { + return (StringBuffer('MealRow(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('position: $position, ') + ..write('retired: $retired') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, position, retired); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MealRow && + other.id == this.id && + other.name == this.name && + other.position == this.position && + other.retired == this.retired); +} + +class MealsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value position; + final Value retired; + final Value rowid; + const MealsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.position = const Value.absent(), + this.retired = const Value.absent(), + this.rowid = const Value.absent(), + }); + MealsCompanion.insert({ + required String id, + required String name, + required int position, + this.retired = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + name = Value(name), + position = Value(position); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? position, + Expression? retired, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (position != null) 'position': position, + if (retired != null) 'retired': retired, + if (rowid != null) 'rowid': rowid, + }); + } + + MealsCompanion copyWith({ + Value? id, + Value? name, + Value? position, + Value? retired, + Value? rowid, + }) { + return MealsCompanion( + id: id ?? this.id, + name: name ?? this.name, + position: position ?? this.position, + retired: retired ?? this.retired, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (retired.present) { + map['retired'] = Variable(retired.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MealsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('position: $position, ') + ..write('retired: $retired, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $PlanEntriesTable extends PlanEntries with TableInfo<$PlanEntriesTable, PlanEntryRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlanEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _dayMeta = const VerificationMeta('day'); + @override + late final GeneratedColumn day = GeneratedColumn( + 'day', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _mealIdMeta = const VerificationMeta('mealId'); + @override + late final GeneratedColumn mealId = GeneratedColumn( + 'meal_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES meals (id)'), + ); + static const VerificationMeta _recipeIdMeta = const VerificationMeta('recipeId'); + @override + late final GeneratedColumn recipeId = GeneratedColumn( + 'recipe_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES recipes (id) ON DELETE SET NULL'), + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _positionMeta = const VerificationMeta('position'); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, day, mealId, recipeId, note, position]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plan_entries'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('day')) { + context.handle(_dayMeta, day.isAcceptableOrUnknown(data['day']!, _dayMeta)); + } else if (isInserting) { + context.missing(_dayMeta); + } + if (data.containsKey('meal_id')) { + context.handle(_mealIdMeta, mealId.isAcceptableOrUnknown(data['meal_id']!, _mealIdMeta)); + } else if (isInserting) { + context.missing(_mealIdMeta); + } + if (data.containsKey('recipe_id')) { + context.handle(_recipeIdMeta, recipeId.isAcceptableOrUnknown(data['recipe_id']!, _recipeIdMeta)); + } + if (data.containsKey('note')) { + context.handle(_noteMeta, note.isAcceptableOrUnknown(data['note']!, _noteMeta)); + } + if (data.containsKey('position')) { + context.handle(_positionMeta, position.isAcceptableOrUnknown(data['position']!, _positionMeta)); + } else if (isInserting) { + context.missing(_positionMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PlanEntryRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PlanEntryRow( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + day: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}day'])!, + mealId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}meal_id'])!, + recipeId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}recipe_id']), + note: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}note']), + position: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}position'])!, + ); + } + + @override + $PlanEntriesTable createAlias(String alias) { + return $PlanEntriesTable(attachedDatabase, alias); + } +} + +class PlanEntryRow extends DataClass implements Insertable { + final String id; + final String day; + final String mealId; + final String? recipeId; + final String? note; + final int position; + const PlanEntryRow({ + required this.id, + required this.day, + required this.mealId, + this.recipeId, + this.note, + required this.position, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['day'] = Variable(day); + map['meal_id'] = Variable(mealId); + if (!nullToAbsent || recipeId != null) { + map['recipe_id'] = Variable(recipeId); + } + if (!nullToAbsent || note != null) { + map['note'] = Variable(note); + } + map['position'] = Variable(position); + return map; + } + + PlanEntriesCompanion toCompanion(bool nullToAbsent) { + return PlanEntriesCompanion( + id: Value(id), + day: Value(day), + mealId: Value(mealId), + recipeId: recipeId == null && nullToAbsent ? const Value.absent() : Value(recipeId), + note: note == null && nullToAbsent ? const Value.absent() : Value(note), + position: Value(position), + ); + } + + factory PlanEntryRow.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlanEntryRow( + id: serializer.fromJson(json['id']), + day: serializer.fromJson(json['day']), + mealId: serializer.fromJson(json['mealId']), + recipeId: serializer.fromJson(json['recipeId']), + note: serializer.fromJson(json['note']), + position: serializer.fromJson(json['position']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'day': serializer.toJson(day), + 'mealId': serializer.toJson(mealId), + 'recipeId': serializer.toJson(recipeId), + 'note': serializer.toJson(note), + 'position': serializer.toJson(position), + }; + } + + PlanEntryRow copyWith({ + String? id, + String? day, + String? mealId, + Value recipeId = const Value.absent(), + Value note = const Value.absent(), + int? position, + }) => PlanEntryRow( + id: id ?? this.id, + day: day ?? this.day, + mealId: mealId ?? this.mealId, + recipeId: recipeId.present ? recipeId.value : this.recipeId, + note: note.present ? note.value : this.note, + position: position ?? this.position, + ); + PlanEntryRow copyWithCompanion(PlanEntriesCompanion data) { + return PlanEntryRow( + id: data.id.present ? data.id.value : this.id, + day: data.day.present ? data.day.value : this.day, + mealId: data.mealId.present ? data.mealId.value : this.mealId, + recipeId: data.recipeId.present ? data.recipeId.value : this.recipeId, + note: data.note.present ? data.note.value : this.note, + position: data.position.present ? data.position.value : this.position, + ); + } + + @override + String toString() { + return (StringBuffer('PlanEntryRow(') + ..write('id: $id, ') + ..write('day: $day, ') + ..write('mealId: $mealId, ') + ..write('recipeId: $recipeId, ') + ..write('note: $note, ') + ..write('position: $position') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, day, mealId, recipeId, note, position); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PlanEntryRow && + other.id == this.id && + other.day == this.day && + other.mealId == this.mealId && + other.recipeId == this.recipeId && + other.note == this.note && + other.position == this.position); +} + +class PlanEntriesCompanion extends UpdateCompanion { + final Value id; + final Value day; + final Value mealId; + final Value recipeId; + final Value note; + final Value position; + final Value rowid; + const PlanEntriesCompanion({ + this.id = const Value.absent(), + this.day = const Value.absent(), + this.mealId = const Value.absent(), + this.recipeId = const Value.absent(), + this.note = const Value.absent(), + this.position = const Value.absent(), + this.rowid = const Value.absent(), + }); + PlanEntriesCompanion.insert({ + required String id, + required String day, + required String mealId, + this.recipeId = const Value.absent(), + this.note = const Value.absent(), + required int position, + this.rowid = const Value.absent(), + }) : id = Value(id), + day = Value(day), + mealId = Value(mealId), + position = Value(position); + static Insertable custom({ + Expression? id, + Expression? day, + Expression? mealId, + Expression? recipeId, + Expression? note, + Expression? position, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (day != null) 'day': day, + if (mealId != null) 'meal_id': mealId, + if (recipeId != null) 'recipe_id': recipeId, + if (note != null) 'note': note, + if (position != null) 'position': position, + if (rowid != null) 'rowid': rowid, + }); + } + + PlanEntriesCompanion copyWith({ + Value? id, + Value? day, + Value? mealId, + Value? recipeId, + Value? note, + Value? position, + Value? rowid, + }) { + return PlanEntriesCompanion( + id: id ?? this.id, + day: day ?? this.day, + mealId: mealId ?? this.mealId, + recipeId: recipeId ?? this.recipeId, + note: note ?? this.note, + position: position ?? this.position, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (day.present) { + map['day'] = Variable(day.value); + } + if (mealId.present) { + map['meal_id'] = Variable(mealId.value); + } + if (recipeId.present) { + map['recipe_id'] = Variable(recipeId.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlanEntriesCompanion(') + ..write('id: $id, ') + ..write('day: $day, ') + ..write('mealId: $mealId, ') + ..write('recipeId: $recipeId, ') + ..write('note: $note, ') + ..write('position: $position, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $CoursesTable courses = $CoursesTable(this); + late final $RecipesTable recipes = $RecipesTable(this); + late final $UnitsTable units = $UnitsTable(this); + late final $MealsTable meals = $MealsTable(this); + late final $PlanEntriesTable planEntries = $PlanEntriesTable(this); + @override + Iterable> get allTables => allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [courses, recipes, units, meals, planEntries]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName('courses', limitUpdateKind: UpdateKind.delete), + result: [TableUpdate('recipes', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('recipes', limitUpdateKind: UpdateKind.delete), + result: [TableUpdate('plan_entries', kind: UpdateKind.update)], + ), + ]); +} + +typedef $$CoursesTableCreateCompanionBuilder = + CoursesCompanion Function({ + required String id, + required String name, + Value emoji, + required int position, + Value rowid, + }); +typedef $$CoursesTableUpdateCompanionBuilder = + CoursesCompanion Function({ + Value id, + Value name, + Value emoji, + Value position, + Value rowid, + }); + +final class $$CoursesTableReferences extends BaseReferences<_$AppDatabase, $CoursesTable, CourseRow> { + $$CoursesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$RecipesTable, List> _recipesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable(db.recipes, aliasName: $_aliasNameGenerator(db.courses.id, db.recipes.courseId)); + + $$RecipesTableProcessedTableManager get recipesRefs { + final manager = $$RecipesTableTableManager( + $_db, + $_db.recipes, + ).filter((f) => f.courseId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_recipesRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$CoursesTableFilterComposer extends Composer<_$AppDatabase, $CoursesTable> { + $$CoursesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnFilters get emoji => + $composableBuilder(column: $table.emoji, builder: (column) => ColumnFilters(column)); + + ColumnFilters get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnFilters(column)); + + Expression recipesRefs(Expression Function($$RecipesTableFilterComposer f) f) { + final $$RecipesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.recipes, + getReferencedColumn: (t) => t.courseId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$RecipesTableFilterComposer( + $db: $db, + $table: $db.recipes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$CoursesTableOrderingComposer extends Composer<_$AppDatabase, $CoursesTable> { + $$CoursesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => + $composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get emoji => + $composableBuilder(column: $table.emoji, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnOrderings(column)); +} + +class $$CoursesTableAnnotationComposer extends Composer<_$AppDatabase, $CoursesTable> { + $$CoursesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get emoji => $composableBuilder(column: $table.emoji, builder: (column) => column); + + GeneratedColumn get position => $composableBuilder(column: $table.position, builder: (column) => column); + + Expression recipesRefs(Expression Function($$RecipesTableAnnotationComposer a) f) { + final $$RecipesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.recipes, + getReferencedColumn: (t) => t.courseId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$RecipesTableAnnotationComposer( + $db: $db, + $table: $db.recipes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$CoursesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CoursesTable, + CourseRow, + $$CoursesTableFilterComposer, + $$CoursesTableOrderingComposer, + $$CoursesTableAnnotationComposer, + $$CoursesTableCreateCompanionBuilder, + $$CoursesTableUpdateCompanionBuilder, + (CourseRow, $$CoursesTableReferences), + CourseRow, + PrefetchHooks Function({bool recipesRefs}) + > { + $$CoursesTableTableManager(_$AppDatabase db, $CoursesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$CoursesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$CoursesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$CoursesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value emoji = const Value.absent(), + Value position = const Value.absent(), + Value rowid = const Value.absent(), + }) => CoursesCompanion(id: id, name: name, emoji: emoji, position: position, rowid: rowid), + createCompanionCallback: + ({ + required String id, + required String name, + Value emoji = const Value.absent(), + required int position, + Value rowid = const Value.absent(), + }) => CoursesCompanion.insert(id: id, name: name, emoji: emoji, position: position, rowid: rowid), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$CoursesTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({recipesRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (recipesRefs) db.recipes], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (recipesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$CoursesTableReferences._recipesRefsTable(db), + managerFromTypedResult: (p0) => $$CoursesTableReferences(db, table, p0).recipesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.courseId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$CoursesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CoursesTable, + CourseRow, + $$CoursesTableFilterComposer, + $$CoursesTableOrderingComposer, + $$CoursesTableAnnotationComposer, + $$CoursesTableCreateCompanionBuilder, + $$CoursesTableUpdateCompanionBuilder, + (CourseRow, $$CoursesTableReferences), + CourseRow, + PrefetchHooks Function({bool recipesRefs}) + >; +typedef $$RecipesTableCreateCompanionBuilder = + RecipesCompanion Function({ + required String id, + required String name, + Value courseId, + required List tags, + Value servings, + Value prepMinutes, + Value cookMinutes, + Value kcal, + Value caloriesBasis, + required List ingredientGroups, + required List stepSections, + required List photos, + required List sources, + Value notes, + Value favourite, + required List reviewReasons, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $$RecipesTableUpdateCompanionBuilder = + RecipesCompanion Function({ + Value id, + Value name, + Value courseId, + Value> tags, + Value servings, + Value prepMinutes, + Value cookMinutes, + Value kcal, + Value caloriesBasis, + Value> ingredientGroups, + Value> stepSections, + Value> photos, + Value> sources, + Value notes, + Value favourite, + Value> reviewReasons, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +final class $$RecipesTableReferences extends BaseReferences<_$AppDatabase, $RecipesTable, RecipeRow> { + $$RecipesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $CoursesTable _courseIdTable(_$AppDatabase db) => + db.courses.createAlias($_aliasNameGenerator(db.recipes.courseId, db.courses.id)); + + $$CoursesTableProcessedTableManager? get courseId { + final $_column = $_itemColumn('course_id'); + if ($_column == null) return null; + final manager = $$CoursesTableTableManager($_db, $_db.courses).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_courseIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); + } + + static MultiTypedResultKey<$PlanEntriesTable, List> _planEntriesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.planEntries, + aliasName: $_aliasNameGenerator(db.recipes.id, db.planEntries.recipeId), + ); + + $$PlanEntriesTableProcessedTableManager get planEntriesRefs { + final manager = $$PlanEntriesTableTableManager( + $_db, + $_db.planEntries, + ).filter((f) => f.recipeId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_planEntriesRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$RecipesTableFilterComposer extends Composer<_$AppDatabase, $RecipesTable> { + $$RecipesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get servings => + $composableBuilder(column: $table.servings, builder: (column) => ColumnFilters(column)); + + ColumnFilters get prepMinutes => + $composableBuilder(column: $table.prepMinutes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get cookMinutes => + $composableBuilder(column: $table.cookMinutes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get kcal => $composableBuilder(column: $table.kcal, builder: (column) => ColumnFilters(column)); + + ColumnFilters get caloriesBasis => + $composableBuilder(column: $table.caloriesBasis, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get ingredientGroups => + $composableBuilder(column: $table.ingredientGroups, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get stepSections => + $composableBuilder(column: $table.stepSections, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get photos => + $composableBuilder(column: $table.photos, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get sources => + $composableBuilder(column: $table.sources, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get notes => + $composableBuilder(column: $table.notes, builder: (column) => ColumnFilters(column)); + + ColumnFilters get favourite => + $composableBuilder(column: $table.favourite, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get reviewReasons => + $composableBuilder(column: $table.reviewReasons, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + + $$CoursesTableFilterComposer get courseId { + final $$CoursesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.courseId, + referencedTable: $db.courses, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$CoursesTableFilterComposer( + $db: $db, + $table: $db.courses, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression planEntriesRefs(Expression Function($$PlanEntriesTableFilterComposer f) f) { + final $$PlanEntriesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.planEntries, + getReferencedColumn: (t) => t.recipeId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PlanEntriesTableFilterComposer( + $db: $db, + $table: $db.planEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RecipesTableOrderingComposer extends Composer<_$AppDatabase, $RecipesTable> { + $$RecipesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => + $composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get tags => + $composableBuilder(column: $table.tags, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get servings => + $composableBuilder(column: $table.servings, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get prepMinutes => + $composableBuilder(column: $table.prepMinutes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get cookMinutes => + $composableBuilder(column: $table.cookMinutes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get kcal => + $composableBuilder(column: $table.kcal, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get caloriesBasis => + $composableBuilder(column: $table.caloriesBasis, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get ingredientGroups => + $composableBuilder(column: $table.ingredientGroups, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get stepSections => + $composableBuilder(column: $table.stepSections, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get photos => + $composableBuilder(column: $table.photos, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sources => + $composableBuilder(column: $table.sources, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get notes => + $composableBuilder(column: $table.notes, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get favourite => + $composableBuilder(column: $table.favourite, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get reviewReasons => + $composableBuilder(column: $table.reviewReasons, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + + $$CoursesTableOrderingComposer get courseId { + final $$CoursesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.courseId, + referencedTable: $db.courses, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$CoursesTableOrderingComposer( + $db: $db, + $table: $db.courses, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$RecipesTableAnnotationComposer extends Composer<_$AppDatabase, $RecipesTable> { + $$RecipesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get tags => + $composableBuilder(column: $table.tags, builder: (column) => column); + + GeneratedColumn get servings => $composableBuilder(column: $table.servings, builder: (column) => column); + + GeneratedColumn get prepMinutes => $composableBuilder(column: $table.prepMinutes, builder: (column) => column); + + GeneratedColumn get cookMinutes => $composableBuilder(column: $table.cookMinutes, builder: (column) => column); + + GeneratedColumn get kcal => $composableBuilder(column: $table.kcal, builder: (column) => column); + + GeneratedColumn get caloriesBasis => + $composableBuilder(column: $table.caloriesBasis, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get ingredientGroups => + $composableBuilder(column: $table.ingredientGroups, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get stepSections => + $composableBuilder(column: $table.stepSections, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get photos => + $composableBuilder(column: $table.photos, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get sources => + $composableBuilder(column: $table.sources, builder: (column) => column); + + GeneratedColumn get notes => $composableBuilder(column: $table.notes, builder: (column) => column); + + GeneratedColumn get favourite => $composableBuilder(column: $table.favourite, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get reviewReasons => + $composableBuilder(column: $table.reviewReasons, builder: (column) => column); + + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + $$CoursesTableAnnotationComposer get courseId { + final $$CoursesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.courseId, + referencedTable: $db.courses, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$CoursesTableAnnotationComposer( + $db: $db, + $table: $db.courses, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression planEntriesRefs(Expression Function($$PlanEntriesTableAnnotationComposer a) f) { + final $$PlanEntriesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.planEntries, + getReferencedColumn: (t) => t.recipeId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PlanEntriesTableAnnotationComposer( + $db: $db, + $table: $db.planEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RecipesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RecipesTable, + RecipeRow, + $$RecipesTableFilterComposer, + $$RecipesTableOrderingComposer, + $$RecipesTableAnnotationComposer, + $$RecipesTableCreateCompanionBuilder, + $$RecipesTableUpdateCompanionBuilder, + (RecipeRow, $$RecipesTableReferences), + RecipeRow, + PrefetchHooks Function({bool courseId, bool planEntriesRefs}) + > { + $$RecipesTableTableManager(_$AppDatabase db, $RecipesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$RecipesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$RecipesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$RecipesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value courseId = const Value.absent(), + Value> tags = const Value.absent(), + Value servings = const Value.absent(), + Value prepMinutes = const Value.absent(), + Value cookMinutes = const Value.absent(), + Value kcal = const Value.absent(), + Value caloriesBasis = const Value.absent(), + Value> ingredientGroups = const Value.absent(), + Value> stepSections = const Value.absent(), + Value> photos = const Value.absent(), + Value> sources = const Value.absent(), + Value notes = const Value.absent(), + Value favourite = const Value.absent(), + Value> reviewReasons = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => RecipesCompanion( + id: id, + name: name, + courseId: courseId, + tags: tags, + servings: servings, + prepMinutes: prepMinutes, + cookMinutes: cookMinutes, + kcal: kcal, + caloriesBasis: caloriesBasis, + ingredientGroups: ingredientGroups, + stepSections: stepSections, + photos: photos, + sources: sources, + notes: notes, + favourite: favourite, + reviewReasons: reviewReasons, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + Value courseId = const Value.absent(), + required List tags, + Value servings = const Value.absent(), + Value prepMinutes = const Value.absent(), + Value cookMinutes = const Value.absent(), + Value kcal = const Value.absent(), + Value caloriesBasis = const Value.absent(), + required List ingredientGroups, + required List stepSections, + required List photos, + required List sources, + Value notes = const Value.absent(), + Value favourite = const Value.absent(), + required List reviewReasons, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => RecipesCompanion.insert( + id: id, + name: name, + courseId: courseId, + tags: tags, + servings: servings, + prepMinutes: prepMinutes, + cookMinutes: cookMinutes, + kcal: kcal, + caloriesBasis: caloriesBasis, + ingredientGroups: ingredientGroups, + stepSections: stepSections, + photos: photos, + sources: sources, + notes: notes, + favourite: favourite, + reviewReasons: reviewReasons, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$RecipesTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({courseId = false, planEntriesRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (planEntriesRefs) db.planEntries], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (courseId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.courseId, + referencedTable: $$RecipesTableReferences._courseIdTable(db), + referencedColumn: $$RecipesTableReferences._courseIdTable(db).id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (planEntriesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$RecipesTableReferences._planEntriesRefsTable(db), + managerFromTypedResult: (p0) => $$RecipesTableReferences(db, table, p0).planEntriesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.recipeId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$RecipesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RecipesTable, + RecipeRow, + $$RecipesTableFilterComposer, + $$RecipesTableOrderingComposer, + $$RecipesTableAnnotationComposer, + $$RecipesTableCreateCompanionBuilder, + $$RecipesTableUpdateCompanionBuilder, + (RecipeRow, $$RecipesTableReferences), + RecipeRow, + PrefetchHooks Function({bool courseId, bool planEntriesRefs}) + >; +typedef $$UnitsTableCreateCompanionBuilder = + UnitsCompanion Function({ + required String key, + Value label, + required bool builtIn, + required bool direct, + required int position, + Value rowid, + }); +typedef $$UnitsTableUpdateCompanionBuilder = + UnitsCompanion Function({ + Value key, + Value label, + Value builtIn, + Value direct, + Value position, + Value rowid, + }); + +class $$UnitsTableFilterComposer extends Composer<_$AppDatabase, $UnitsTable> { + $$UnitsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder(column: $table.key, builder: (column) => ColumnFilters(column)); + + ColumnFilters get label => + $composableBuilder(column: $table.label, builder: (column) => ColumnFilters(column)); + + ColumnFilters get builtIn => + $composableBuilder(column: $table.builtIn, builder: (column) => ColumnFilters(column)); + + ColumnFilters get direct => + $composableBuilder(column: $table.direct, builder: (column) => ColumnFilters(column)); + + ColumnFilters get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnFilters(column)); +} + +class $$UnitsTableOrderingComposer extends Composer<_$AppDatabase, $UnitsTable> { + $$UnitsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => + $composableBuilder(column: $table.key, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get label => + $composableBuilder(column: $table.label, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get builtIn => + $composableBuilder(column: $table.builtIn, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get direct => + $composableBuilder(column: $table.direct, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnOrderings(column)); +} + +class $$UnitsTableAnnotationComposer extends Composer<_$AppDatabase, $UnitsTable> { + $$UnitsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get label => $composableBuilder(column: $table.label, builder: (column) => column); + + GeneratedColumn get builtIn => $composableBuilder(column: $table.builtIn, builder: (column) => column); + + GeneratedColumn get direct => $composableBuilder(column: $table.direct, builder: (column) => column); + + GeneratedColumn get position => $composableBuilder(column: $table.position, builder: (column) => column); +} + +class $$UnitsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $UnitsTable, + UnitRow, + $$UnitsTableFilterComposer, + $$UnitsTableOrderingComposer, + $$UnitsTableAnnotationComposer, + $$UnitsTableCreateCompanionBuilder, + $$UnitsTableUpdateCompanionBuilder, + (UnitRow, BaseReferences<_$AppDatabase, $UnitsTable, UnitRow>), + UnitRow, + PrefetchHooks Function() + > { + $$UnitsTableTableManager(_$AppDatabase db, $UnitsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$UnitsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$UnitsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$UnitsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value key = const Value.absent(), + Value label = const Value.absent(), + Value builtIn = const Value.absent(), + Value direct = const Value.absent(), + Value position = const Value.absent(), + Value rowid = const Value.absent(), + }) => UnitsCompanion( + key: key, + label: label, + builtIn: builtIn, + direct: direct, + position: position, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String key, + Value label = const Value.absent(), + required bool builtIn, + required bool direct, + required int position, + Value rowid = const Value.absent(), + }) => UnitsCompanion.insert( + key: key, + label: label, + builtIn: builtIn, + direct: direct, + position: position, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$UnitsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $UnitsTable, + UnitRow, + $$UnitsTableFilterComposer, + $$UnitsTableOrderingComposer, + $$UnitsTableAnnotationComposer, + $$UnitsTableCreateCompanionBuilder, + $$UnitsTableUpdateCompanionBuilder, + (UnitRow, BaseReferences<_$AppDatabase, $UnitsTable, UnitRow>), + UnitRow, + PrefetchHooks Function() + >; +typedef $$MealsTableCreateCompanionBuilder = + MealsCompanion Function({ + required String id, + required String name, + required int position, + Value retired, + Value rowid, + }); +typedef $$MealsTableUpdateCompanionBuilder = + MealsCompanion Function({ + Value id, + Value name, + Value position, + Value retired, + Value rowid, + }); + +final class $$MealsTableReferences extends BaseReferences<_$AppDatabase, $MealsTable, MealRow> { + $$MealsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$PlanEntriesTable, List> _planEntriesRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.planEntries, + aliasName: $_aliasNameGenerator(db.meals.id, db.planEntries.mealId), + ); + + $$PlanEntriesTableProcessedTableManager get planEntriesRefs { + final manager = $$PlanEntriesTableTableManager( + $_db, + $_db.planEntries, + ).filter((f) => f.mealId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_planEntriesRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $$MealsTableFilterComposer extends Composer<_$AppDatabase, $MealsTable> { + $$MealsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column)); + + ColumnFilters get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnFilters(column)); + + ColumnFilters get retired => + $composableBuilder(column: $table.retired, builder: (column) => ColumnFilters(column)); + + Expression planEntriesRefs(Expression Function($$PlanEntriesTableFilterComposer f) f) { + final $$PlanEntriesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.planEntries, + getReferencedColumn: (t) => t.mealId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PlanEntriesTableFilterComposer( + $db: $db, + $table: $db.planEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$MealsTableOrderingComposer extends Composer<_$AppDatabase, $MealsTable> { + $$MealsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get name => + $composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get retired => + $composableBuilder(column: $table.retired, builder: (column) => ColumnOrderings(column)); +} + +class $$MealsTableAnnotationComposer extends Composer<_$AppDatabase, $MealsTable> { + $$MealsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get position => $composableBuilder(column: $table.position, builder: (column) => column); + + GeneratedColumn get retired => $composableBuilder(column: $table.retired, builder: (column) => column); + + Expression planEntriesRefs(Expression Function($$PlanEntriesTableAnnotationComposer a) f) { + final $$PlanEntriesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.planEntries, + getReferencedColumn: (t) => t.mealId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PlanEntriesTableAnnotationComposer( + $db: $db, + $table: $db.planEntries, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$MealsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $MealsTable, + MealRow, + $$MealsTableFilterComposer, + $$MealsTableOrderingComposer, + $$MealsTableAnnotationComposer, + $$MealsTableCreateCompanionBuilder, + $$MealsTableUpdateCompanionBuilder, + (MealRow, $$MealsTableReferences), + MealRow, + PrefetchHooks Function({bool planEntriesRefs}) + > { + $$MealsTableTableManager(_$AppDatabase db, $MealsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$MealsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$MealsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$MealsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value position = const Value.absent(), + Value retired = const Value.absent(), + Value rowid = const Value.absent(), + }) => MealsCompanion(id: id, name: name, position: position, retired: retired, rowid: rowid), + createCompanionCallback: + ({ + required String id, + required String name, + required int position, + Value retired = const Value.absent(), + Value rowid = const Value.absent(), + }) => MealsCompanion.insert(id: id, name: name, position: position, retired: retired, rowid: rowid), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$MealsTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({planEntriesRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (planEntriesRefs) db.planEntries], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (planEntriesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$MealsTableReferences._planEntriesRefsTable(db), + managerFromTypedResult: (p0) => $$MealsTableReferences(db, table, p0).planEntriesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.mealId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$MealsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $MealsTable, + MealRow, + $$MealsTableFilterComposer, + $$MealsTableOrderingComposer, + $$MealsTableAnnotationComposer, + $$MealsTableCreateCompanionBuilder, + $$MealsTableUpdateCompanionBuilder, + (MealRow, $$MealsTableReferences), + MealRow, + PrefetchHooks Function({bool planEntriesRefs}) + >; +typedef $$PlanEntriesTableCreateCompanionBuilder = + PlanEntriesCompanion Function({ + required String id, + required String day, + required String mealId, + Value recipeId, + Value note, + required int position, + Value rowid, + }); +typedef $$PlanEntriesTableUpdateCompanionBuilder = + PlanEntriesCompanion Function({ + Value id, + Value day, + Value mealId, + Value recipeId, + Value note, + Value position, + Value rowid, + }); + +final class $$PlanEntriesTableReferences extends BaseReferences<_$AppDatabase, $PlanEntriesTable, PlanEntryRow> { + $$PlanEntriesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $MealsTable _mealIdTable(_$AppDatabase db) => + db.meals.createAlias($_aliasNameGenerator(db.planEntries.mealId, db.meals.id)); + + $$MealsTableProcessedTableManager get mealId { + final $_column = $_itemColumn('meal_id')!; + + final manager = $$MealsTableTableManager($_db, $_db.meals).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_mealIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); + } + + static $RecipesTable _recipeIdTable(_$AppDatabase db) => + db.recipes.createAlias($_aliasNameGenerator(db.planEntries.recipeId, db.recipes.id)); + + $$RecipesTableProcessedTableManager? get recipeId { + final $_column = $_itemColumn('recipe_id'); + if ($_column == null) return null; + final manager = $$RecipesTableTableManager($_db, $_db.recipes).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_recipeIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$PlanEntriesTableFilterComposer extends Composer<_$AppDatabase, $PlanEntriesTable> { + $$PlanEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get day => $composableBuilder(column: $table.day, builder: (column) => ColumnFilters(column)); + + ColumnFilters get note => $composableBuilder(column: $table.note, builder: (column) => ColumnFilters(column)); + + ColumnFilters get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnFilters(column)); + + $$MealsTableFilterComposer get mealId { + final $$MealsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.mealId, + referencedTable: $db.meals, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MealsTableFilterComposer( + $db: $db, + $table: $db.meals, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$RecipesTableFilterComposer get recipeId { + final $$RecipesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipes, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$RecipesTableFilterComposer( + $db: $db, + $table: $db.recipes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PlanEntriesTableOrderingComposer extends Composer<_$AppDatabase, $PlanEntriesTable> { + $$PlanEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get day => + $composableBuilder(column: $table.day, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get note => + $composableBuilder(column: $table.note, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get position => + $composableBuilder(column: $table.position, builder: (column) => ColumnOrderings(column)); + + $$MealsTableOrderingComposer get mealId { + final $$MealsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.mealId, + referencedTable: $db.meals, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MealsTableOrderingComposer( + $db: $db, + $table: $db.meals, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$RecipesTableOrderingComposer get recipeId { + final $$RecipesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipes, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$RecipesTableOrderingComposer( + $db: $db, + $table: $db.recipes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PlanEntriesTableAnnotationComposer extends Composer<_$AppDatabase, $PlanEntriesTable> { + $$PlanEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get day => $composableBuilder(column: $table.day, builder: (column) => column); + + GeneratedColumn get note => $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get position => $composableBuilder(column: $table.position, builder: (column) => column); + + $$MealsTableAnnotationComposer get mealId { + final $$MealsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.mealId, + referencedTable: $db.meals, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MealsTableAnnotationComposer( + $db: $db, + $table: $db.meals, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$RecipesTableAnnotationComposer get recipeId { + final $$RecipesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.recipeId, + referencedTable: $db.recipes, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$RecipesTableAnnotationComposer( + $db: $db, + $table: $db.recipes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PlanEntriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PlanEntriesTable, + PlanEntryRow, + $$PlanEntriesTableFilterComposer, + $$PlanEntriesTableOrderingComposer, + $$PlanEntriesTableAnnotationComposer, + $$PlanEntriesTableCreateCompanionBuilder, + $$PlanEntriesTableUpdateCompanionBuilder, + (PlanEntryRow, $$PlanEntriesTableReferences), + PlanEntryRow, + PrefetchHooks Function({bool mealId, bool recipeId}) + > { + $$PlanEntriesTableTableManager(_$AppDatabase db, $PlanEntriesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$PlanEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$PlanEntriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$PlanEntriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value day = const Value.absent(), + Value mealId = const Value.absent(), + Value recipeId = const Value.absent(), + Value note = const Value.absent(), + Value position = const Value.absent(), + Value rowid = const Value.absent(), + }) => PlanEntriesCompanion( + id: id, + day: day, + mealId: mealId, + recipeId: recipeId, + note: note, + position: position, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String day, + required String mealId, + Value recipeId = const Value.absent(), + Value note = const Value.absent(), + required int position, + Value rowid = const Value.absent(), + }) => PlanEntriesCompanion.insert( + id: id, + day: day, + mealId: mealId, + recipeId: recipeId, + note: note, + position: position, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$PlanEntriesTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({mealId = false, recipeId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (mealId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.mealId, + referencedTable: $$PlanEntriesTableReferences._mealIdTable(db), + referencedColumn: $$PlanEntriesTableReferences._mealIdTable(db).id, + ) + as T; + } + if (recipeId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.recipeId, + referencedTable: $$PlanEntriesTableReferences._recipeIdTable(db), + referencedColumn: $$PlanEntriesTableReferences._recipeIdTable(db).id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$PlanEntriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PlanEntriesTable, + PlanEntryRow, + $$PlanEntriesTableFilterComposer, + $$PlanEntriesTableOrderingComposer, + $$PlanEntriesTableAnnotationComposer, + $$PlanEntriesTableCreateCompanionBuilder, + $$PlanEntriesTableUpdateCompanionBuilder, + (PlanEntryRow, $$PlanEntriesTableReferences), + PlanEntryRow, + PrefetchHooks Function({bool mealId, bool recipeId}) + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$CoursesTableTableManager get courses => $$CoursesTableTableManager(_db, _db.courses); + $$RecipesTableTableManager get recipes => $$RecipesTableTableManager(_db, _db.recipes); + $$UnitsTableTableManager get units => $$UnitsTableTableManager(_db, _db.units); + $$MealsTableTableManager get meals => $$MealsTableTableManager(_db, _db.meals); + $$PlanEntriesTableTableManager get planEntries => $$PlanEntriesTableTableManager(_db, _db.planEntries); +} diff --git a/lib/src/design/app_theme.dart b/lib/src/design/app_theme.dart new file mode 100644 index 0000000..9473662 --- /dev/null +++ b/lib/src/design/app_theme.dart @@ -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)), + ), + ); + } +} diff --git a/lib/src/design/cookit_colors.dart b/lib/src/design/cookit_colors.dart new file mode 100644 index 0000000..8619eb7 --- /dev/null +++ b/lib/src/design/cookit_colors.dart @@ -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 { + 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 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() ?? light; + + @override + CookItColors copyWith() => this; + + @override + CookItColors lerp(CookItColors? other, double t) => t < 0.5 || other == null ? this : other; +} diff --git a/lib/src/design/cookit_nav_bar.dart b/lib/src/design/cookit_nav_bar.dart new file mode 100644 index 0000000..e419503 --- /dev/null +++ b/lib/src/design/cookit_nav_bar.dart @@ -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 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/design/herb_pattern.dart b/lib/src/design/herb_pattern.dart new file mode 100644 index 0000000..02d4f57 --- /dev/null +++ b/lib/src/design/herb_pattern.dart @@ -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 = + '' + ''; + +const _mint = ''' + + + + + + +'''; + +const _parsley = ''' + + + +'''; + +const _chili = ''' + +'''; + +const _pepper = ''; + +/// The cluster around the home screen's title: big lime leaf, sage leaf and mint. +const _headerHeight = 220.0; +const _headerSvg = + ''' + + $_leaf + $_leaf + $_mint +'''; + +/// One tile of edge decorations; tiles repeat down the page. +const _edgeHeight = 844.0; +const _edgeSvg = + ''' + + $_parsley + $_leaf + $_chili + $_pepper + $_leaf + $_leaf + $_pepper +'''; + +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()), + ], + ); + } +} diff --git a/lib/src/design/leaf_painter.dart b/lib/src/design/leaf_painter.dart new file mode 100644 index 0000000..1e1c2a7 --- /dev/null +++ b/lib/src/design/leaf_painter.dart @@ -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; +} diff --git a/lib/src/home/home_view.dart b/lib/src/home/home_view.dart new file mode 100644 index 0000000..a0246fa --- /dev/null +++ b/lib/src/home/home_view.dart @@ -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 createState() => _HomeViewState(); +} + +class _HomeViewState extends State { + 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), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb new file mode 100644 index 0000000..4ad2306 --- /dev/null +++ b/lib/src/localization/app_de.arb @@ -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 …" +} diff --git a/lib/src/localization/app_localizations.dart b/lib/src/localization/app_localizations.dart new file mode 100644 index 0000000..930a927 --- /dev/null +++ b/lib/src/localization/app_localizations.dart @@ -0,0 +1,1904 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_de.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'localization/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('de')]; + + /// No description provided for @appTitle. + /// + /// In de, this message translates to: + /// **'CookIt'** + String get appTitle; + + /// No description provided for @searchHint. + /// + /// In de, this message translates to: + /// **'Rezept, Zutat oder Stichwort'** + String get searchHint; + + /// No description provided for @homeTitle. + /// + /// In de, this message translates to: + /// **'Was kochen wir heute?'** + String get homeTitle; + + /// No description provided for @greetingMorning. + /// + /// In de, this message translates to: + /// **'Guten Morgen'** + String get greetingMorning; + + /// No description provided for @greetingDay. + /// + /// In de, this message translates to: + /// **'Guten Tag'** + String get greetingDay; + + /// No description provided for @greetingEvening. + /// + /// In de, this message translates to: + /// **'Guten Abend'** + String get greetingEvening; + + /// No description provided for @greetingLunch. + /// + /// In de, this message translates to: + /// **'Mahlzeit!'** + String get greetingLunch; + + /// No description provided for @greetingLate. + /// + /// In de, this message translates to: + /// **'Schönen Abend'** + String get greetingLate; + + /// No description provided for @suggestionLabel. + /// + /// In de, this message translates to: + /// **'Idee für heute'** + String get suggestionLabel; + + /// No description provided for @anotherIdea. + /// + /// In de, this message translates to: + /// **'Andere Idee'** + String get anotherIdea; + + /// No description provided for @decisionButton. + /// + /// In de, this message translates to: + /// **'Hilf mir entscheiden'** + String get decisionButton; + + /// No description provided for @decisionCourseQuestion. + /// + /// In de, this message translates to: + /// **'Worauf hast du Lust?'** + String get decisionCourseQuestion; + + /// No description provided for @decisionCourseHint. + /// + /// In de, this message translates to: + /// **'Mehrere möglich – nichts gewählt heißt egal.'** + String get decisionCourseHint; + + /// No description provided for @decisionTimeQuestion. + /// + /// In de, this message translates to: + /// **'Wie viel Zeit hast du?'** + String get decisionTimeQuestion; + + /// No description provided for @decisionUpTo30. + /// + /// In de, this message translates to: + /// **'bis 30 Min.'** + String get decisionUpTo30; + + /// No description provided for @decisionUpTo60. + /// + /// In de, this message translates to: + /// **'bis 60 Min.'** + String get decisionUpTo60; + + /// No description provided for @decisionAnyTime. + /// + /// In de, this message translates to: + /// **'Egal'** + String get decisionAnyTime; + + /// No description provided for @decisionNext. + /// + /// In de, this message translates to: + /// **'Weiter'** + String get decisionNext; + + /// No description provided for @decisionShow. + /// + /// In de, this message translates to: + /// **'Vorschlag zeigen'** + String get decisionShow; + + /// No description provided for @decisionMatches. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{1 Rezept passt} other{{count} Rezepte passen}}'** + String decisionMatches(int count); + + /// No description provided for @decisionAnother. + /// + /// In de, this message translates to: + /// **'Noch einer'** + String get decisionAnother; + + /// No description provided for @decisionOpen. + /// + /// In de, this message translates to: + /// **'Rezept öffnen'** + String get decisionOpen; + + /// No description provided for @decisionStartOver. + /// + /// In de, this message translates to: + /// **'Neu wählen'** + String get decisionStartOver; + + /// No description provided for @decisionNoneTitle. + /// + /// In de, this message translates to: + /// **'Dazu passt leider nichts.'** + String get decisionNoneTitle; + + /// No description provided for @decisionWithoutTime. + /// + /// In de, this message translates to: + /// **'Ohne Zeitlimit: {count}'** + String decisionWithoutTime(int count); + + /// No description provided for @decisionAllCourses. + /// + /// In de, this message translates to: + /// **'Alle Gänge: {count}'** + String decisionAllCourses(int count); + + /// No description provided for @reviewBanner. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{1 Rezept wartet auf deinen Blick} other{{count} Rezepte warten auf deinen Blick}}'** + String reviewBanner(int count); + + /// No description provided for @reviewBannerActive. + /// + /// In de, this message translates to: + /// **'Nur Rezepte zum Prüfen – alle zeigen'** + String get reviewBannerActive; + + /// No description provided for @filterAll. + /// + /// In de, this message translates to: + /// **'Alle'** + String get filterAll; + + /// No description provided for @navRecipes. + /// + /// In de, this message translates to: + /// **'Rezepte'** + String get navRecipes; + + /// No description provided for @navWeekPlan. + /// + /// In de, this message translates to: + /// **'Wochenplan'** + String get navWeekPlan; + + /// No description provided for @durationMinutes. + /// + /// In de, this message translates to: + /// **'{minutes} Min.'** + String durationMinutes(int minutes); + + /// No description provided for @durationHours. + /// + /// In de, this message translates to: + /// **'{hours} Std.'** + String durationHours(int hours); + + /// No description provided for @factTotal. + /// + /// In de, this message translates to: + /// **'Gesamt'** + String get factTotal; + + /// No description provided for @factServings. + /// + /// In de, this message translates to: + /// **'Portionen'** + String get factServings; + + /// No description provided for @factCalories. + /// + /// In de, this message translates to: + /// **'Kalorien'** + String get factCalories; + + /// No description provided for @kcalValue. + /// + /// In de, this message translates to: + /// **'{kcal} kcal'** + String kcalValue(int kcal); + + /// No description provided for @tabIngredients. + /// + /// In de, this message translates to: + /// **'Zutaten · {count}'** + String tabIngredients(int count); + + /// No description provided for @tabSteps. + /// + /// In de, this message translates to: + /// **'Zubereitung · {count}'** + String tabSteps(int count); + + /// No description provided for @back. + /// + /// In de, this message translates to: + /// **'Zurück'** + String get back; + + /// No description provided for @cookStart. + /// + /// In de, this message translates to: + /// **'Kochmodus starten'** + String get cookStart; + + /// No description provided for @cookClose. + /// + /// In de, this message translates to: + /// **'Kochmodus beenden'** + String get cookClose; + + /// No description provided for @cookReadyTitle. + /// + /// In de, this message translates to: + /// **'Bereit zum Kochen?'** + String get cookReadyTitle; + + /// No description provided for @cookReadyBody. + /// + /// In de, this message translates to: + /// **'Leg dir alles bereit – abhaken hilft.'** + String get cookReadyBody; + + /// No description provided for @cookLetsGo. + /// + /// In de, this message translates to: + /// **'Los geht’s'** + String get cookLetsGo; + + /// No description provided for @cookStepOf. + /// + /// In de, this message translates to: + /// **'Schritt {number} von {total}'** + String cookStepOf(int number, int total); + + /// No description provided for @cookNext. + /// + /// In de, this message translates to: + /// **'Weiter'** + String get cookNext; + + /// No description provided for @cookPrevious. + /// + /// In de, this message translates to: + /// **'Zurück'** + String get cookPrevious; + + /// No description provided for @cookFinish. + /// + /// In de, this message translates to: + /// **'Fertig'** + String get cookFinish; + + /// No description provided for @cookDoneTitle. + /// + /// In de, this message translates to: + /// **'Guten Appetit!'** + String get cookDoneTitle; + + /// No description provided for @cookDoneBody. + /// + /// In de, this message translates to: + /// **'Alle Schritte geschafft. Lass es dir schmecken.'** + String get cookDoneBody; + + /// No description provided for @cookBackToRecipe. + /// + /// In de, this message translates to: + /// **'Zurück zum Rezept'** + String get cookBackToRecipe; + + /// No description provided for @cookNoSteps. + /// + /// In de, this message translates to: + /// **'Für dieses Rezept ist keine Zubereitung eingetragen.'** + String get cookNoSteps; + + /// No description provided for @servingsFewer. + /// + /// In de, this message translates to: + /// **'Weniger Portionen'** + String get servingsFewer; + + /// No description provided for @servingsMore. + /// + /// In de, this message translates to: + /// **'Mehr Portionen'** + String get servingsMore; + + /// No description provided for @shareIngredients. + /// + /// In de, this message translates to: + /// **'Zutaten teilen'** + String get shareIngredients; + + /// No description provided for @shareIngredientsHint. + /// + /// In de, this message translates to: + /// **'Jede Zutat steht in einer eigenen Zeile – so kann z. B. Bring! sie als einzelne Artikel übernehmen.'** + String get shareIngredientsHint; + + /// No description provided for @shareIngredientsSubject. + /// + /// In de, this message translates to: + /// **'{name} – Zutaten'** + String shareIngredientsSubject(String name); + + /// No description provided for @shareIngredientsSubjectServings. + /// + /// In de, this message translates to: + /// **'{name} – Zutaten für {servings} Portionen'** + String shareIngredientsSubjectServings(String name, String servings); + + /// No description provided for @share. + /// + /// In de, this message translates to: + /// **'Teilen'** + String get share; + + /// No description provided for @shareRecipe. + /// + /// In de, this message translates to: + /// **'Rezept teilen'** + String get shareRecipe; + + /// No description provided for @shareRecipeHint. + /// + /// In de, this message translates to: + /// **'Auf dem anderen Handy in CookIt oben auf „Rezept scannen“ tippen und diesen Code scannen – ganz ohne Internet. Fotos reisen nur als Datei mit.'** + String get shareRecipeHint; + + /// No description provided for @includeNotes. + /// + /// In de, this message translates to: + /// **'Notizen mitschicken'** + String get includeNotes; + + /// No description provided for @shareAsFile. + /// + /// In de, this message translates to: + /// **'Als Datei teilen'** + String get shareAsFile; + + /// No description provided for @shareAsFileHint. + /// + /// In de, this message translates to: + /// **'Mit Fotos – z. B. über Quick Share, auch ohne Internet.'** + String get shareAsFileHint; + + /// No description provided for @codeTooLong. + /// + /// In de, this message translates to: + /// **'Dieses Rezept ist zu lang für einen Code. Teile es als Datei.'** + String get codeTooLong; + + /// No description provided for @scanRecipe. + /// + /// In de, this message translates to: + /// **'Rezept scannen'** + String get scanRecipe; + + /// No description provided for @scanHint. + /// + /// In de, this message translates to: + /// **'Halte die Kamera auf den Rezept-Code.'** + String get scanHint; + + /// No description provided for @notARecipeCode. + /// + /// In de, this message translates to: + /// **'Das ist kein CookIt-Rezept-Code.'** + String get notARecipeCode; + + /// No description provided for @codeFromNewerVersion. + /// + /// In de, this message translates to: + /// **'Dieser Code stammt aus einer neueren CookIt-Version. Bitte CookIt aktualisieren.'** + String get codeFromNewerVersion; + + /// No description provided for @codeDamaged. + /// + /// In de, this message translates to: + /// **'Der Code ließ sich nicht lesen. Versuch es noch einmal.'** + String get codeDamaged; + + /// No description provided for @cameraUnavailable. + /// + /// In de, this message translates to: + /// **'Die Kamera ist nicht verfügbar. Erlaube CookIt den Kamerazugriff in den Android-Einstellungen.'** + String get cameraUnavailable; + + /// No description provided for @receivedRecipe. + /// + /// In de, this message translates to: + /// **'Rezept empfangen'** + String get receivedRecipe; + + /// No description provided for @recipeNameTaken. + /// + /// In de, this message translates to: + /// **'Du hast schon ein Rezept „{name}“. Beim Speichern kommt dieses als eigenes dazu.'** + String recipeNameTaken(String name); + + /// No description provided for @recipeSaved. + /// + /// In de, this message translates to: + /// **'„{name}“ gespeichert'** + String recipeSaved(String name); + + /// No description provided for @fileUnreadable. + /// + /// In de, this message translates to: + /// **'Die Datei ließ sich nicht öffnen: {message}'** + String fileUnreadable(String message); + + /// No description provided for @plannedToday. + /// + /// In de, this message translates to: + /// **'Heute geplant'** + String get plannedToday; + + /// No description provided for @plannedTodayMore. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{+1 weiterer Eintrag} other{+{count} weitere Einträge}}'** + String plannedTodayMore(int count); + + /// No description provided for @showIdea. + /// + /// In de, this message translates to: + /// **'Lieber eine Idee'** + String get showIdea; + + /// No description provided for @weekPlanTitle. + /// + /// In de, this message translates to: + /// **'Wochenplan'** + String get weekPlanTitle; + + /// No description provided for @jumpToToday. + /// + /// In de, this message translates to: + /// **'Heute'** + String get jumpToToday; + + /// No description provided for @thisWeek. + /// + /// In de, this message translates to: + /// **'Diese Woche'** + String get thisWeek; + + /// No description provided for @nextWeek. + /// + /// In de, this message translates to: + /// **'Nächste Woche'** + String get nextWeek; + + /// No description provided for @lastWeek. + /// + /// In de, this message translates to: + /// **'Letzte Woche'** + String get lastWeek; + + /// No description provided for @calendarWeek. + /// + /// In de, this message translates to: + /// **'KW {number}'** + String calendarWeek(int number); + + /// No description provided for @todayMarker. + /// + /// In de, this message translates to: + /// **'heute'** + String get todayMarker; + + /// No description provided for @previousWeek. + /// + /// In de, this message translates to: + /// **'Vorige Woche'** + String get previousWeek; + + /// No description provided for @dropToRemove. + /// + /// In de, this message translates to: + /// **'Hier ablegen zum Entfernen'** + String get dropToRemove; + + /// No description provided for @dropAdd. + /// + /// In de, this message translates to: + /// **'+ dazulegen'** + String get dropAdd; + + /// No description provided for @cook. + /// + /// In de, this message translates to: + /// **'Kochen'** + String get cook; + + /// No description provided for @todayBanner. + /// + /// In de, this message translates to: + /// **'Heute · {weekday}'** + String todayBanner(String weekday); + + /// No description provided for @addAnother. + /// + /// In de, this message translates to: + /// **'Weiteres hinzufügen'** + String get addAnother; + + /// No description provided for @entryRemoved. + /// + /// In de, this message translates to: + /// **'„{name}“ entfernt'** + String entryRemoved(String name); + + /// No description provided for @undo. + /// + /// In de, this message translates to: + /// **'Rückgängig'** + String get undo; + + /// No description provided for @entryEdit. + /// + /// In de, this message translates to: + /// **'Bearbeiten'** + String get entryEdit; + + /// No description provided for @editNoteTitle. + /// + /// In de, this message translates to: + /// **'Notiz bearbeiten'** + String get editNoteTitle; + + /// No description provided for @copy. + /// + /// In de, this message translates to: + /// **'Kopieren'** + String get copy; + + /// No description provided for @copied. + /// + /// In de, this message translates to: + /// **'In die Zwischenablage kopiert'** + String get copied; + + /// No description provided for @shareMenu. + /// + /// In de, this message translates to: + /// **'Teilen'** + String get shareMenu; + + /// No description provided for @shareWeekPlan. + /// + /// In de, this message translates to: + /// **'Wochenplan teilen'** + String get shareWeekPlan; + + /// No description provided for @shareWeekPlanHint. + /// + /// In de, this message translates to: + /// **'Als Datei an eine andere CookIt-App – mit allen Rezepten und Fotos.'** + String get shareWeekPlanHint; + + /// No description provided for @shareWeekIngredientsHint. + /// + /// In de, this message translates to: + /// **'Zusammengezählt, z. B. für die Einkaufsliste.'** + String get shareWeekIngredientsHint; + + /// No description provided for @weekPlanFileName. + /// + /// In de, this message translates to: + /// **'Wochenplan {range}'** + String weekPlanFileName(String range); + + /// No description provided for @receivedWeek. + /// + /// In de, this message translates to: + /// **'Wochenplan empfangen'** + String get receivedWeek; + + /// No description provided for @receivedWeekEntries. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{1 Eintrag} other{{count} Einträge}}'** + String receivedWeekEntries(int count); + + /// No description provided for @receivedWeekNewRecipes. + /// + /// In de, this message translates to: + /// **'{count, plural, =0{keine neuen Rezepte} =1{1 neues Rezept} other{{count} neue Rezepte}}'** + String receivedWeekNewRecipes(int count); + + /// No description provided for @receivedWeekExisting. + /// + /// In de, this message translates to: + /// **'{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.}}'** + String receivedWeekExisting(int count); + + /// No description provided for @adopt. + /// + /// In de, this message translates to: + /// **'Übernehmen'** + String get adopt; + + /// No description provided for @weekAdopted. + /// + /// In de, this message translates to: + /// **'Wochenplan {range} übernommen'** + String weekAdopted(String range); + + /// No description provided for @addEntry. + /// + /// In de, this message translates to: + /// **'Eintrag hinzufügen'** + String get addEntry; + + /// No description provided for @entryReroll. + /// + /// In de, this message translates to: + /// **'Neu würfeln'** + String get entryReroll; + + /// No description provided for @entryMove. + /// + /// In de, this message translates to: + /// **'Verschieben …'** + String get entryMove; + + /// No description provided for @entryRemove. + /// + /// In de, this message translates to: + /// **'Entfernen'** + String get entryRemove; + + /// No description provided for @noOtherRecipe. + /// + /// In de, this message translates to: + /// **'Kein anderes passendes Rezept gefunden.'** + String get noOtherRecipe; + + /// No description provided for @addEntryTitle. + /// + /// In de, this message translates to: + /// **'{meal} · {day}'** + String addEntryTitle(String meal, String day); + + /// No description provided for @addEntryHint. + /// + /// In de, this message translates to: + /// **'Rezept suchen oder Notiz schreiben'** + String get addEntryHint; + + /// No description provided for @addAsNote. + /// + /// In de, this message translates to: + /// **'Als Notiz: „{text}“'** + String addAsNote(String text); + + /// No description provided for @planRecipe. + /// + /// In de, this message translates to: + /// **'Einplanen'** + String get planRecipe; + + /// No description provided for @planWhichDay. + /// + /// In de, this message translates to: + /// **'An welchem Tag?'** + String get planWhichDay; + + /// No description provided for @planWhichMeal. + /// + /// In de, this message translates to: + /// **'Zu welcher Mahlzeit?'** + String get planWhichMeal; + + /// No description provided for @planOtherDate. + /// + /// In de, this message translates to: + /// **'Anderes Datum …'** + String get planOtherDate; + + /// No description provided for @tomorrow. + /// + /// In de, this message translates to: + /// **'Morgen'** + String get tomorrow; + + /// No description provided for @planned. + /// + /// In de, this message translates to: + /// **'Eingeplant: {day} · {meal}'** + String planned(String day, String meal); + + /// No description provided for @moveEntryTitle. + /// + /// In de, this message translates to: + /// **'Verschieben'** + String get moveEntryTitle; + + /// No description provided for @noMeals. + /// + /// In de, this message translates to: + /// **'Lege zuerst in den Einstellungen eine Mahlzeit an.'** + String get noMeals; + + /// No description provided for @fillWeek. + /// + /// In de, this message translates to: + /// **'Woche füllen'** + String get fillWeek; + + /// No description provided for @fillWeekHint. + /// + /// In de, this message translates to: + /// **'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.'** + String get fillWeekHint; + + /// No description provided for @fillWhichMeal. + /// + /// In de, this message translates to: + /// **'Welche Mahlzeit?'** + String get fillWhichMeal; + + /// No description provided for @fillAction. + /// + /// In de, this message translates to: + /// **'Füllen'** + String get fillAction; + + /// No description provided for @fillResult. + /// + /// In de, this message translates to: + /// **'{count, plural, =0{Keine leeren Tage gefüllt.} =1{1 Tag gefüllt.} other{{count} Tage gefüllt.}}'** + String fillResult(int count); + + /// No description provided for @fillLeftEmpty. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{1 Tag bleibt leer – keine passenden Rezepte mehr.} other{{count} Tage bleiben leer – keine passenden Rezepte mehr.}}'** + String fillLeftEmpty(int count); + + /// No description provided for @fillNothingEmpty. + /// + /// In de, this message translates to: + /// **'An den Tagen ab heute ist schon alles geplant.'** + String get fillNothingEmpty; + + /// No description provided for @shareWeek. + /// + /// In de, this message translates to: + /// **'Zutaten der Woche teilen'** + String get shareWeek; + + /// No description provided for @shareWeekHint. + /// + /// In de, this message translates to: + /// **'Gleiche Zutaten werden zusammengezählt. Jedes Rezept zählt mit seinen eigenen Portionen.'** + String get shareWeekHint; + + /// No description provided for @shareWeekSubject. + /// + /// In de, this message translates to: + /// **'Zutaten für {range}'** + String shareWeekSubject(String range); + + /// No description provided for @shareWeekEmpty. + /// + /// In de, this message translates to: + /// **'An den gewählten Tagen ist kein Rezept geplant.'** + String get shareWeekEmpty; + + /// No description provided for @settingsSectionPlanning. + /// + /// In de, this message translates to: + /// **'Wochenplan'** + String get settingsSectionPlanning; + + /// No description provided for @settingsMeals. + /// + /// In de, this message translates to: + /// **'Mahlzeiten bearbeiten'** + String get settingsMeals; + + /// No description provided for @settingsMealsSubtitle. + /// + /// In de, this message translates to: + /// **'z. B. Frühstück, Mittag, Abend'** + String get settingsMealsSubtitle; + + /// No description provided for @settingsWeekStart. + /// + /// In de, this message translates to: + /// **'Woche beginnt am'** + String get settingsWeekStart; + + /// No description provided for @mealsTitle. + /// + /// In de, this message translates to: + /// **'Mahlzeiten'** + String get mealsTitle; + + /// No description provided for @addMeal. + /// + /// In de, this message translates to: + /// **'Mahlzeit hinzufügen'** + String get addMeal; + + /// No description provided for @editMeal. + /// + /// In de, this message translates to: + /// **'Mahlzeit umbenennen'** + String get editMeal; + + /// No description provided for @mealNameTaken. + /// + /// In de, this message translates to: + /// **'Diese Mahlzeit gibt es schon.'** + String get mealNameTaken; + + /// No description provided for @deleteMealTitle. + /// + /// In de, this message translates to: + /// **'Mahlzeit löschen?'** + String get deleteMealTitle; + + /// No description provided for @deleteMealBody. + /// + /// In de, this message translates to: + /// **'{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.'** + String deleteMealBody(String name, int count); + + /// No description provided for @more. + /// + /// In de, this message translates to: + /// **'Mehr'** + String get more; + + /// No description provided for @filterFavourites. + /// + /// In de, this message translates to: + /// **'Favoriten'** + String get filterFavourites; + + /// No description provided for @filterReview. + /// + /// In de, this message translates to: + /// **'Zu prüfen ({count})'** + String filterReview(int count); + + /// No description provided for @newRecipe. + /// + /// In de, this message translates to: + /// **'Neues Rezept'** + String get newRecipe; + + /// No description provided for @settings. + /// + /// In de, this message translates to: + /// **'Einstellungen'** + String get settings; + + /// No description provided for @emptyCollectionTitle. + /// + /// In de, this message translates to: + /// **'Noch keine Rezepte'** + String get emptyCollectionTitle; + + /// No description provided for @emptyCollectionBody. + /// + /// In de, this message translates to: + /// **'Leg dein erstes Rezept an oder importiere ein Rezeptpaket in den Einstellungen.'** + String get emptyCollectionBody; + + /// No description provided for @noMatchesTitle. + /// + /// In de, this message translates to: + /// **'Nichts gefunden'** + String get noMatchesTitle; + + /// No description provided for @noMatchesBody. + /// + /// In de, this message translates to: + /// **'Versuch es mit einem anderen Suchbegriff oder weniger Filtern.'** + String get noMatchesBody; + + /// No description provided for @resetFilters. + /// + /// In de, this message translates to: + /// **'Filter zurücksetzen'** + String get resetFilters; + + /// No description provided for @recipeCount. + /// + /// In de, this message translates to: + /// **'{count, plural, =1{1 Rezept} other{{count} Rezepte}}'** + String recipeCount(int count); + + /// No description provided for @favourite. + /// + /// In de, this message translates to: + /// **'Favorit'** + String get favourite; + + /// No description provided for @removeFavourite. + /// + /// In de, this message translates to: + /// **'Kein Favorit mehr'** + String get removeFavourite; + + /// No description provided for @edit. + /// + /// In de, this message translates to: + /// **'Bearbeiten'** + String get edit; + + /// No description provided for @delete. + /// + /// In de, this message translates to: + /// **'Löschen'** + String get delete; + + /// No description provided for @cancel. + /// + /// In de, this message translates to: + /// **'Abbrechen'** + String get cancel; + + /// No description provided for @save. + /// + /// In de, this message translates to: + /// **'Speichern'** + String get save; + + /// No description provided for @deleteRecipeTitle. + /// + /// In de, this message translates to: + /// **'Rezept löschen?'** + String get deleteRecipeTitle; + + /// No description provided for @deleteRecipeBody. + /// + /// In de, this message translates to: + /// **'„{name}“ und seine Fotos werden endgültig gelöscht.'** + String deleteRecipeBody(String name); + + /// No description provided for @recipeNotFound. + /// + /// In de, this message translates to: + /// **'Dieses Rezept gibt es nicht mehr.'** + String get recipeNotFound; + + /// No description provided for @reviewTitle. + /// + /// In de, this message translates to: + /// **'Bitte prüfen'** + String get reviewTitle; + + /// No description provided for @reviewDone. + /// + /// In de, this message translates to: + /// **'Erledigt'** + String get reviewDone; + + /// No description provided for @servings. + /// + /// In de, this message translates to: + /// **'Portionen'** + String get servings; + + /// No description provided for @servingsOne. + /// + /// In de, this message translates to: + /// **'1 Portion'** + String get servingsOne; + + /// No description provided for @servingsValue. + /// + /// In de, this message translates to: + /// **'{value} Portionen'** + String servingsValue(String value); + + /// No description provided for @minutesValue. + /// + /// In de, this message translates to: + /// **'{value} Min.'** + String minutesValue(int value); + + /// No description provided for @prepTime. + /// + /// In de, this message translates to: + /// **'Vorbereitung'** + String get prepTime; + + /// No description provided for @cookTime. + /// + /// In de, this message translates to: + /// **'Kochen'** + String get cookTime; + + /// No description provided for @totalTime. + /// + /// In de, this message translates to: + /// **'Gesamt'** + String get totalTime; + + /// No description provided for @caloriesPerServing. + /// + /// In de, this message translates to: + /// **'{kcal} kcal pro Portion'** + String caloriesPerServing(int kcal); + + /// No description provided for @caloriesWholeRecipe. + /// + /// In de, this message translates to: + /// **'{kcal} kcal gesamt'** + String caloriesWholeRecipe(int kcal); + + /// No description provided for @ingredients. + /// + /// In de, this message translates to: + /// **'Zutaten'** + String get ingredients; + + /// No description provided for @steps. + /// + /// In de, this message translates to: + /// **'Zubereitung'** + String get steps; + + /// No description provided for @notes. + /// + /// In de, this message translates to: + /// **'Notizen'** + String get notes; + + /// No description provided for @sources. + /// + /// In de, this message translates to: + /// **'Quellen'** + String get sources; + + /// No description provided for @tags. + /// + /// In de, this message translates to: + /// **'Stichwörter'** + String get tags; + + /// No description provided for @noIngredients. + /// + /// In de, this message translates to: + /// **'Keine Zutaten eingetragen.'** + String get noIngredients; + + /// No description provided for @noSteps. + /// + /// In de, this message translates to: + /// **'Keine Zubereitung eingetragen.'** + String get noSteps; + + /// No description provided for @editRecipe. + /// + /// In de, this message translates to: + /// **'Rezept bearbeiten'** + String get editRecipe; + + /// No description provided for @discardTitle. + /// + /// In de, this message translates to: + /// **'Änderungen verwerfen?'** + String get discardTitle; + + /// No description provided for @discardBody. + /// + /// In de, this message translates to: + /// **'Deine Änderungen an diesem Rezept gehen verloren.'** + String get discardBody; + + /// No description provided for @discard. + /// + /// In de, this message translates to: + /// **'Verwerfen'** + String get discard; + + /// No description provided for @keepEditing. + /// + /// In de, this message translates to: + /// **'Weiter bearbeiten'** + String get keepEditing; + + /// No description provided for @fieldName. + /// + /// In de, this message translates to: + /// **'Name'** + String get fieldName; + + /// No description provided for @fieldNameRequired. + /// + /// In de, this message translates to: + /// **'Bitte gib dem Rezept einen Namen.'** + String get fieldNameRequired; + + /// No description provided for @fieldCourse. + /// + /// In de, this message translates to: + /// **'Gang'** + String get fieldCourse; + + /// No description provided for @noCourse. + /// + /// In de, this message translates to: + /// **'Kein Gang'** + String get noCourse; + + /// No description provided for @fieldServings. + /// + /// In de, this message translates to: + /// **'Portionen'** + String get fieldServings; + + /// No description provided for @fieldPrep. + /// + /// In de, this message translates to: + /// **'Vorbereitung'** + String get fieldPrep; + + /// No description provided for @fieldCook. + /// + /// In de, this message translates to: + /// **'Kochen'** + String get fieldCook; + + /// No description provided for @minutesSuffix. + /// + /// In de, this message translates to: + /// **'Min.'** + String get minutesSuffix; + + /// No description provided for @fieldKcal. + /// + /// In de, this message translates to: + /// **'Kalorien (kcal)'** + String get fieldKcal; + + /// No description provided for @basisPerServing. + /// + /// In de, this message translates to: + /// **'pro Portion'** + String get basisPerServing; + + /// No description provided for @basisWholeRecipe. + /// + /// In de, this message translates to: + /// **'gesamt'** + String get basisWholeRecipe; + + /// No description provided for @fieldTags. + /// + /// In de, this message translates to: + /// **'Stichwörter'** + String get fieldTags; + + /// No description provided for @fieldTagsHint. + /// + /// In de, this message translates to: + /// **'durch Komma getrennt, z. B. vegan, schnell'** + String get fieldTagsHint; + + /// No description provided for @fieldNotes. + /// + /// In de, this message translates to: + /// **'Notizen'** + String get fieldNotes; + + /// No description provided for @fieldSources. + /// + /// In de, this message translates to: + /// **'Quellen'** + String get fieldSources; + + /// No description provided for @fieldSourcesHint. + /// + /// In de, this message translates to: + /// **'ein Link pro Zeile'** + String get fieldSourcesHint; + + /// No description provided for @invalidNumber. + /// + /// In de, this message translates to: + /// **'Keine gültige Zahl'** + String get invalidNumber; + + /// No description provided for @photos. + /// + /// In de, this message translates to: + /// **'Fotos'** + String get photos; + + /// No description provided for @addPhoto. + /// + /// In de, this message translates to: + /// **'Foto hinzufügen'** + String get addPhoto; + + /// No description provided for @photoFromCamera. + /// + /// In de, this message translates to: + /// **'Kamera'** + String get photoFromCamera; + + /// No description provided for @photoFromGallery. + /// + /// In de, this message translates to: + /// **'Galerie'** + String get photoFromGallery; + + /// No description provided for @mainPhoto. + /// + /// In de, this message translates to: + /// **'Hauptfoto'** + String get mainPhoto; + + /// No description provided for @setMainPhoto. + /// + /// In de, this message translates to: + /// **'Als Hauptfoto festlegen'** + String get setMainPhoto; + + /// No description provided for @photoHint. + /// + /// In de, this message translates to: + /// **'Gedrückt halten und ziehen zum Sortieren – das erste Foto ist das Hauptfoto. Antippen für mehr.'** + String get photoHint; + + /// No description provided for @removePhoto. + /// + /// In de, this message translates to: + /// **'Foto entfernen'** + String get removePhoto; + + /// No description provided for @addIngredient. + /// + /// In de, this message translates to: + /// **'Zutat'** + String get addIngredient; + + /// No description provided for @addIngredientGroup. + /// + /// In de, this message translates to: + /// **'Gruppe'** + String get addIngredientGroup; + + /// No description provided for @ingredientAmount. + /// + /// In de, this message translates to: + /// **'Menge'** + String get ingredientAmount; + + /// No description provided for @ingredientUnit. + /// + /// In de, this message translates to: + /// **'Einheit'** + String get ingredientUnit; + + /// No description provided for @ingredientName. + /// + /// In de, this message translates to: + /// **'Zutat'** + String get ingredientName; + + /// No description provided for @ingredientRemark. + /// + /// In de, this message translates to: + /// **'Hinweis, z. B. gehackt'** + String get ingredientRemark; + + /// No description provided for @addRemark. + /// + /// In de, this message translates to: + /// **'Hinweis hinzufügen'** + String get addRemark; + + /// No description provided for @invalidAmount. + /// + /// In de, this message translates to: + /// **'z. B. 2, 1,5, ½ oder 2-3'** + String get invalidAmount; + + /// No description provided for @groupName. + /// + /// In de, this message translates to: + /// **'Gruppenname (optional)'** + String get groupName; + + /// No description provided for @noUnit. + /// + /// In de, this message translates to: + /// **'–'** + String get noUnit; + + /// No description provided for @addStep. + /// + /// In de, this message translates to: + /// **'Schritt'** + String get addStep; + + /// No description provided for @addStepSection. + /// + /// In de, this message translates to: + /// **'Abschnitt'** + String get addStepSection; + + /// No description provided for @stepText. + /// + /// In de, this message translates to: + /// **'Schritt {number}'** + String stepText(int number); + + /// No description provided for @sectionTitle. + /// + /// In de, this message translates to: + /// **'Abschnitt, z. B. Teig'** + String get sectionTitle; + + /// No description provided for @removeItem. + /// + /// In de, this message translates to: + /// **'Entfernen'** + String get removeItem; + + /// No description provided for @unnamedGroup. + /// + /// In de, this message translates to: + /// **'Weitere Zutaten'** + String get unnamedGroup; + + /// No description provided for @settingsCourses. + /// + /// In de, this message translates to: + /// **'Gänge bearbeiten'** + String get settingsCourses; + + /// No description provided for @settingsCoursesSubtitle. + /// + /// In de, this message translates to: + /// **'Hinzufügen, umbenennen, sortieren'** + String get settingsCoursesSubtitle; + + /// No description provided for @settingsImport. + /// + /// In de, this message translates to: + /// **'Rezepte importieren'** + String get settingsImport; + + /// No description provided for @settingsImportSubtitle. + /// + /// In de, this message translates to: + /// **'Rezeptpaket (.zip) laden'** + String get settingsImportSubtitle; + + /// No description provided for @settingsExport. + /// + /// In de, this message translates to: + /// **'Sammlung exportieren'** + String get settingsExport; + + /// No description provided for @settingsExportSubtitle. + /// + /// In de, this message translates to: + /// **'Alle Rezepte mit Fotos als Backup speichern'** + String get settingsExportSubtitle; + + /// No description provided for @settingsSectionCollection. + /// + /// In de, this message translates to: + /// **'Sammlung'** + String get settingsSectionCollection; + + /// No description provided for @settingsSectionData. + /// + /// In de, this message translates to: + /// **'Daten'** + String get settingsSectionData; + + /// No description provided for @settingsSectionAppearance. + /// + /// In de, this message translates to: + /// **'Darstellung'** + String get settingsSectionAppearance; + + /// No description provided for @themeLight. + /// + /// In de, this message translates to: + /// **'Hell'** + String get themeLight; + + /// No description provided for @themeDark. + /// + /// In de, this message translates to: + /// **'Dunkel'** + String get themeDark; + + /// No description provided for @themeSystem. + /// + /// In de, this message translates to: + /// **'System'** + String get themeSystem; + + /// No description provided for @importing. + /// + /// In de, this message translates to: + /// **'Rezepte werden importiert …'** + String get importing; + + /// No description provided for @importResult. + /// + /// In de, this message translates to: + /// **'{added} neu, {skipped} übersprungen'** + String importResult(int added, int skipped); + + /// No description provided for @importFailed. + /// + /// In de, this message translates to: + /// **'Import fehlgeschlagen: {message}'** + String importFailed(String message); + + /// No description provided for @exporting. + /// + /// In de, this message translates to: + /// **'Sammlung wird exportiert …'** + String get exporting; + + /// No description provided for @exportDone. + /// + /// In de, this message translates to: + /// **'Sammlung gespeichert.'** + String get exportDone; + + /// No description provided for @exportFailed. + /// + /// In de, this message translates to: + /// **'Export fehlgeschlagen: {message}'** + String exportFailed(String message); + + /// No description provided for @settingsUnits. + /// + /// In de, this message translates to: + /// **'Maßeinheiten bearbeiten'** + String get settingsUnits; + + /// No description provided for @settingsUnitsSubtitle. + /// + /// In de, this message translates to: + /// **'Eigene hinzufügen, sortieren, direkt anzeigen'** + String get settingsUnitsSubtitle; + + /// No description provided for @unitsTitle. + /// + /// In de, this message translates to: + /// **'Maßeinheiten'** + String get unitsTitle; + + /// No description provided for @unitsDirectHint. + /// + /// In de, this message translates to: + /// **'Eingeschaltete Einheiten erscheinen direkt in der Auswahl, die anderen unter „Weitere Einheiten“. Ziehen zum Sortieren.'** + String get unitsDirectHint; + + /// No description provided for @addUnit. + /// + /// In de, this message translates to: + /// **'Einheit hinzufügen'** + String get addUnit; + + /// No description provided for @editUnit. + /// + /// In de, this message translates to: + /// **'Einheit umbenennen'** + String get editUnit; + + /// No description provided for @unitName. + /// + /// In de, this message translates to: + /// **'Name'** + String get unitName; + + /// No description provided for @ownUnit. + /// + /// In de, this message translates to: + /// **'Eigene Einheit'** + String get ownUnit; + + /// No description provided for @unitNameTaken. + /// + /// In de, this message translates to: + /// **'Diese Einheit gibt es schon.'** + String get unitNameTaken; + + /// No description provided for @showDirectly. + /// + /// In de, this message translates to: + /// **'Direkt anzeigen'** + String get showDirectly; + + /// No description provided for @deleteUnitTitle. + /// + /// In de, this message translates to: + /// **'Einheit löschen?'** + String get deleteUnitTitle; + + /// No description provided for @deleteUnitBody. + /// + /// In de, this message translates to: + /// **'{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.}}'** + String deleteUnitBody(String name, int count); + + /// No description provided for @coursesTitle. + /// + /// In de, this message translates to: + /// **'Gänge'** + String get coursesTitle; + + /// No description provided for @addCourse. + /// + /// In de, this message translates to: + /// **'Gang hinzufügen'** + String get addCourse; + + /// No description provided for @editCourse. + /// + /// In de, this message translates to: + /// **'Gang bearbeiten'** + String get editCourse; + + /// No description provided for @courseName. + /// + /// In de, this message translates to: + /// **'Name'** + String get courseName; + + /// No description provided for @courseEmoji. + /// + /// In de, this message translates to: + /// **'Emoji (optional)'** + String get courseEmoji; + + /// No description provided for @courseNameTaken. + /// + /// In de, this message translates to: + /// **'Diesen Gang gibt es schon.'** + String get courseNameTaken; + + /// No description provided for @deleteCourseTitle. + /// + /// In de, this message translates to: + /// **'Gang löschen?'** + String get deleteCourseTitle; + + /// No description provided for @deleteCourseBody. + /// + /// In de, this message translates to: + /// **'{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.}}'** + String deleteCourseBody(String name, int count); + + /// No description provided for @unitGram. + /// + /// In de, this message translates to: + /// **'g'** + String get unitGram; + + /// No description provided for @unitKilogram. + /// + /// In de, this message translates to: + /// **'kg'** + String get unitKilogram; + + /// No description provided for @unitMilliliter. + /// + /// In de, this message translates to: + /// **'ml'** + String get unitMilliliter; + + /// No description provided for @unitLiter. + /// + /// In de, this message translates to: + /// **'l'** + String get unitLiter; + + /// No description provided for @unitTablespoon. + /// + /// In de, this message translates to: + /// **'EL'** + String get unitTablespoon; + + /// No description provided for @unitTeaspoon. + /// + /// In de, this message translates to: + /// **'TL'** + String get unitTeaspoon; + + /// No description provided for @unitPinch. + /// + /// In de, this message translates to: + /// **'Prise'** + String get unitPinch; + + /// No description provided for @unitKnifeTip. + /// + /// In de, this message translates to: + /// **'Msp.'** + String get unitKnifeTip; + + /// No description provided for @unitPack. + /// + /// In de, this message translates to: + /// **'Pck.'** + String get unitPack; + + /// No description provided for @unitPiece. + /// + /// In de, this message translates to: + /// **'Stück'** + String get unitPiece; + + /// No description provided for @unitSlice. + /// + /// In de, this message translates to: + /// **'Scheibe(n)'** + String get unitSlice; + + /// No description provided for @unitClove. + /// + /// In de, this message translates to: + /// **'Zehe(n)'** + String get unitClove; + + /// No description provided for @unitSprig. + /// + /// In de, this message translates to: + /// **'Zweig(e)'** + String get unitSprig; + + /// No description provided for @unitBunch. + /// + /// In de, this message translates to: + /// **'Bund'** + String get unitBunch; + + /// No description provided for @unitHandful. + /// + /// In de, this message translates to: + /// **'Handvoll'** + String get unitHandful; + + /// No description provided for @unitCup. + /// + /// In de, this message translates to: + /// **'Tasse(n)'** + String get unitCup; + + /// No description provided for @unitCan. + /// + /// In de, this message translates to: + /// **'Dose'** + String get unitCan; + + /// No description provided for @unitJar. + /// + /// In de, this message translates to: + /// **'Glas'** + String get unitJar; + + /// No description provided for @unitTub. + /// + /// In de, this message translates to: + /// **'Becher'** + String get unitTub; + + /// No description provided for @unitBag. + /// + /// In de, this message translates to: + /// **'Beutel'** + String get unitBag; + + /// No description provided for @unitCarton. + /// + /// In de, this message translates to: + /// **'Karton'** + String get unitCarton; + + /// No description provided for @unitHead. + /// + /// In de, this message translates to: + /// **'Kopf'** + String get unitHead; + + /// No description provided for @unitGrain. + /// + /// In de, this message translates to: + /// **'Korn'** + String get unitGrain; + + /// No description provided for @unitSmall. + /// + /// In de, this message translates to: + /// **'kleine(r)'** + String get unitSmall; + + /// No description provided for @unitMedium. + /// + /// In de, this message translates to: + /// **'mittlere(r)'** + String get unitMedium; + + /// No description provided for @unitLarge. + /// + /// In de, this message translates to: + /// **'große(r)'** + String get unitLarge; + + /// No description provided for @unitSome. + /// + /// In de, this message translates to: + /// **'etwas'** + String get unitSome; + + /// No description provided for @moreUnits. + /// + /// In de, this message translates to: + /// **'Weitere Einheiten …'** + String get moreUnits; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['de'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'de': + return AppLocalizationsDe(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/src/localization/app_localizations_de.dart b/lib/src/localization/app_localizations_de.dart new file mode 100644 index 0000000..146b2fd --- /dev/null +++ b/lib/src/localization/app_localizations_de.dart @@ -0,0 +1,1089 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get appTitle => 'CookIt'; + + @override + String get searchHint => 'Rezept, Zutat oder Stichwort'; + + @override + String get homeTitle => 'Was kochen wir heute?'; + + @override + String get greetingMorning => 'Guten Morgen'; + + @override + String get greetingDay => 'Guten Tag'; + + @override + String get greetingEvening => 'Guten Abend'; + + @override + String get greetingLunch => 'Mahlzeit!'; + + @override + String get greetingLate => 'Schönen Abend'; + + @override + String get suggestionLabel => 'Idee für heute'; + + @override + String get anotherIdea => 'Andere Idee'; + + @override + String get decisionButton => 'Hilf mir entscheiden'; + + @override + String get decisionCourseQuestion => 'Worauf hast du Lust?'; + + @override + String get decisionCourseHint => + 'Mehrere möglich – nichts gewählt heißt egal.'; + + @override + String get decisionTimeQuestion => 'Wie viel Zeit hast du?'; + + @override + String get decisionUpTo30 => 'bis 30 Min.'; + + @override + String get decisionUpTo60 => 'bis 60 Min.'; + + @override + String get decisionAnyTime => 'Egal'; + + @override + String get decisionNext => 'Weiter'; + + @override + String get decisionShow => 'Vorschlag zeigen'; + + @override + String decisionMatches(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Rezepte passen', + one: '1 Rezept passt', + ); + return '$_temp0'; + } + + @override + String get decisionAnother => 'Noch einer'; + + @override + String get decisionOpen => 'Rezept öffnen'; + + @override + String get decisionStartOver => 'Neu wählen'; + + @override + String get decisionNoneTitle => 'Dazu passt leider nichts.'; + + @override + String decisionWithoutTime(int count) { + return 'Ohne Zeitlimit: $count'; + } + + @override + String decisionAllCourses(int count) { + return 'Alle Gänge: $count'; + } + + @override + String reviewBanner(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Rezepte warten auf deinen Blick', + one: '1 Rezept wartet auf deinen Blick', + ); + return '$_temp0'; + } + + @override + String get reviewBannerActive => 'Nur Rezepte zum Prüfen – alle zeigen'; + + @override + String get filterAll => 'Alle'; + + @override + String get navRecipes => 'Rezepte'; + + @override + String get navWeekPlan => 'Wochenplan'; + + @override + String durationMinutes(int minutes) { + return '$minutes Min.'; + } + + @override + String durationHours(int hours) { + return '$hours Std.'; + } + + @override + String get factTotal => 'Gesamt'; + + @override + String get factServings => 'Portionen'; + + @override + String get factCalories => 'Kalorien'; + + @override + String kcalValue(int kcal) { + return '$kcal kcal'; + } + + @override + String tabIngredients(int count) { + return 'Zutaten · $count'; + } + + @override + String tabSteps(int count) { + return 'Zubereitung · $count'; + } + + @override + String get back => 'Zurück'; + + @override + String get cookStart => 'Kochmodus starten'; + + @override + String get cookClose => 'Kochmodus beenden'; + + @override + String get cookReadyTitle => 'Bereit zum Kochen?'; + + @override + String get cookReadyBody => 'Leg dir alles bereit – abhaken hilft.'; + + @override + String get cookLetsGo => 'Los geht’s'; + + @override + String cookStepOf(int number, int total) { + return 'Schritt $number von $total'; + } + + @override + String get cookNext => 'Weiter'; + + @override + String get cookPrevious => 'Zurück'; + + @override + String get cookFinish => 'Fertig'; + + @override + String get cookDoneTitle => 'Guten Appetit!'; + + @override + String get cookDoneBody => 'Alle Schritte geschafft. Lass es dir schmecken.'; + + @override + String get cookBackToRecipe => 'Zurück zum Rezept'; + + @override + String get cookNoSteps => + 'Für dieses Rezept ist keine Zubereitung eingetragen.'; + + @override + String get servingsFewer => 'Weniger Portionen'; + + @override + String get servingsMore => 'Mehr Portionen'; + + @override + String get shareIngredients => 'Zutaten teilen'; + + @override + String get shareIngredientsHint => + 'Jede Zutat steht in einer eigenen Zeile – so kann z. B. Bring! sie als einzelne Artikel übernehmen.'; + + @override + String shareIngredientsSubject(String name) { + return '$name – Zutaten'; + } + + @override + String shareIngredientsSubjectServings(String name, String servings) { + return '$name – Zutaten für $servings Portionen'; + } + + @override + String get share => 'Teilen'; + + @override + String get shareRecipe => 'Rezept teilen'; + + @override + String get 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.'; + + @override + String get includeNotes => 'Notizen mitschicken'; + + @override + String get shareAsFile => 'Als Datei teilen'; + + @override + String get shareAsFileHint => + 'Mit Fotos – z. B. über Quick Share, auch ohne Internet.'; + + @override + String get codeTooLong => + 'Dieses Rezept ist zu lang für einen Code. Teile es als Datei.'; + + @override + String get scanRecipe => 'Rezept scannen'; + + @override + String get scanHint => 'Halte die Kamera auf den Rezept-Code.'; + + @override + String get notARecipeCode => 'Das ist kein CookIt-Rezept-Code.'; + + @override + String get codeFromNewerVersion => + 'Dieser Code stammt aus einer neueren CookIt-Version. Bitte CookIt aktualisieren.'; + + @override + String get codeDamaged => + 'Der Code ließ sich nicht lesen. Versuch es noch einmal.'; + + @override + String get cameraUnavailable => + 'Die Kamera ist nicht verfügbar. Erlaube CookIt den Kamerazugriff in den Android-Einstellungen.'; + + @override + String get receivedRecipe => 'Rezept empfangen'; + + @override + String recipeNameTaken(String name) { + return 'Du hast schon ein Rezept „$name“. Beim Speichern kommt dieses als eigenes dazu.'; + } + + @override + String recipeSaved(String name) { + return '„$name“ gespeichert'; + } + + @override + String fileUnreadable(String message) { + return 'Die Datei ließ sich nicht öffnen: $message'; + } + + @override + String get plannedToday => 'Heute geplant'; + + @override + String plannedTodayMore(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '+$count weitere Einträge', + one: '+1 weiterer Eintrag', + ); + return '$_temp0'; + } + + @override + String get showIdea => 'Lieber eine Idee'; + + @override + String get weekPlanTitle => 'Wochenplan'; + + @override + String get jumpToToday => 'Heute'; + + @override + String get thisWeek => 'Diese Woche'; + + @override + String get nextWeek => 'Nächste Woche'; + + @override + String get lastWeek => 'Letzte Woche'; + + @override + String calendarWeek(int number) { + return 'KW $number'; + } + + @override + String get todayMarker => 'heute'; + + @override + String get previousWeek => 'Vorige Woche'; + + @override + String get dropToRemove => 'Hier ablegen zum Entfernen'; + + @override + String get dropAdd => '+ dazulegen'; + + @override + String get cook => 'Kochen'; + + @override + String todayBanner(String weekday) { + return 'Heute · $weekday'; + } + + @override + String get addAnother => 'Weiteres hinzufügen'; + + @override + String entryRemoved(String name) { + return '„$name“ entfernt'; + } + + @override + String get undo => 'Rückgängig'; + + @override + String get entryEdit => 'Bearbeiten'; + + @override + String get editNoteTitle => 'Notiz bearbeiten'; + + @override + String get copy => 'Kopieren'; + + @override + String get copied => 'In die Zwischenablage kopiert'; + + @override + String get shareMenu => 'Teilen'; + + @override + String get shareWeekPlan => 'Wochenplan teilen'; + + @override + String get shareWeekPlanHint => + 'Als Datei an eine andere CookIt-App – mit allen Rezepten und Fotos.'; + + @override + String get shareWeekIngredientsHint => + 'Zusammengezählt, z. B. für die Einkaufsliste.'; + + @override + String weekPlanFileName(String range) { + return 'Wochenplan $range'; + } + + @override + String get receivedWeek => 'Wochenplan empfangen'; + + @override + String receivedWeekEntries(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Einträge', + one: '1 Eintrag', + ); + return '$_temp0'; + } + + @override + String receivedWeekNewRecipes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count neue Rezepte', + one: '1 neues Rezept', + zero: 'keine neuen Rezepte', + ); + return '$_temp0'; + } + + @override + String receivedWeekExisting(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Du hast in dieser Woche schon $count Einträge – die neuen kommen dazu.', + one: 'Du hast in dieser Woche schon 1 Eintrag – die neuen kommen dazu.', + ); + return '$_temp0'; + } + + @override + String get adopt => 'Übernehmen'; + + @override + String weekAdopted(String range) { + return 'Wochenplan $range übernommen'; + } + + @override + String get addEntry => 'Eintrag hinzufügen'; + + @override + String get entryReroll => 'Neu würfeln'; + + @override + String get entryMove => 'Verschieben …'; + + @override + String get entryRemove => 'Entfernen'; + + @override + String get noOtherRecipe => 'Kein anderes passendes Rezept gefunden.'; + + @override + String addEntryTitle(String meal, String day) { + return '$meal · $day'; + } + + @override + String get addEntryHint => 'Rezept suchen oder Notiz schreiben'; + + @override + String addAsNote(String text) { + return 'Als Notiz: „$text“'; + } + + @override + String get planRecipe => 'Einplanen'; + + @override + String get planWhichDay => 'An welchem Tag?'; + + @override + String get planWhichMeal => 'Zu welcher Mahlzeit?'; + + @override + String get planOtherDate => 'Anderes Datum …'; + + @override + String get tomorrow => 'Morgen'; + + @override + String planned(String day, String meal) { + return 'Eingeplant: $day · $meal'; + } + + @override + String get moveEntryTitle => 'Verschieben'; + + @override + String get noMeals => 'Lege zuerst in den Einstellungen eine Mahlzeit an.'; + + @override + String get fillWeek => 'Woche füllen'; + + @override + String get 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.'; + + @override + String get fillWhichMeal => 'Welche Mahlzeit?'; + + @override + String get fillAction => 'Füllen'; + + @override + String fillResult(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Tage gefüllt.', + one: '1 Tag gefüllt.', + zero: 'Keine leeren Tage gefüllt.', + ); + return '$_temp0'; + } + + @override + String fillLeftEmpty(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Tage bleiben leer – keine passenden Rezepte mehr.', + one: '1 Tag bleibt leer – keine passenden Rezepte mehr.', + ); + return '$_temp0'; + } + + @override + String get fillNothingEmpty => + 'An den Tagen ab heute ist schon alles geplant.'; + + @override + String get shareWeek => 'Zutaten der Woche teilen'; + + @override + String get shareWeekHint => + 'Gleiche Zutaten werden zusammengezählt. Jedes Rezept zählt mit seinen eigenen Portionen.'; + + @override + String shareWeekSubject(String range) { + return 'Zutaten für $range'; + } + + @override + String get shareWeekEmpty => + 'An den gewählten Tagen ist kein Rezept geplant.'; + + @override + String get settingsSectionPlanning => 'Wochenplan'; + + @override + String get settingsMeals => 'Mahlzeiten bearbeiten'; + + @override + String get settingsMealsSubtitle => 'z. B. Frühstück, Mittag, Abend'; + + @override + String get settingsWeekStart => 'Woche beginnt am'; + + @override + String get mealsTitle => 'Mahlzeiten'; + + @override + String get addMeal => 'Mahlzeit hinzufügen'; + + @override + String get editMeal => 'Mahlzeit umbenennen'; + + @override + String get mealNameTaken => 'Diese Mahlzeit gibt es schon.'; + + @override + String get deleteMealTitle => 'Mahlzeit löschen?'; + + @override + String deleteMealBody(String name, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '„$name“ wird gelöscht. $count geplante Einträge ab heute werden entfernt.', + one: '„$name“ wird gelöscht. 1 geplanter Eintrag ab heute wird entfernt.', + zero: '„$name“ wird gelöscht.', + ); + return '$_temp0 Vergangene Wochen behalten sie.'; + } + + @override + String get more => 'Mehr'; + + @override + String get filterFavourites => 'Favoriten'; + + @override + String filterReview(int count) { + return 'Zu prüfen ($count)'; + } + + @override + String get newRecipe => 'Neues Rezept'; + + @override + String get settings => 'Einstellungen'; + + @override + String get emptyCollectionTitle => 'Noch keine Rezepte'; + + @override + String get emptyCollectionBody => + 'Leg dein erstes Rezept an oder importiere ein Rezeptpaket in den Einstellungen.'; + + @override + String get noMatchesTitle => 'Nichts gefunden'; + + @override + String get noMatchesBody => + 'Versuch es mit einem anderen Suchbegriff oder weniger Filtern.'; + + @override + String get resetFilters => 'Filter zurücksetzen'; + + @override + String recipeCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Rezepte', + one: '1 Rezept', + ); + return '$_temp0'; + } + + @override + String get favourite => 'Favorit'; + + @override + String get removeFavourite => 'Kein Favorit mehr'; + + @override + String get edit => 'Bearbeiten'; + + @override + String get delete => 'Löschen'; + + @override + String get cancel => 'Abbrechen'; + + @override + String get save => 'Speichern'; + + @override + String get deleteRecipeTitle => 'Rezept löschen?'; + + @override + String deleteRecipeBody(String name) { + return '„$name“ und seine Fotos werden endgültig gelöscht.'; + } + + @override + String get recipeNotFound => 'Dieses Rezept gibt es nicht mehr.'; + + @override + String get reviewTitle => 'Bitte prüfen'; + + @override + String get reviewDone => 'Erledigt'; + + @override + String get servings => 'Portionen'; + + @override + String get servingsOne => '1 Portion'; + + @override + String servingsValue(String value) { + return '$value Portionen'; + } + + @override + String minutesValue(int value) { + return '$value Min.'; + } + + @override + String get prepTime => 'Vorbereitung'; + + @override + String get cookTime => 'Kochen'; + + @override + String get totalTime => 'Gesamt'; + + @override + String caloriesPerServing(int kcal) { + return '$kcal kcal pro Portion'; + } + + @override + String caloriesWholeRecipe(int kcal) { + return '$kcal kcal gesamt'; + } + + @override + String get ingredients => 'Zutaten'; + + @override + String get steps => 'Zubereitung'; + + @override + String get notes => 'Notizen'; + + @override + String get sources => 'Quellen'; + + @override + String get tags => 'Stichwörter'; + + @override + String get noIngredients => 'Keine Zutaten eingetragen.'; + + @override + String get noSteps => 'Keine Zubereitung eingetragen.'; + + @override + String get editRecipe => 'Rezept bearbeiten'; + + @override + String get discardTitle => 'Änderungen verwerfen?'; + + @override + String get discardBody => 'Deine Änderungen an diesem Rezept gehen verloren.'; + + @override + String get discard => 'Verwerfen'; + + @override + String get keepEditing => 'Weiter bearbeiten'; + + @override + String get fieldName => 'Name'; + + @override + String get fieldNameRequired => 'Bitte gib dem Rezept einen Namen.'; + + @override + String get fieldCourse => 'Gang'; + + @override + String get noCourse => 'Kein Gang'; + + @override + String get fieldServings => 'Portionen'; + + @override + String get fieldPrep => 'Vorbereitung'; + + @override + String get fieldCook => 'Kochen'; + + @override + String get minutesSuffix => 'Min.'; + + @override + String get fieldKcal => 'Kalorien (kcal)'; + + @override + String get basisPerServing => 'pro Portion'; + + @override + String get basisWholeRecipe => 'gesamt'; + + @override + String get fieldTags => 'Stichwörter'; + + @override + String get fieldTagsHint => 'durch Komma getrennt, z. B. vegan, schnell'; + + @override + String get fieldNotes => 'Notizen'; + + @override + String get fieldSources => 'Quellen'; + + @override + String get fieldSourcesHint => 'ein Link pro Zeile'; + + @override + String get invalidNumber => 'Keine gültige Zahl'; + + @override + String get photos => 'Fotos'; + + @override + String get addPhoto => 'Foto hinzufügen'; + + @override + String get photoFromCamera => 'Kamera'; + + @override + String get photoFromGallery => 'Galerie'; + + @override + String get mainPhoto => 'Hauptfoto'; + + @override + String get setMainPhoto => 'Als Hauptfoto festlegen'; + + @override + String get photoHint => + 'Gedrückt halten und ziehen zum Sortieren – das erste Foto ist das Hauptfoto. Antippen für mehr.'; + + @override + String get removePhoto => 'Foto entfernen'; + + @override + String get addIngredient => 'Zutat'; + + @override + String get addIngredientGroup => 'Gruppe'; + + @override + String get ingredientAmount => 'Menge'; + + @override + String get ingredientUnit => 'Einheit'; + + @override + String get ingredientName => 'Zutat'; + + @override + String get ingredientRemark => 'Hinweis, z. B. gehackt'; + + @override + String get addRemark => 'Hinweis hinzufügen'; + + @override + String get invalidAmount => 'z. B. 2, 1,5, ½ oder 2-3'; + + @override + String get groupName => 'Gruppenname (optional)'; + + @override + String get noUnit => '–'; + + @override + String get addStep => 'Schritt'; + + @override + String get addStepSection => 'Abschnitt'; + + @override + String stepText(int number) { + return 'Schritt $number'; + } + + @override + String get sectionTitle => 'Abschnitt, z. B. Teig'; + + @override + String get removeItem => 'Entfernen'; + + @override + String get unnamedGroup => 'Weitere Zutaten'; + + @override + String get settingsCourses => 'Gänge bearbeiten'; + + @override + String get settingsCoursesSubtitle => 'Hinzufügen, umbenennen, sortieren'; + + @override + String get settingsImport => 'Rezepte importieren'; + + @override + String get settingsImportSubtitle => 'Rezeptpaket (.zip) laden'; + + @override + String get settingsExport => 'Sammlung exportieren'; + + @override + String get settingsExportSubtitle => + 'Alle Rezepte mit Fotos als Backup speichern'; + + @override + String get settingsSectionCollection => 'Sammlung'; + + @override + String get settingsSectionData => 'Daten'; + + @override + String get settingsSectionAppearance => 'Darstellung'; + + @override + String get themeLight => 'Hell'; + + @override + String get themeDark => 'Dunkel'; + + @override + String get themeSystem => 'System'; + + @override + String get importing => 'Rezepte werden importiert …'; + + @override + String importResult(int added, int skipped) { + return '$added neu, $skipped übersprungen'; + } + + @override + String importFailed(String message) { + return 'Import fehlgeschlagen: $message'; + } + + @override + String get exporting => 'Sammlung wird exportiert …'; + + @override + String get exportDone => 'Sammlung gespeichert.'; + + @override + String exportFailed(String message) { + return 'Export fehlgeschlagen: $message'; + } + + @override + String get settingsUnits => 'Maßeinheiten bearbeiten'; + + @override + String get settingsUnitsSubtitle => + 'Eigene hinzufügen, sortieren, direkt anzeigen'; + + @override + String get unitsTitle => 'Maßeinheiten'; + + @override + String get unitsDirectHint => + 'Eingeschaltete Einheiten erscheinen direkt in der Auswahl, die anderen unter „Weitere Einheiten“. Ziehen zum Sortieren.'; + + @override + String get addUnit => 'Einheit hinzufügen'; + + @override + String get editUnit => 'Einheit umbenennen'; + + @override + String get unitName => 'Name'; + + @override + String get ownUnit => 'Eigene Einheit'; + + @override + String get unitNameTaken => 'Diese Einheit gibt es schon.'; + + @override + String get showDirectly => 'Direkt anzeigen'; + + @override + String get deleteUnitTitle => 'Einheit löschen?'; + + @override + String deleteUnitBody(String name, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '„$name“ wird gelöscht. $count Rezepte verwenden diese Einheit – dort wandert das Wort in den Zutatennamen.', + one: + '„$name“ wird gelöscht. 1 Rezept verwendet diese Einheit – dort wandert das Wort in den Zutatennamen.', + zero: '„$name“ wird gelöscht.', + ); + return '$_temp0'; + } + + @override + String get coursesTitle => 'Gänge'; + + @override + String get addCourse => 'Gang hinzufügen'; + + @override + String get editCourse => 'Gang bearbeiten'; + + @override + String get courseName => 'Name'; + + @override + String get courseEmoji => 'Emoji (optional)'; + + @override + String get courseNameTaken => 'Diesen Gang gibt es schon.'; + + @override + String get deleteCourseTitle => 'Gang löschen?'; + + @override + String deleteCourseBody(String name, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '„$name“ wird gelöscht. $count Rezepte haben danach keinen Gang mehr.', + one: '„$name“ wird gelöscht. 1 Rezept hat danach keinen Gang mehr.', + zero: '„$name“ wird gelöscht.', + ); + return '$_temp0'; + } + + @override + String get unitGram => 'g'; + + @override + String get unitKilogram => 'kg'; + + @override + String get unitMilliliter => 'ml'; + + @override + String get unitLiter => 'l'; + + @override + String get unitTablespoon => 'EL'; + + @override + String get unitTeaspoon => 'TL'; + + @override + String get unitPinch => 'Prise'; + + @override + String get unitKnifeTip => 'Msp.'; + + @override + String get unitPack => 'Pck.'; + + @override + String get unitPiece => 'Stück'; + + @override + String get unitSlice => 'Scheibe(n)'; + + @override + String get unitClove => 'Zehe(n)'; + + @override + String get unitSprig => 'Zweig(e)'; + + @override + String get unitBunch => 'Bund'; + + @override + String get unitHandful => 'Handvoll'; + + @override + String get unitCup => 'Tasse(n)'; + + @override + String get unitCan => 'Dose'; + + @override + String get unitJar => 'Glas'; + + @override + String get unitTub => 'Becher'; + + @override + String get unitBag => 'Beutel'; + + @override + String get unitCarton => 'Karton'; + + @override + String get unitHead => 'Kopf'; + + @override + String get unitGrain => 'Korn'; + + @override + String get unitSmall => 'kleine(r)'; + + @override + String get unitMedium => 'mittlere(r)'; + + @override + String get unitLarge => 'große(r)'; + + @override + String get unitSome => 'etwas'; + + @override + String get moreUnits => 'Weitere Einheiten …'; +} diff --git a/lib/src/planning/day.dart b/lib/src/planning/day.dart new file mode 100644 index 0000000..45cd169 --- /dev/null +++ b/lib/src/planning/day.dart @@ -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 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; +} diff --git a/lib/src/planning/day_format.dart b/lib/src/planning/day_format.dart new file mode 100644 index 0000000..d5c487f --- /dev/null +++ b/lib/src/planning/day_format.dart @@ -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); diff --git a/lib/src/planning/ingredient_totals.dart b/lib/src/planning/ingredient_totals.dart new file mode 100644 index 0000000..a2f9acb --- /dev/null +++ b/lib/src/planning/ingredient_totals.dart @@ -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 ingredientTotals(Iterable 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, + ); +} diff --git a/lib/src/planning/models/meal.dart b/lib/src/planning/models/meal.dart new file mode 100644 index 0000000..2a9b43f --- /dev/null +++ b/lib/src/planning/models/meal.dart @@ -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']; diff --git a/lib/src/planning/models/plan_entry.dart b/lib/src/planning/models/plan_entry.dart new file mode 100644 index 0000000..e9f56c2 --- /dev/null +++ b/lib/src/planning/models/plan_entry.dart @@ -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; +} diff --git a/lib/src/planning/plan_repository.dart b/lib/src/planning/plan_repository.dart new file mode 100644 index 0000000..a88a3ea --- /dev/null +++ b/lib/src/planning/plan_repository.dart @@ -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> watchMeals() { + final query = _db.select(_db.meals)..orderBy([(m) => OrderingTerm.asc(m.position)]); + return query.watch().map((rows) => rows.map(_toMeal).toList()); + } + + Future> 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 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 renameMeal(String id, String name) => _updateMeal(id, MealsCompanion(name: Value(name))); + + Future reorderMeals(List 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 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 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> 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> 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 addEntry(DateTime day, String mealId, {String? recipeId, String? note}) => + addEntries([(day: day, mealId: mealId, recipeId: recipeId, note: note)]); + + Future 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 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 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 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 removeEntry(String entryId) => (_db.delete(_db.planEntries)..where((e) => e.id.equals(entryId))).go(); + + Future _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 _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, + ); +} diff --git a/lib/src/planning/plan_share_service.dart b/lib/src/planning/plan_share_service.dart new file mode 100644 index 0000000..0aa4ec0 --- /dev/null +++ b/lib/src/planning/plan_share_service.dart @@ -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 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 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 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 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 = {}; + for (final MapEntry(key: id, value: entry) in week.recipes.entries) { + ids[id] = matches[id] ?? await _addRecipe(entry, week.contents.files); + } + + final mealIds = {}; + 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> _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 _addRecipe(BundleRecipe entry, Map 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 _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(); +} diff --git a/lib/src/planning/views/shared_week_view.dart b/lib/src/planning/views/shared_week_view.dart new file mode 100644 index 0000000..f55fd97 --- /dev/null +++ b/lib/src/planning/views/shared_week_view.dart @@ -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 createState() => _SharedWeekViewState(); +} + +class _SharedWeekViewState extends State { + late final Future _preview = context.read().preview(widget.week); + var _adopting = false; + + Future _adopt() async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + setState(() => _adopting = true); + await context.read().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( + 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/planning/views/week_plan_view.dart b/lib/src/planning/views/week_plan_view.dart new file mode 100644 index 0000000..c3f7de6 --- /dev/null +++ b/lib/src/planning/views/week_plan_view.dart @@ -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? onDraggingChanged; + + @override + State createState() => _WeekPlanViewState(); +} + +class _WeekPlanViewState extends State { + /// 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().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 _dropOnWeek(PlanEntry entry, int direction) async { + final weekStart = context.read().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().moveEntry(entry.id, day, entry.mealId); + _goTo(page); + } + + Future _remove(PlanEntry entry) async { + final recipes = context.read().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(); + 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( + 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 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( + 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, + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/planning/views/widgets/add_entry_sheet.dart b/lib/src/planning/views/widgets/add_entry_sheet.dart new file mode 100644 index 0000000..f59eefc --- /dev/null +++ b/lib/src/planning/views/widgets/add_entry_sheet.dart @@ -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 showAddEntrySheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet( + 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 _add({String? recipeId, String? note}) async { + final plan = context.read(); + 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(); + 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), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/cell_sheet.dart b/lib/src/planning/views/widgets/cell_sheet.dart new file mode 100644 index 0000000..43cd1ed --- /dev/null +++ b/lib/src/planning/views/widgets/cell_sheet.dart @@ -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 showCellSheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet( + 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().entriesFor(day, meal.id); + final recipes = context.watch(); + 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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/fill_week_sheet.dart b/lib/src/planning/views/widgets/fill_week_sheet.dart new file mode 100644 index 0000000..2f840c0 --- /dev/null +++ b/lib/src/planning/views/widgets/fill_week_sheet.dart @@ -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 showFillWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet( + 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 _courseIds = _defaultCourses(); + var _time = TimeLimit.any; + + String? _defaultMeal() { + final meals = context.read().meals; + for (final meal in meals) { + if (meal.name.toLowerCase() == 'abend') return meal.id; + } + return meals.isEmpty ? null : meals.last.id; + } + + Set _defaultCourses() => { + for (final course in context.read().courses) + if (course.name.toLowerCase() == 'hauptgericht') course.id, + }; + + Future _fill() async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final plan = context.read(); + final week = context.read(); + final recipes = context.read().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().meals; + final courses = context.watch().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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/plan_actions.dart b/lib/src/planning/views/widgets/plan_actions.dart new file mode 100644 index 0000000..3287c3b --- /dev/null +++ b/lib/src/planning/views/widgets/plan_actions.dart @@ -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 rerollEntry(BuildContext context, PlanEntry entry, Recipe current) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final week = context.read(); + final recipes = context.read().allRecipes; + final plan = context.read(); + final weekStart = weekStartOf(entry.day, context.read().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 moveEntryWithPicker(BuildContext context, PlanEntry entry) async { + final l10n = AppLocalizations.of(context); + final plan = context.read(); + 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 removeEntryWithUndo(BuildContext context, PlanEntry entry, String name) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final plan = context.read(); + 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 editNote(BuildContext context, PlanEntry entry) async { + final plan = context.read(); + final text = await showDialog( + 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)), + ], + ); + } +} diff --git a/lib/src/planning/views/widgets/plan_grid.dart b/lib/src/planning/views/widgets/plan_grid.dart new file mode 100644 index 0000000..6635944 --- /dev/null +++ b/lib/src/planning/views/widgets/plan_grid.dart @@ -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(); + 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 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( + 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( + onWillAcceptWithDetails: (d) => !meal.retired && !(d.data.day == day && d.data.mealId == meal.id), + onAcceptWithDetails: (d) => context.read().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(); + 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/plan_slot_sheet.dart b/lib/src/planning/views/widgets/plan_slot_sheet.dart new file mode 100644 index 0000000..bb0d48c --- /dev/null +++ b/lib/src/planning/views/widgets/plan_slot_sheet.dart @@ -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 get _days => [for (var i = 0; i < _quickDays; i++) addDays(_today, i)]; + + Future _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().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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/share_week_sheet.dart b/lib/src/planning/views/widgets/share_week_sheet.dart new file mode 100644 index 0000000..32fb95d --- /dev/null +++ b/lib/src/planning/views/widgets/share_week_sheet.dart @@ -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 showShareWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet( + 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 _days = { + for (final day in weekDays(widget.weekStart)) + if (!day.isBefore(_today) && _recipesOn(day).isNotEmpty) day, + }; + + List _recipesOn(DateTime day) { + final byId = {for (final r in context.read().allRecipes) r.id: r}; + return [ + for (final entry in context.read().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(); + context.watch(); + 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), + ], + ), + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/today_banner.dart b/lib/src/planning/views/widgets/today_banner.dart new file mode 100644 index 0000000..531ab8a --- /dev/null +++ b/lib/src/planning/views/widgets/today_banner.dart @@ -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(); + final recipes = context.watch(); + 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), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/planning/views/widgets/week_share_menu.dart b/lib/src/planning/views/widgets/week_share_menu.dart new file mode 100644 index 0000000..b31ce15 --- /dev/null +++ b/lib/src/planning/views/widgets/week_share_menu.dart @@ -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 showWeekShareMenu(BuildContext context, DateTime weekStart) => showModalBottomSheet( + 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 _shareWeekFile(BuildContext context, DateTime weekStart) async { + final l10n = AppLocalizations.of(context); + final bytes = await context.read().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')])); +} diff --git a/lib/src/planning/week_fill.dart b/lib/src/planning/week_fill.dart new file mode 100644 index 0000000..dd646f2 --- /dev/null +++ b/lib/src/planning/week_fill.dart @@ -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 fillWeek({ + required List emptyDays, + required List candidates, + required Set inWeek, + required Set 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 candidates, + required Set inWeek, + required Set recent, + required Random random, +}) { + final pool = _preferFresh(candidates.where((r) => !inWeek.contains(r.id)), recent, random); + return pool.isEmpty ? null : pool.first; +} + +List _preferFresh(Iterable recipes, Set 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]; +} diff --git a/lib/src/planning/week_plan_provider.dart b/lib/src/planning/week_plan_provider.dart new file mode 100644 index 0000000..d061131 --- /dev/null +++ b/lib/src/planning/week_plan_provider.dart @@ -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> _subscriptions; + List _meals = const []; + Map> _byDay = {}; + + /// The Meals the cook plans for now, in order. + List 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 entriesOn(DateTime day) => _byDay[day] ?? const []; + + /// The Meals shown for [day]: the current ones, plus retired ones its entries still use. + List 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 mealsDuring(Iterable 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 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 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 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(); + } +} diff --git a/lib/src/recipes/amount.dart b/lib/src/recipes/amount.dart new file mode 100644 index 0000000..710dff5 --- /dev/null +++ b/lib/src/recipes/amount.dart @@ -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)}'; +} diff --git a/lib/src/recipes/course_repository.dart b/lib/src/recipes/course_repository.dart new file mode 100644 index 0000000..478380a --- /dev/null +++ b/lib/src/recipes/course_repository.dart @@ -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> watchCourses() { + final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]); + return query.watch().map((rows) => rows.map(_toCourse).toList()); + } + + Future> allCourses() async { + final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]); + return (await query.get()).map(_toCourse).toList(); + } + + Future 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 findOrCreate(String name) async { + for (final course in await allCourses()) { + if (course.name.toLowerCase() == name.toLowerCase()) return course; + } + return addCourse(name); + } + + Future 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 reorder(List 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 deleteCourse(String id) => (_db.delete(_db.courses)..where((c) => c.id.equals(id))).go(); + + Future 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); +} diff --git a/lib/src/recipes/decision.dart b/lib/src/recipes/decision.dart new file mode 100644 index 0000000..2d4d1d4 --- /dev/null +++ b/lib/src/recipes/decision.dart @@ -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 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 matching(Iterable recipes) => recipes.where(matches).toList(); + + DecisionChoice withoutTimeLimit() => DecisionChoice(courseIds: courseIds); + + DecisionChoice withAllCourses() => DecisionChoice(time: time); +} diff --git a/lib/src/recipes/ingredients_text.dart b/lib/src/recipes/ingredients_text.dart new file mode 100644 index 0000000..06d4158 --- /dev/null +++ b/lib/src/recipes/ingredients_text.dart @@ -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 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'); diff --git a/lib/src/recipes/models/course.dart b/lib/src/recipes/models/course.dart new file mode 100644 index 0000000..395b7b8 --- /dev/null +++ b/lib/src/recipes/models/course.dart @@ -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', '🥤'), +]; diff --git a/lib/src/recipes/models/recipe.dart b/lib/src/recipes/models/recipe.dart new file mode 100644 index 0000000..890bb8b --- /dev/null +++ b/lib/src/recipes/models/recipe.dart @@ -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 tags; + final double? servings; + final int? prepMinutes; + final int? cookMinutes; + final Calories? calories; + final List ingredientGroups; + final List stepSections; + + /// Photo file names in display order; the first is the main Photo. + final List photos; + final List sources; + final String? notes; + final bool favourite; + + /// Why the cook should check this Recipe. Empty means no Review Flag. + final List 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 get ingredientLines => ingredientGroups.expand((g) => g.lines); + + Recipe copyWith({ + String? name, + String? Function()? courseId, + List? tags, + double? Function()? servings, + int? Function()? prepMinutes, + int? Function()? cookMinutes, + Calories? Function()? calories, + List? ingredientGroups, + List? stepSections, + List? photos, + List? sources, + String? Function()? notes, + bool? favourite, + List? 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 toJson() => {'kcal': kcal, 'basis': basis.key}; + + static Calories fromJson(Map 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 lines; + + Map toJson() => { + 'name': name, + 'lines': [for (final l in lines) l.toJson()], + }; + + static IngredientGroup fromJson(Map json) => IngredientGroup( + name: json['name'] as String?, + lines: [for (final l in json['lines'] as List) IngredientLine.fromJson(l as Map)], + ); +} + +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 toJson() => { + 'amount': amount, + 'amountMax': amountMax, + 'unit': unitKey, + 'name': name, + 'remark': remark, + }; + + static IngredientLine fromJson(Map 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 steps; + + Map toJson() => {'title': title, 'steps': steps}; + + static StepSection fromJson(Map json) => + StepSection(title: json['title'] as String?, steps: [for (final s in json['steps'] as List) s as String]); +} diff --git a/lib/src/recipes/models/unit.dart b/lib/src/recipes/models/unit.dart new file mode 100644 index 0000000..faf76de --- /dev/null +++ b/lib/src/recipes/models/unit.dart @@ -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; + } +} diff --git a/lib/src/recipes/models/unit_definition.dart b/lib/src/recipes/models/unit_definition.dart new file mode 100644 index 0000000..b034c0d --- /dev/null +++ b/lib/src/recipes/models/unit_definition.dart @@ -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); +} diff --git a/lib/src/recipes/photo_store.dart b/lib/src/recipes/photo_store.dart new file mode 100644 index 0000000..b669957 --- /dev/null +++ b/lib/src/recipes/photo_store.dart @@ -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 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 read(String name) => file(name).readAsBytes(); + + Future 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 importFile(String path) async { + final name = '${const Uuid().v4()}${p.extension(path).toLowerCase()}'; + await File(path).copy(file(name).path); + return name; + } + + Future delete(Iterable names) async { + for (final name in names) { + final f = file(name); + if (await f.exists()) await f.delete(); + } + } +} diff --git a/lib/src/recipes/recipe_draft.dart b/lib/src/recipes/recipe_draft.dart new file mode 100644 index 0000000..bc2f592 --- /dev/null +++ b/lib/src/recipes/recipe_draft.dart @@ -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 ingredientEntriesFrom(List 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 ingredientGroupsFrom(List entries) { + final groups = <({String? name, List 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 stepEntriesFrom(List sections) => [ + for (final section in sections) ...[ + if (section.title != null) SectionEntry(section.title), + for (final step in section.steps) StepTextEntry(step), + ], +]; + +List stepSectionsFrom(List entries) { + final sections = <({String? title, List 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 tagsFrom(String text) { + final seen = {}; + return [ + for (final tag in text.split(',').map((t) => t.trim())) + if (tag.isNotEmpty && seen.add(tag.toLowerCase())) tag, + ]; +} + +List 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; +} diff --git a/lib/src/recipes/recipe_filter.dart b/lib/src/recipes/recipe_filter.dart new file mode 100644 index 0000000..fc4badf --- /dev/null +++ b/lib/src/recipes/recipe_filter.dart @@ -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(); +} diff --git a/lib/src/recipes/recipe_list_provider.dart b/lib/src/recipes/recipe_list_provider.dart new file mode 100644 index 0000000..f7d4e9e --- /dev/null +++ b/lib/src/recipes/recipe_list_provider.dart @@ -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> _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 _recipes = const []; + List _courses = const []; + RecipeFilter _filter = const RecipeFilter(); + bool _loaded = false; + + bool get loaded => _loaded; + List get allRecipes => _recipes; + List get courses => _courses; + RecipeFilter get filter => _filter; + + List 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(); + } +} diff --git a/lib/src/recipes/recipe_repository.dart b/lib/src/recipes/recipe_repository.dart new file mode 100644 index 0000000..9b76dda --- /dev/null +++ b/lib/src/recipes/recipe_repository.dart @@ -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> 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 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 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> allRecipes() async => (await _db.select(_db.recipes).get()).map(_toRecipe).toList(); + + Future> 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 saveRecipe(Recipe recipe) => _db.into(_db.recipes).insertOnConflictUpdate(_toCompanion(recipe)); + + Future insertAll(List 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 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 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 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, + ); +} diff --git a/lib/src/recipes/text_breaks.dart b/lib/src/recipes/text_breaks.dart new file mode 100644 index 0000000..db0a025 --- /dev/null +++ b/lib/src/recipes/text_breaks.dart @@ -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]}​'); diff --git a/lib/src/recipes/time_format.dart b/lib/src/recipes/time_format.dart new file mode 100644 index 0000000..3d63816 --- /dev/null +++ b/lib/src/recipes/time_format.dart @@ -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; +} diff --git a/lib/src/recipes/unit_catalog.dart b/lib/src/recipes/unit_catalog.dart new file mode 100644 index 0000000..5bd46ac --- /dev/null +++ b/lib/src/recipes/unit_catalog.dart @@ -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> _subscription; + List? _units; + + /// Until the database answers, the built-in defaults. + List get units => + _units ?? + [ + for (final (i, u) in Unit.values.indexed) + UnitDefinition(key: u.key, builtIn: true, direct: u.common, position: i), + ]; + + Iterable get direct => units.where((u) => u.direct); + + Iterable 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(); + } +} diff --git a/lib/src/recipes/unit_label.dart b/lib/src/recipes/unit_label.dart new file mode 100644 index 0000000..1475cbd --- /dev/null +++ b/lib/src/recipes/unit_label.dart @@ -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, +}; diff --git a/lib/src/recipes/unit_repository.dart b/lib/src/recipes/unit_repository.dart new file mode 100644 index 0000000..8a2b135 --- /dev/null +++ b/lib/src/recipes/unit_repository.dart @@ -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> watchUnits() { + final query = _db.select(_db.units)..orderBy([(u) => OrderingTerm.asc(u.position)]); + return query.watch().map((rows) => rows.map(_toUnit).toList()); + } + + Future> 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 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 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 setDirect(String key, bool direct) => + (_db.update(_db.units)..where((u) => u.key.equals(key))).write(UnitsCompanion(direct: Value(direct))); + + Future reorder(List 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 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 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> _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); +} diff --git a/lib/src/recipes/views/recipe_detail_view.dart b/lib/src/recipes/views/recipe_detail_view.dart new file mode 100644 index 0000000..3c2a956 --- /dev/null +++ b/lib/src/recipes/views/recipe_detail_view.dart @@ -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 createState() => _RecipeDetailViewState(); +} + +class _RecipeDetailViewState extends State { + late final Stream _recipe = context.read().watchRecipe(widget.recipeId); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + 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((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 = {}; + final _scroll = ScrollController(); + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + Recipe get recipe => widget.recipe; + + Future _delete() async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + 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(); + Navigator.pop(context); + await repository.deleteRecipe(recipe); + } + + /// "Einplanen": puts the Recipe on a day and Meal of the Week Plan. + Future _plan() async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final plan = context.read(); + final meals = context.read(); + 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(); + 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( + 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 _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 _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 _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 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().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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/recipes/views/recipe_edit_view.dart b/lib/src/recipes/views/recipe_edit_view.dart new file mode 100644 index 0000000..6383100 --- /dev/null +++ b/lib/src/recipes/views/recipe_edit_view.dart @@ -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 createState() => _RecipeEditViewState(); +} + +class _RecipeEditViewState extends State { + final _form = GlobalKey(); + 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 _photos = []; + final _addedPhotos = []; + List _ingredients = []; + List _steps = []; + + late final PhotoStore _photoStore; + + @override + void initState() { + super.initState(); + _photoStore = context.read(); + _load(); + } + + Future _load() async { + final id = widget.recipeId; + final recipe = id == null ? null : await context.read().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 _save() async { + if (!_form.currentState!.validate()) return; + final repository = context.read(); + 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 []; + await _photoStore.delete(removed); + _saved = true; + if (mounted) Navigator.pop(context, recipe.id); + } + + Future _confirmDiscard() async { + final l10n = AppLocalizations.of(context); + final discard = await showDialog( + 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().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( + 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( + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/recipes/views/recipe_list_view.dart b/lib/src/recipes/views/recipe_list_view.dart new file mode 100644 index 0000000..ee8de1a --- /dev/null +++ b/lib/src/recipes/views/recipe_list_view.dart @@ -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 createState() => _RecipeListViewState(); +} + +class _RecipeListViewState extends State { + late final _search = TextEditingController(text: context.read().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 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().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(); + final repository = context.read(); + final recipes = provider.visibleRecipes; + final filter = provider.filter; + final suggestion = provider.suggestion; + final planned = _showIdea ? const [] : context.watch().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 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!], + ], + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/decision_helper_sheet.dart b/lib/src/recipes/views/widgets/decision_helper_sheet.dart new file mode 100644 index 0000000..497e733 --- /dev/null +++ b/lib/src/recipes/views/widgets/decision_helper_sheet.dart @@ -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 showDecisionHelper(BuildContext context, {required ValueChanged onOpen}) => + showModalBottomSheet( + 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 onOpen; + + @override + State<_DecisionHelperSheet> createState() => _DecisionHelperSheetState(); +} + +class _DecisionHelperSheetState extends State<_DecisionHelperSheet> { + final _random = Random(); + var _step = _Step.course; + var _courseIds = {}; + 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().allRecipes); + setState(() { + _step = _Step.result; + _pick = _pickFrom(matches); + }); + } + + Recipe? _pickFrom(List 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().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(); + 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 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)), + ], + ), + ], + ); + } +} diff --git a/lib/src/recipes/views/widgets/ingredient_checklist.dart b/lib/src/recipes/views/widgets/ingredient_checklist.dart new file mode 100644 index 0000000..22dd672 --- /dev/null +++ b/lib/src/recipes/views/widgets/ingredient_checklist.dart @@ -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 groups; + final Set checked; + final ValueChanged 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(); + 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), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/ingredient_editor.dart b/lib/src/recipes/views/widgets/ingredient_editor.dart new file mode 100644 index 0000000..78a4340 --- /dev/null +++ b/lib/src/recipes/views/widgets/ingredient_editor.dart @@ -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 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( + 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 onChanged; + + Future _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(); + 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().byKey(current)?.direct ?? false); + }(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final units = context.watch(); + 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)), + ); +} diff --git a/lib/src/recipes/views/widgets/photo_editor.dart b/lib/src/recipes/views/widgets/photo_editor.dart new file mode 100644 index 0000000..15c9c49 --- /dev/null +++ b/lib/src/recipes/views/widgets/photo_editor.dart @@ -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 photos; + + /// Photo files newly stored while editing (so they can be cleaned up on discard). + final ValueChanged> onAdded; + final VoidCallback onChanged; + + Future _pick(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final source = await showModalBottomSheet( + 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(); + 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 _showPhotoMenu(BuildContext context, int index) async { + final l10n = AppLocalizations.of(context); + final action = await showModalBottomSheet( + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/recipe_card.dart b/lib/src/recipes/views/widgets/recipe_card.dart new file mode 100644 index 0000000..f76922a --- /dev/null +++ b/lib/src/recipes/views/widgets/recipe_card.dart @@ -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, + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/recipe_photo.dart b/lib/src/recipes/views/widgets/recipe_photo.dart new file mode 100644 index 0000000..6ab6f57 --- /dev/null +++ b/lib/src/recipes/views/widgets/recipe_photo.dart @@ -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().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)), + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/servings_stepper.dart b/lib/src/recipes/views/widgets/servings_stepper.dart new file mode 100644 index 0000000..b0a3659 --- /dev/null +++ b/lib/src/recipes/views/widgets/servings_stepper.dart @@ -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)), + ], + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/share_ingredients_sheet.dart b/lib/src/recipes/views/widgets/share_ingredients_sheet.dart new file mode 100644 index 0000000..6d7c769 --- /dev/null +++ b/lib/src/recipes/views/widgets/share_ingredients_sheet.dart @@ -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 showShareIngredients(BuildContext context, Recipe recipe) => showModalBottomSheet( + 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(); + 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), + ], + ), + ), + ); + } +} diff --git a/lib/src/recipes/views/widgets/share_or_copy_buttons.dart b/lib/src/recipes/views/widgets/share_or_copy_buttons.dart new file mode 100644 index 0000000..18856e6 --- /dev/null +++ b/lib/src/recipes/views/widgets/share_or_copy_buttons.dart @@ -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 createState() => _ShareOrCopyButtonsState(); +} + +class _ShareOrCopyButtonsState extends State { + var _copied = false; + Timer? _reset; + + @override + void dispose() { + _reset?.cancel(); + super.dispose(); + } + + Future _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), + ), + ), + ], + ); + } +} diff --git a/lib/src/recipes/views/widgets/step_editor.dart b/lib/src/recipes/views/widgets/step_editor.dart new file mode 100644 index 0000000..f6b07b4 --- /dev/null +++ b/lib/src/recipes/views/widgets/step_editor.dart @@ -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 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), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/settings/courses_view.dart b/lib/src/settings/courses_view.dart new file mode 100644 index 0000000..6351acf --- /dev/null +++ b/lib/src/settings/courses_view.dart @@ -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 _edit(BuildContext context, {Course? course}) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final existing = context.read().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 _delete(BuildContext context, Course course) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final count = await repository.recipeCount(course.id); + if (!context.mounted) return; + final confirmed = await showDialog( + 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().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().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 takenNames; + + @override + State<_CourseDialog> createState() => _CourseDialogState(); +} + +class _CourseDialogState extends State<_CourseDialog> { + final _form = GlobalKey(); + 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)), + ], + ); + } +} diff --git a/lib/src/settings/meals_view.dart b/lib/src/settings/meals_view.dart new file mode 100644 index 0000000..756fdbb --- /dev/null +++ b/lib/src/settings/meals_view.dart @@ -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 _edit(BuildContext context, {Meal? meal}) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final existing = context.read().meals; + final name = await showDialog( + 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 _delete(BuildContext context, Meal meal) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final today = dayOf(DateTime.now()); + final count = await repository.upcomingEntryCount(meal.id, today); + if (!context.mounted) return; + final confirmed = await showDialog( + 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().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().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 takenNames; + + @override + State<_MealDialog> createState() => _MealDialogState(); +} + +class _MealDialogState extends State<_MealDialog> { + final _form = GlobalKey(); + 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)), + ], + ); + } +} diff --git a/lib/src/settings/settings_controller.dart b/lib/src/settings/settings_controller.dart new file mode 100644 index 0000000..19aba33 --- /dev/null +++ b/lib/src/settings/settings_controller.dart @@ -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() async { + final stored = (await SharedPreferences.getInstance()).getString(_themeModeKey); + return ThemeMode.values.firstWhere((m) => m.name == stored, orElse: () => ThemeMode.light); + } + + Future 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 weekStart() async => (await SharedPreferences.getInstance()).getInt(_weekStartKey) ?? DateTime.monday; + + Future 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 loadSettings() async { + _themeMode = await _service.themeMode(); + _weekStart = await _service.weekStart(); + notifyListeners(); + } + + Future updateWeekStart(int weekday) async { + if (weekday == _weekStart) return; + _weekStart = weekday; + notifyListeners(); + await _service.updateWeekStart(weekday); + } + + Future updateThemeMode(ThemeMode mode) async { + if (mode == _themeMode) return; + _themeMode = mode; + notifyListeners(); + await _service.updateThemeMode(mode); + } +} diff --git a/lib/src/settings/settings_view.dart b/lib/src/settings/settings_view.dart new file mode 100644 index 0000000..8776a62 --- /dev/null +++ b/lib/src/settings/settings_view.dart @@ -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 _import(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final service = context.read(); + 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 _export(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + final service = context.read(); + 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(BuildContext context, String label, Future Function() task) async { + final navigator = Navigator.of(context); + showDialog( + 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( + 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().themeMode}, + onSelectionChanged: (s) => context.read().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( + value: context.watch().weekStart, + underline: const SizedBox(), + onChanged: (day) => context.read().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), + ), + ], + ), + ); + } +} diff --git a/lib/src/settings/units_view.dart b/lib/src/settings/units_view.dart new file mode 100644 index 0000000..b0758e8 --- /dev/null +++ b/lib/src/settings/units_view.dart @@ -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 _edit(BuildContext context, {UnitDefinition? unit}) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final catalog = context.read(); + final name = await showDialog( + 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 _delete(BuildContext context, UnitDefinition unit) async { + final l10n = AppLocalizations.of(context); + final repository = context.read(); + final count = await repository.recipeCount(unit.key); + if (!context.mounted) return; + final confirmed = await showDialog( + 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(); + final repository = context.read(); + 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 takenNames; + + @override + State<_UnitDialog> createState() => _UnitDialogState(); +} + +class _UnitDialogState extends State<_UnitDialog> { + final _form = GlobalKey(); + 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)), + ], + ); + } +} diff --git a/lib/src/sharing/cookit_platform.dart b/lib/src/sharing/cookit_platform.dart new file mode 100644 index 0000000..fd091df --- /dev/null +++ b/lib/src/sharing/cookit_platform.dart @@ -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 onFile) { + Future take() async { + try { + final bytes = await _channel.invokeMethod('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 setMaxBrightness(bool on) async { + try { + await _channel.invokeMethod('setMaxBrightness', on); + } on MissingPluginException { + // Tests and other platforms keep their brightness. + } + } +} diff --git a/lib/src/sharing/recipe_code.dart b/lib/src/sharing/recipe_code.dart new file mode 100644 index 0000000..32d4cc0 --- /dev/null +++ b/lib/src/sharing/recipe_code.dart @@ -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 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; + final entry = BundleCodec.decodeRecipe(payload['recipe'] as Map, 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 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(); +} diff --git a/lib/src/sharing/recipe_share_service.dart b/lib/src/sharing/recipe_share_service.dart new file mode 100644 index 0000000..dbe1fd7 --- /dev/null +++ b/lib/src/sharing/recipe_share_service.dart @@ -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 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 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 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 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 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; + } +} diff --git a/lib/src/sharing/shared_recipe.dart b/lib/src/sharing/shared_recipe.dart new file mode 100644 index 0000000..54fbf91 --- /dev/null +++ b/lib/src/sharing/shared_recipe.dart @@ -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 units; + + /// Photo files in order (only a Recipe Bundle carries them). + final List photos; +} diff --git a/lib/src/sharing/views/scan_recipe_view.dart b/lib/src/sharing/views/scan_recipe_view.dart new file mode 100644 index 0000000..7c36f83 --- /dev/null +++ b/lib/src/sharing/views/scan_recipe_view.dart @@ -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 createState() => _ScanRecipeViewState(); +} + +class _ScanRecipeViewState extends State { + 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().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), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/sharing/views/share_recipe_view.dart b/lib/src/sharing/views/share_recipe_view.dart new file mode 100644 index 0000000..871080b --- /dev/null +++ b/lib/src/sharing/views/share_recipe_view.dart @@ -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 createState() => _ShareRecipeViewState(); +} + +class _ShareRecipeViewState extends State { + 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 _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 _load() async { + final recipe = await context.read().getRecipe(widget.recipeId); + if (!mounted || recipe == null) return; + _recipe = recipe; + await _makeCode(); + } + + Future _makeCode() async { + final text = await context.read().recipeCode(_recipe!, includeNotes: _includeNotes); + if (!mounted) return; + setState(() { + try { + _code = RecipeCode.qrCode(text); + _tooLong = false; + } on RecipeCodeException { + _code = null; + _tooLong = true; + } + }); + } + + Future _shareFile() async { + final recipe = _recipe!; + setState(() => _sending = true); + try { + final bytes = await context.read().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), + ), + ], + ), + ); + } +} diff --git a/lib/src/sharing/views/shared_recipe_view.dart b/lib/src/sharing/views/shared_recipe_view.dart new file mode 100644 index 0000000..f165962 --- /dev/null +++ b/lib/src/sharing/views/shared_recipe_view.dart @@ -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 createState() => _SharedRecipeViewState(); +} + +class _SharedRecipeViewState extends State { + late final Future _nameTaken = context.read().hasRecipeNamed(widget.shared.recipe.name); + var _saving = false; + + Future _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().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(); + 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( + 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), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..5362470 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1127 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" + source: hosted + version: "88.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" + source: hosted + version: "8.1.1" + archive: + dependency: "direct main" + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + drift: + dependency: "direct main" + description: + name: drift + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 + url: "https://pub.dev" + source: hosted + version: "0.2.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + url: "https://pub.dev" + source: hosted + version: "11.0.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + url: "https://pub.dev" + source: hosted + version: "10.0.9" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0" + url: "https://pub.dev" + source: hosted + version: "7.4.2" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + qr: + dependency: "direct main" + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqlite3: + dependency: "direct dev" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" + url: "https://pub.dev" + source: hosted + version: "0.43.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + url: "https://pub.dev" + source: hosted + version: "6.3.20" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.8.1 <4.0.0" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..94bfdae --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,75 @@ +name: cookit +description: "CookIt – Rezeptsammlung und Wochenplaner." +publish_to: 'none' +version: 0.4.0+4 + +environment: + sdk: ^3.8.1 + +dependencies: + archive: ^4.5.2 + drift: ^2.31.0 + drift_flutter: ^0.2.8 + file_picker: ^11.0.3 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_svg: ^2.2.3 + image_picker: ^1.2.1 + intl: ^0.20.2 + mobile_scanner: ^7.4.2 + path: ^1.9.1 + path_provider: ^2.1.5 + provider: ^6.1.5+1 + qr: ^3.0.2 + qr_flutter: ^4.1.0 + share_plus: ^12.0.2 + shared_preferences: ^2.5.3 + url_launcher: ^6.3.2 + uuid: ^4.6.0 + wakelock_plus: ^1.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + drift_dev: ^2.31.0 + build_runner: ^2.15.1 + mocktail: ^1.0.5 + sqlite3: ^2.9.4 + flutter_launcher_icons: ^0.14.4 + +flutter: + uses-material-design: true + generate: true + + fonts: + - family: Nunito + fonts: + - asset: assets/fonts/Nunito-600.ttf + weight: 600 + - asset: assets/fonts/Nunito-700.ttf + weight: 700 + - asset: assets/fonts/Nunito-800.ttf + weight: 800 + - asset: assets/fonts/Nunito-900.ttf + weight: 900 + - family: NunitoSans + fonts: + - asset: assets/fonts/NunitoSans-400.ttf + weight: 400 + - asset: assets/fonts/NunitoSans-600.ttf + weight: 600 + - asset: assets/fonts/NunitoSans-700.ttf + weight: 700 + - asset: assets/fonts/NunitoSans-800.ttf + weight: 800 + +flutter_launcher_icons: + android: true + ios: false + image_path: assets/icon/icon.png + adaptive_icon_background: "#65A86C" + adaptive_icon_foreground: assets/icon/icon_foreground.png + min_sdk_android: 21 diff --git a/test/bundle/bundle_service_test.dart b/test/bundle/bundle_service_test.dart new file mode 100644 index 0000000..f47fc27 --- /dev/null +++ b/test/bundle/bundle_service_test.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:cookit/src/bundle/bundle_codec.dart'; +import 'package:cookit/src/bundle/bundle_service.dart'; +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/unit_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +class _App { + _App(this.db, this.photos) + : recipes = RecipeRepository(db, photos), + courses = CourseRepository(db), + units = UnitRepository(db) { + bundles = BundleService(recipes, courses, units, photos, clock: () => testTime); + } + + final AppDatabase db; + final PhotoStore photos; + final RecipeRepository recipes; + final CourseRepository courses; + final UnitRepository units; + late final BundleService bundles; + + static Future<_App> create() async => _App(testDatabase(), await tempPhotoStore()); + + Future close() async { + await db.close(); + await photos.directory.delete(recursive: true); + } +} + +Uint8List _zip(Map> files) { + final archive = Archive(); + files.forEach((name, bytes) => archive.add(ArchiveFile.bytes(name, bytes))); + return ZipEncoder().encodeBytes(archive); +} + +void main() { + late _App source; + late _App target; + + setUp(() async { + source = await _App.create(); + target = await _App.create(); + }); + + tearDown(() async { + await source.close(); + await target.close(); + }); + + test('export then import reproduces the collection with Photos and Courses', () async { + final course = await source.courses.addCourse('Suppe', emoji: '🍲'); + final photo = await source.photos.saveBytes(Uint8List.fromList([9, 8, 7])); + await source.recipes.saveRecipe(recipe(courseId: course.id, photos: [photo], favourite: true)); + await source.recipes.saveRecipe(recipe(id: 'r2', name: 'Brot')); + + final bytes = await source.bundles.exportBundle(); + final result = await target.bundles.importBundle(bytes); + + expect((result.added, result.skipped), (2, 0)); + final imported = (await target.recipes.watchRecipe('r1').first)!; + expect(imported.favourite, isTrue); + expect(imported.photos, hasLength(1)); + expect(await target.photos.read(imported.photos.single), [9, 8, 7]); + final targetCourse = (await target.courses.allCourses()).firstWhere((c) => c.id == imported.courseId); + expect(targetCourse.name, 'Suppe'); + expect( + [for (final g in imported.ingredientGroups) g.toJson()], + [for (final g in recipe().ingredientGroups) g.toJson()], + ); + }); + + test('own units travel with the export and are created on import', () async { + final cube = await source.units.addOwn('Würfel'); + await source.units.setDirect(cube.key, false); + await source.recipes.saveRecipe( + recipe( + ingredientGroups: [ + IngredientGroup( + lines: [IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe')], + ), + ], + ), + ); + + await target.bundles.importBundle(await source.bundles.exportBundle()); + + final imported = (await target.units.allUnits()).firstWhere((u) => u.key == cube.key); + expect((imported.label, imported.direct, imported.builtIn), ('Würfel', false, false)); + final line = (await target.recipes.getRecipe('r1'))!.ingredientGroups.single.lines.single; + expect(line.unitKey, cube.key); + }); + + test('importing again skips Recipes that are already there', () async { + await source.recipes.saveRecipe(recipe()); + final bytes = await source.bundles.exportBundle(); + await target.bundles.importBundle(bytes); + final again = await target.bundles.importBundle(bytes); + expect((again.added, again.skipped), (0, 1)); + expect(await target.recipes.allRecipes(), hasLength(1)); + }); + + test('a Course named like an existing one is reused', () async { + final manifest = BundleCodec.encodeManifest([ + BundleCodec.encodeRecipe(recipe(), courseName: 'Dessert', photoPaths: const []), + ], createdAt: testTime); + await target.bundles.importBundle(_zip({'bundle.json': utf8.encode(jsonEncode(manifest))})); + final courses = await target.courses.allCourses(); + expect(courses.where((c) => c.name == 'Dessert'), hasLength(1)); + expect((await target.recipes.allRecipes()).single.courseId, courses.firstWhere((c) => c.name == 'Dessert').id); + }); + + test('rejects files that are not Recipe Bundles', () async { + expect( + () => target.bundles.importBundle(Uint8List.fromList(utf8.encode('hello'))), + throwsA(isA()), + ); + expect( + () => target.bundles.importBundle( + _zip({ + 'other.txt': [1], + }), + ), + throwsA(isA()), + ); + expect( + () => target.bundles.importBundle( + _zip({ + 'bundle.json': utf8.encode(jsonEncode({'format': 'cookit.recipe-bundle', 'version': 99, 'recipes': []})), + }), + ), + throwsA(isA()), + ); + }); + + // Contract test against the Excel converter's real output, when it has been generated. + final converted = File('tools/excel-import/out/cookit-rezepte.zip'); + test( + 'imports the converted Excel workbook', + () async { + final result = await target.bundles.importBundle(await converted.readAsBytes()); + expect(result.added, greaterThan(200)); + final all = await target.recipes.allRecipes(); + final focaccia = all.firstWhere((r) => r.name == 'Focaccia'); + expect(focaccia.stepSections.map((s) => s.title).take(2), ['Vorteig', 'Teig']); + final shake = all.firstWhere((r) => r.name == 'Melonen-Minze-Shake'); + expect(shake.photos, hasLength(3)); + expect(target.photos.file(shake.photos.first).existsSync(), isTrue); + expect(all.where((r) => r.needsReview), isNotEmpty); + }, + skip: converted.existsSync() ? false : 'run tools/excel-import/convert.py first', + timeout: const Timeout(Duration(minutes: 2)), + ); +} diff --git a/test/cooking_mode/cooking_session_test.dart b/test/cooking_mode/cooking_session_test.dart new file mode 100644 index 0000000..732e66b --- /dev/null +++ b/test/cooking_mode/cooking_session_test.dart @@ -0,0 +1,52 @@ +import 'package:cookit/src/cooking_mode/cooking_session.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + test('steps run through all Step Sections with continuous numbers', () { + final session = CookingSession( + recipe( + stepSections: const [ + StepSection(steps: ['Ofen vorheizen.']), + StepSection(title: 'Teig', steps: ['Kneten.', 'Ruhen lassen.']), + ], + ), + ); + expect( + [for (final s in session.steps) (s.number, s.section, s.text)], + [(1, null, 'Ofen vorheizen.'), (2, 'Teig', 'Kneten.'), (3, 'Teig', 'Ruhen lassen.')], + ); + }); + + test('more servings scale every amount', () { + final session = CookingSession(recipe()); // 4 servings: 300 g Mehl, 2–3 EL Öl + expect(session.factor, 1); + session + ..moreServings() + ..moreServings(); + expect(session.servings, 6); + final lines = session.ingredientGroups.first.lines; + expect(lines[0].amount, 450); + expect((lines[1].amount, lines[1].amountMax), (3, 4.5)); + }); + + test('servings never go below one and fractional servings step to whole numbers', () { + final session = CookingSession(Recipe(id: 'x', name: 'x', servings: 2.5, createdAt: testTime, updatedAt: testTime)); + session.fewerServings(); + expect(session.servings, 2); + session + ..fewerServings() + ..fewerServings(); + expect(session.servings, 1); + session.moreServings(); + expect(session.servings, 2); + }); + + test('a Recipe without Servings is not scaled', () { + final session = CookingSession(Recipe(id: 'x', name: 'x', createdAt: testTime, updatedAt: testTime)); + expect(session.canScale, isFalse); + expect(session.factor, 1); + }); +} diff --git a/test/planning/day_test.dart b/test/planning/day_test.dart new file mode 100644 index 0000000..8fe98c6 --- /dev/null +++ b/test/planning/day_test.dart @@ -0,0 +1,38 @@ +import 'package:cookit/src/planning/day.dart'; +import 'package:cookit/src/planning/day_format.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + setUpAll(() => initializeDateFormatting('de')); + + final friday = DateTime.utc(2026, 10, 23); + + test('weekStartOf honours the configured first weekday', () { + expect(weekStartOf(friday, DateTime.monday), DateTime.utc(2026, 10, 19)); + expect(weekStartOf(friday, DateTime.tuesday), DateTime.utc(2026, 10, 20)); + expect(weekStartOf(friday, DateTime.friday), friday); + expect(weekStartOf(friday, DateTime.saturday), DateTime.utc(2026, 10, 17)); + }); + + test('days survive daylight saving time and storage', () { + // Summer time ends in Germany on 25 Oct 2026. + expect(addDays(DateTime.utc(2026, 10, 24), 2), DateTime.utc(2026, 10, 26)); + expect(weekDays(DateTime.utc(2026, 10, 26)).last, DateTime.utc(2026, 11, 1)); + expect(dayKey(friday), '2026-10-23'); + expect(parseDayKey('2026-10-23'), friday); + expect(dayOf(DateTime(2026, 10, 23, 23, 59)), friday); + }); + + test('calendarWeek is the ISO week, also across the turn of the year', () { + expect(calendarWeek(DateTime.utc(2026, 10, 19)), 43); + expect(calendarWeek(DateTime.utc(2026, 10, 20)), 43); // a Tuesday-start week + expect(calendarWeek(DateTime.utc(2026, 12, 28)), 53); + expect(calendarWeek(DateTime.utc(2027, 1, 4)), 1); + }); + + test('dayRange', () { + expect(dayRange(DateTime.utc(2026, 10, 19), DateTime.utc(2026, 10, 25)), '19.–25. Okt.'); + expect(dayRange(DateTime.utc(2026, 10, 26), DateTime.utc(2026, 11, 1)), '26. Okt. – 1. Nov.'); + }); +} diff --git a/test/planning/ingredient_totals_test.dart b/test/planning/ingredient_totals_test.dart new file mode 100644 index 0000000..a50e2b7 --- /dev/null +++ b/test/planning/ingredient_totals_test.dart @@ -0,0 +1,56 @@ +import 'package:cookit/src/planning/ingredient_totals.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + Recipe withLines(String id, List lines) => recipe( + id: id, + ingredientGroups: [IngredientGroup(lines: lines)], + ); + + test('lines with the same name and Unit are added up, others stay apart', () { + final totals = ingredientTotals([ + withLines('a', const [ + IngredientLine(amount: 200, unitKey: 'gram', name: 'Butter'), + IngredientLine(amount: 2, name: 'Eier'), + IngredientLine(name: 'Salz'), + ]), + withLines('b', const [ + IngredientLine(amount: 200, unitKey: 'gram', name: 'butter '), + IngredientLine(amount: 1, unitKey: 'tablespoon', name: 'Butter'), + IngredientLine(amount: 1, amountMax: 2, name: 'Eier'), + IngredientLine(name: 'Salz'), + ]), + ]); + expect( + [for (final l in totals) (l.amount, l.amountMax, l.unitKey, l.name)], + [ + (400.0, null, 'gram', 'Butter'), + (3.0, 4.0, null, 'Eier'), + (null, null, null, 'Salz'), + (1.0, null, 'tablespoon', 'Butter'), + ], + ); + }); + + test('a remark survives only when every line agrees', () { + final totals = ingredientTotals([ + withLines('a', const [ + IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'), + IngredientLine(amount: 1, name: 'Ei', remark: 'groß'), + ]), + withLines('b', const [ + IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'), + IngredientLine(amount: 1, name: 'Ei'), + ]), + ]); + expect([for (final l in totals) l.remark], ['bio', null]); + }); + + test('the same Recipe planned twice counts twice', () { + final r = withLines('a', const [IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl')]); + expect(ingredientTotals([r, r]).single.amount, 200); + }); +} diff --git a/test/planning/plan_repository_test.dart b/test/planning/plan_repository_test.dart new file mode 100644 index 0000000..fc53e88 --- /dev/null +++ b/test/planning/plan_repository_test.dart @@ -0,0 +1,126 @@ +import 'dart:io'; + +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/planning/models/meal.dart'; +import 'package:cookit/src/planning/plan_repository.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +void main() { + late AppDatabase db; + late PhotoStore photos; + late PlanRepository plan; + late RecipeRepository recipes; + + final yesterday = DateTime.utc(2026, 10, 22); + final today = DateTime.utc(2026, 10, 23); + final tomorrow = DateTime.utc(2026, 10, 24); + + setUp(() async { + db = testDatabase(); + photos = await tempPhotoStore(); + plan = PlanRepository(db); + recipes = RecipeRepository(db, photos); + }); + + tearDown(() async { + await db.close(); + await photos.directory.delete(recursive: true); + }); + + Future meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name); + + test('a fresh database plans Mittag and Abend', () async { + expect((await plan.allMeals()).map((m) => m.name), defaultMeals); + }); + + test('entries are added in order, moved to the end of another Meal and removed', () async { + await recipes.saveRecipe(recipe()); + final abend = await meal('Abend'); + final mittag = await meal('Mittag'); + await plan.addEntry(today, abend.id, recipeId: 'r1'); + await plan.addEntry(today, abend.id, note: 'Salat'); + await plan.addEntry(tomorrow, mittag.id, note: 'Reste'); + + var entries = await plan.allEntries(); + expect( + [for (final e in entries) (e.day, e.recipeId ?? e.note, e.position)], + [(today, 'r1', 0), (today, 'Salat', 1), (tomorrow, 'Reste', 0)], + ); + + await plan.moveEntry(entries.first.id, tomorrow, mittag.id); + await plan.removeEntry(entries[1].id); + entries = await plan.allEntries(); + expect( + [for (final e in entries) (e.day, e.mealId, e.recipeId ?? e.note, e.position)], + [(tomorrow, mittag.id, 'Reste', 0), (tomorrow, mittag.id, 'r1', 1)], + ); + }); + + test('deleting a Meal removes its upcoming entries and keeps it retired for the past', () async { + final abend = await meal('Abend'); + await plan.addEntry(yesterday, abend.id, note: 'Pizza'); + await plan.addEntry(today, abend.id, note: 'Suppe'); + await plan.addEntry(tomorrow, abend.id, note: 'Curry'); + expect(await plan.upcomingEntryCount(abend.id, today), 2); + + await plan.deleteMeal(abend.id, today); + expect((await plan.allEntries()).map((e) => e.note), ['Pizza']); + expect((await meal('Abend')).retired, isTrue); + + // Added again under the same name, the old Meal comes back with its history. + await plan.addMeal('abend'); + final back = await meal('abend'); + expect(back.id, abend.id); + expect(back.retired, isFalse); + }); + + test('deleting an unused Meal removes it entirely', () async { + final mittag = await meal('Mittag'); + await plan.deleteMeal(mittag.id, today); + expect((await plan.allMeals()).map((m) => m.name), ['Abend']); + }); + + test('a deleted Recipe stays in the plan as a note with its name', () async { + await recipes.saveRecipe(recipe()); + final abend = await meal('Abend'); + await plan.addEntry(yesterday, abend.id, recipeId: 'r1'); + await recipes.deleteRecipe(recipe()); + final entry = (await plan.allEntries()).single; + expect(entry.recipeId, isNull); + expect(entry.note, 'Focaccia'); + }); + + test('"Tauschen" plans another Recipe in the same entry', () async { + await recipes.saveRecipe(recipe()); + await recipes.saveRecipe(recipe(id: 'r2', name: 'Pizza')); + final abend = await meal('Abend'); + await plan.addEntry(today, abend.id, recipeId: 'r1'); + await plan.replaceRecipe((await plan.allEntries()).single.id, 'r2'); + expect((await plan.allEntries()).single.recipeId, 'r2'); + }); + + test('an existing version 2 database gets the Week Plan tables on upgrade', () async { + await db.close(); + final dir = await Directory.systemTemp.createTemp('cookit_db_'); + final file = File('${dir.path}/cookit.sqlite'); + + var fileDb = testDatabase(file: file); + await RecipeRepository(fileDb, photos).saveRecipe(recipe()); + await fileDb.customStatement('DROP TABLE plan_entries'); + await fileDb.customStatement('DROP TABLE meals'); + await fileDb.customStatement('PRAGMA user_version = 2'); + await fileDb.close(); + + fileDb = testDatabase(file: file); + expect((await PlanRepository(fileDb).allMeals()).map((m) => m.name), defaultMeals); + expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia'); + await fileDb.close(); + await dir.delete(recursive: true); + db = testDatabase(); // for tearDown + }); +} diff --git a/test/planning/plan_share_service_test.dart b/test/planning/plan_share_service_test.dart new file mode 100644 index 0000000..448437b --- /dev/null +++ b/test/planning/plan_share_service_test.dart @@ -0,0 +1,100 @@ +import 'package:cookit/src/bundle/bundle_service.dart'; +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/planning/day.dart'; +import 'package:cookit/src/planning/plan_repository.dart'; +import 'package:cookit/src/planning/plan_share_service.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/unit_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +/// One cook's collection and Week Plan. +class Phone { + Phone._(this.db, this.photos) + : recipes = RecipeRepository(db, photos), + courses = CourseRepository(db), + units = UnitRepository(db), + plan = PlanRepository(db) { + bundles = BundleService(recipes, courses, units, photos); + shares = PlanShareService(recipes, courses, units, photos, plan, bundles); + } + + static Future create() async { + final phone = Phone._(testDatabase(), await tempPhotoStore()); + addTearDown(() async { + await phone.db.close(); + await phone.photos.directory.delete(recursive: true); + }); + return phone; + } + + final AppDatabase db; + final PhotoStore photos; + final RecipeRepository recipes; + final CourseRepository courses; + final UnitRepository units; + final PlanRepository plan; + late final BundleService bundles; + late final PlanShareService shares; + + Future meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name).id; +} + +void main() { + final monday = DateTime.utc(2026, 10, 19); + + test('a shared week reuses known Recipes, adds new ones and its Meals, and keeps what is planned', () async { + final mine = await Phone.create(); + final theirs = await Phone.create(); + + // Mine: three Recipes planned across the week, plus a note and a Meal they don't have. + await mine.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia')); + await mine.recipes.saveRecipe(recipe(id: 'mine-pizza', name: 'Pizza')); + await mine.recipes.saveRecipe(recipe(id: 'new', name: 'Gulasch')); + await mine.plan.addMeal('Frühstück'); + await mine.plan.addEntry(monday, await mine.meal('Abend'), recipeId: 'shared'); + await mine.plan.addEntry(addDays(monday, 1), await mine.meal('Abend'), recipeId: 'mine-pizza'); + await mine.plan.addEntry(addDays(monday, 2), await mine.meal('Mittag'), recipeId: 'new'); + await mine.plan.addEntry(addDays(monday, 3), await mine.meal('Frühstück'), note: 'Bäcker'); + await mine.plan.addEntry(addDays(monday, 8), await mine.meal('Abend'), recipeId: 'new'); // next week: not sent + + // Theirs: the same Focaccia (same id), their own Pizza (same name), and a plan of their own. + await theirs.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia')); + await theirs.recipes.saveRecipe(recipe(id: 'their-pizza', name: 'pizza ')); + await theirs.plan.addEntry(monday, await theirs.meal('Mittag'), note: 'Reste'); + + final week = SharedWeek(await theirs.bundles.read(await mine.shares.weekFile(monday))); + final preview = await theirs.shares.preview(week); + expect((preview.entries, preview.newRecipes, preview.existingEntries), (4, 1, 1)); + + await theirs.shares.adopt(week); + + final recipes = await theirs.recipes.allRecipes(); + expect(recipes.map((r) => r.id), unorderedEquals(['shared', 'their-pizza', 'new'])); + final meals = {for (final m in await theirs.plan.allMeals()) m.id: m.name}; + final entries = await theirs.plan.allEntries(); + expect( + [for (final e in entries) (e.day, meals[e.mealId], e.recipeId ?? e.note)], + unorderedEquals([ + (monday, 'Mittag', 'Reste'), + (monday, 'Abend', 'shared'), + (addDays(monday, 1), 'Abend', 'their-pizza'), + (addDays(monday, 2), 'Mittag', 'new'), + (addDays(monday, 3), 'Frühstück', 'Bäcker'), + ]), + ); + }); + + test('only a bundle shared from the Week Plan carries a week', () async { + final mine = await Phone.create(); + await mine.recipes.saveRecipe(recipe()); + final contents = await mine.bundles.read(await mine.bundles.exportBundle()); + expect(contents.plan, isNull); + final week = await mine.bundles.read(await mine.shares.weekFile(monday)); + expect(week.plan, isNotNull); + }); +} diff --git a/test/planning/week_fill_test.dart b/test/planning/week_fill_test.dart new file mode 100644 index 0000000..5ed58ca --- /dev/null +++ b/test/planning/week_fill_test.dart @@ -0,0 +1,65 @@ +import 'dart:math'; + +import 'package:cookit/src/planning/week_fill.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + final days = [for (var d = 19; d <= 25; d++) DateTime.utc(2026, 10, d)]; + final recipes = [for (var i = 0; i < 5; i++) recipe(id: 'r$i', name: 'R$i')]; + + test('fills each empty day with a different Recipe, never one already in the week', () { + final filled = fillWeek( + emptyDays: days.take(3).toList(), + candidates: recipes, + inWeek: {'r0'}, + recent: const {}, + random: Random(1), + ); + expect(filled.keys, days.take(3)); + final ids = filled.values.map((r) => r.id).toList(); + expect(ids.toSet(), hasLength(3)); + expect(ids, isNot(contains('r0'))); + }); + + test('recently planned Recipes come only after the others', () { + for (var seed = 0; seed < 20; seed++) { + final filled = fillWeek( + emptyDays: days.take(4).toList(), + candidates: recipes, + inWeek: const {}, + recent: {'r0', 'r1'}, + random: Random(seed), + ); + final ids = filled.values.map((r) => r.id).toList(); + expect(ids.take(3).toSet(), {'r2', 'r3', 'r4'}); + expect(ids.last, anyOf('r0', 'r1')); + } + }); + + test('days stay empty when the Recipes run out', () { + final filled = fillWeek( + emptyDays: days, + candidates: recipes.take(2).toList(), + inWeek: const {}, + recent: const {}, + random: Random(2), + ); + expect(filled, hasLength(2)); + }); + + test('pickReplacement skips the week and returns null when nothing is left', () { + final pick = pickReplacement( + candidates: recipes, + inWeek: {'r0', 'r1', 'r2', 'r3'}, + recent: const {}, + random: Random(), + ); + expect(pick?.id, 'r4'); + expect( + pickReplacement(candidates: recipes, inWeek: {for (final r in recipes) r.id}, recent: const {}, random: Random()), + isNull, + ); + }); +} diff --git a/test/recipes/amount_test.dart b/test/recipes/amount_test.dart new file mode 100644 index 0000000..8f8fa6f --- /dev/null +++ b/test/recipes/amount_test.dart @@ -0,0 +1,47 @@ +import 'package:cookit/src/recipes/amount.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + setUpAll(() => initializeDateFormatting('de')); + + group('parseAmount', () { + final cases = { + '': (amount: null, amountMax: null), + '2': (amount: 2, amountMax: null), + '1,5': (amount: 1.5, amountMax: null), + '0.25': (amount: 0.25, amountMax: null), + '½': (amount: 0.5, amountMax: null), + '1½': (amount: 1.5, amountMax: null), + '1/2': (amount: 0.5, amountMax: null), + '1 1/2': (amount: 1.5, amountMax: null), + '2-3': (amount: 2, amountMax: 3), + '10 – 12': (amount: 10, amountMax: 12), + '1 bis 2': (amount: 1, amountMax: 2), + 'etwas': null, + '2-': null, + }; + cases.forEach((input, expected) { + test('"$input"', () => expect(parseAmount(input), expected)); + }); + }); + + group('formatAmount', () { + test('uses fraction glyphs and German decimals', () { + expect(formatAmount(1.5, null), '1½'); + expect(formatAmount(0.25, null), '¼'); + expect(formatAmount(0.333, null), '⅓'); + expect(formatAmount(2, null), '2'); + expect(formatAmount(1.4, null), '1,4'); + expect(formatAmount(1000, null), '1.000'); + expect(formatAmount(2, 3), '2–3'); + expect(formatAmount(null, null), ''); + }); + + test('round-trips through parseAmount', () { + for (final value in [0.5, 1.5, 2.0, 1.4, 250.0]) { + expect(parseAmount(formatAmount(value, null))!.amount, closeTo(value, 0.001)); + } + }); + }); +} diff --git a/test/recipes/decision_test.dart b/test/recipes/decision_test.dart new file mode 100644 index 0000000..1b79811 --- /dev/null +++ b/test/recipes/decision_test.dart @@ -0,0 +1,47 @@ +import 'package:cookit/src/recipes/decision.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + Recipe make(String id, {String? course, int? prep, int? cook}) => Recipe( + id: id, + name: id, + courseId: course, + prepMinutes: prep, + cookMinutes: cook, + createdAt: testTime, + updatedAt: testTime, + ); + + final quickPasta = make('quick pasta', course: 'main', prep: 10, cook: 15); + final roast = make('roast', course: 'main', prep: 30, cook: 90); + final cake = make('cake', course: 'dessert', prep: 20, cook: 40); + final noTime = make('no time', course: 'main'); + final all = [quickPasta, roast, cake, noTime]; + + List names(DecisionChoice c) => c.matching(all).map((r) => r.name).toList(); + + test('no answers match everything', () { + expect(names(const DecisionChoice()), ['quick pasta', 'roast', 'cake', 'no time']); + }); + + test('several Courses can be chosen', () { + expect(names(const DecisionChoice(courseIds: {'dessert'})), ['cake']); + expect(names(const DecisionChoice(courseIds: {'dessert', 'main'})), hasLength(4)); + }); + + test('a time limit uses the total time and leaves out Recipes without one', () { + expect(names(const DecisionChoice(time: TimeLimit.upTo30)), ['quick pasta']); + expect(names(const DecisionChoice(time: TimeLimit.upTo60)), ['quick pasta', 'cake']); + expect(names(const DecisionChoice(time: TimeLimit.any)), contains('no time')); + }); + + test('answers combine, and can be loosened one at a time', () { + const choice = DecisionChoice(courseIds: {'main'}, time: TimeLimit.upTo60); + expect(names(choice), ['quick pasta']); + expect(names(choice.withoutTimeLimit()), ['quick pasta', 'roast', 'no time']); + expect(names(choice.withAllCourses()), ['quick pasta', 'cake']); + }); +} diff --git a/test/recipes/ingredients_text_test.dart b/test/recipes/ingredients_text_test.dart new file mode 100644 index 0000000..4592a0f --- /dev/null +++ b/test/recipes/ingredients_text_test.dart @@ -0,0 +1,35 @@ +import 'package:cookit/src/recipes/ingredients_text.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +import '../support/fixtures.dart'; + +void main() { + setUpAll(() => initializeDateFormatting('de')); + + String label(String key) => {'gram': 'g', 'tablespoon': 'EL'}[key]!; + + test('one Ingredient Line per line, groups separated by an empty line', () { + expect( + ingredientsAsText(recipe().ingredientGroups, label), + '300 g Mehl\n' + '2–3 EL Olivenöl (gutes)\n' + '\n' + 'Rosmarin', + ); + }); + + test('scaled amounts and empty groups', () { + final groups = [ + const IngredientGroup(lines: []), + const IngredientGroup(lines: [IngredientLine(amount: 1, name: 'Ei')]), + ]; + expect( + ingredientsAsText([ + for (final g in groups) IngredientGroup(lines: [for (final l in g.lines) l.scaled(1.5)]), + ], label), + '1½ Ei', + ); + }); +} diff --git a/test/recipes/recipe_draft_test.dart b/test/recipes/recipe_draft_test.dart new file mode 100644 index 0000000..611a20b --- /dev/null +++ b/test/recipes/recipe_draft_test.dart @@ -0,0 +1,82 @@ +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/recipe_draft.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + setUpAll(() => initializeDateFormatting('de')); + + group('ingredients', () { + const groups = [ + IngredientGroup( + lines: [ + IngredientLine(amount: 300, unitKey: 'gram', name: 'Mehl'), + IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes'), + ], + ), + IngredientGroup(lines: [IngredientLine(name: 'Salz')]), + IngredientGroup( + name: 'Topping', + lines: [IngredientLine(amount: 0.5, unitKey: 'pinch', name: 'Rosmarin')], + ), + ]; + + test('the first unnamed group has no heading; later groups get one', () { + final entries = ingredientEntriesFrom(groups); + expect(entries.map((e) => e.runtimeType), [LineEntry, LineEntry, GroupEntry, LineEntry, GroupEntry, LineEntry]); + expect((entries[1] as LineEntry).amount.text, '2–3'); + expect((entries[5] as LineEntry).amount.text, '½'); + }); + + test('round-trips', () { + final back = ingredientGroupsFrom(ingredientEntriesFrom(groups)); + expect([for (final g in back) g.toJson()], [for (final g in groups) g.toJson()]); + }); + + test('lines without a name and empty groups are dropped', () { + final entries = [ + GroupEntry('Leer'), + LineEntry()..name.text = ' ', + GroupEntry(' Sauce '), + LineEntry() + ..amount.text = '1,5' + ..name.text = 'Sahne', + ]; + final groups = ingredientGroupsFrom(entries); + expect(groups, hasLength(1)); + expect(groups.single.name, 'Sauce'); + expect(groups.single.lines.single.amount, 1.5); + }); + + test('a heading with an empty name becomes an unnamed group', () { + final entries = [LineEntry()..name.text = 'Mehl', GroupEntry(''), LineEntry()..name.text = 'Zucker']; + expect(ingredientGroupsFrom(entries).map((g) => g.name), [null, null]); + }); + }); + + group('steps', () { + test('round-trips sections', () { + const sections = [ + StepSection(steps: ['Ofen vorheizen.']), + StepSection(title: 'Teig', steps: ['Kneten.', 'Ruhen lassen.']), + ]; + final back = stepSectionsFrom(stepEntriesFrom(sections)); + expect([for (final s in back) s.toJson()], [for (final s in sections) s.toJson()]); + }); + + test('empty steps are dropped', () { + final entries = [StepTextEntry(' '), SectionEntry('Soße'), StepTextEntry('Rühren.')]; + final sections = stepSectionsFrom(entries); + expect(sections.single.title, 'Soße'); + expect(sections.single.steps, ['Rühren.']); + }); + }); + + test('tagsFrom trims and removes duplicates ignoring case', () { + expect(tagsFrom('vegan, schnell , Vegan,,'), ['vegan', 'schnell']); + }); + + test('linesFrom', () { + expect(linesFrom(' https://a.de \n\nhttps://b.de'), ['https://a.de', 'https://b.de']); + }); +} diff --git a/test/recipes/recipe_filter_test.dart b/test/recipes/recipe_filter_test.dart new file mode 100644 index 0000000..123dd45 --- /dev/null +++ b/test/recipes/recipe_filter_test.dart @@ -0,0 +1,53 @@ +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/recipe_filter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + final kaesespaetzle = recipe( + id: 'a', + name: 'Käsespätzle', + courseId: 'main', + tags: ['schwäbisch'], + ingredientGroups: const [ + IngredientGroup( + lines: [ + IngredientLine(name: 'Bergkäse'), + IngredientLine(name: 'Zwiebeln'), + ], + ), + ], + favourite: true, + ); + final tiramisu = recipe(id: 'b', name: 'Tiramisu', courseId: 'dessert', reviewReasons: ['Portionen fehlen.']); + + List names(RecipeFilter f) => [kaesespaetzle, tiramisu].where(f.matches).map((r) => r.name).toList(); + + test('empty filter matches everything', () { + expect(names(const RecipeFilter()), ['Käsespätzle', 'Tiramisu']); + }); + + test('search ignores case and umlauts and looks at name, tags and ingredients', () { + expect(names(const RecipeFilter(query: 'kase')), ['Käsespätzle']); + expect(names(const RecipeFilter(query: 'SCHWABISCH')), ['Käsespätzle']); + expect(names(const RecipeFilter(query: 'zwiebel')), ['Käsespätzle']); + expect(names(const RecipeFilter(query: 'tira')), ['Tiramisu']); + }); + + test('every search word must match', () { + expect(names(const RecipeFilter(query: 'käse zwiebel')), ['Käsespätzle']); + expect(names(const RecipeFilter(query: 'käse tiramisu')), isEmpty); + }); + + test('course, favourites and review filters combine', () { + expect(names(const RecipeFilter(courseId: 'dessert')), ['Tiramisu']); + expect(names(const RecipeFilter(favouritesOnly: true)), ['Käsespätzle']); + expect(names(const RecipeFilter(reviewOnly: true)), ['Tiramisu']); + expect(names(const RecipeFilter(reviewOnly: true, favouritesOnly: true)), isEmpty); + }); + + test('normalizeForSearch', () { + expect(normalizeForSearch(' Größe Crêpes '), 'grosse crepes'); + }); +} diff --git a/test/recipes/recipe_model_test.dart b/test/recipes/recipe_model_test.dart new file mode 100644 index 0000000..35e81dc --- /dev/null +++ b/test/recipes/recipe_model_test.dart @@ -0,0 +1,18 @@ +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('unit keys round-trip through JSON, built-in or own', () { + for (final key in ['gram', 'bunch', 'own-1234']) { + final line = IngredientLine.fromJson(IngredientLine(amount: 1, unitKey: key, name: 'x').toJson()); + expect(line.unitKey, key); + } + }); + + test('scaled multiplies amount and range, keeps the rest', () { + const line = IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Öl', remark: 'gutes'); + final half = line.scaled(0.5); + expect((half.amount, half.amountMax, half.unitKey, half.name, half.remark), (1, 1.5, 'tablespoon', 'Öl', 'gutes')); + expect(const IngredientLine(name: 'Salz').scaled(2).amount, isNull); + }); +} diff --git a/test/recipes/repository_test.dart b/test/recipes/repository_test.dart new file mode 100644 index 0000000..df821dd --- /dev/null +++ b/test/recipes/repository_test.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/models/course.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +void main() { + late AppDatabase db; + late PhotoStore photos; + late RecipeRepository recipes; + late CourseRepository courses; + + setUp(() async { + db = testDatabase(); + photos = await tempPhotoStore(); + recipes = RecipeRepository(db, photos); + courses = CourseRepository(db); + }); + + tearDown(() async { + await db.close(); + await photos.directory.delete(recursive: true); + }); + + test('a fresh database starts with the default Courses in order', () async { + final all = await courses.allCourses(); + expect(all.map((c) => c.name), defaultCourses.map((c) => c.$1)); + expect(all.first.label, '🍝 Hauptgericht'); + }); + + test('saves and reads back a whole Recipe', () async { + final course = (await courses.allCourses()).first; + final original = recipe(courseId: course.id, tags: ['Brot'], reviewReasons: ['Portionen fehlen.']); + await recipes.saveRecipe(original); + + final read = (await recipes.watchRecipe('r1').first)!; + expect(read.name, 'Focaccia'); + expect(read.courseId, course.id); + expect(read.tags, ['Brot']); + expect(read.calories!.kcal, 775); + expect( + [for (final g in read.ingredientGroups) g.toJson()], + [for (final g in original.ingredientGroups) g.toJson()], + ); + expect(read.stepSections.single.title, 'Teig'); + expect(read.sources, ['https://example.org/focaccia']); + expect(read.needsReview, isTrue); + }); + + test('watchRecipes is sorted by name ignoring case', () async { + await recipes.saveRecipe(recipe(id: 'b', name: 'zwiebelkuchen')); + await recipes.saveRecipe(recipe(id: 'a', name: 'Apfelstrudel')); + await recipes.saveRecipe(recipe(id: 'c', name: 'Brot')); + final names = (await recipes.watchRecipes().first).map((r) => r.name); + expect(names, ['Apfelstrudel', 'Brot', 'zwiebelkuchen']); + }); + + test('favourite and clearing the Review Flag', () async { + await recipes.saveRecipe(recipe(reviewReasons: ['x'])); + await recipes.setFavourite('r1', true); + await recipes.clearReview('r1'); + final read = (await recipes.watchRecipe('r1').first)!; + expect(read.favourite, isTrue); + expect(read.needsReview, isFalse); + }); + + test('deleting a Recipe removes its Photo files', () async { + final photo = await photos.saveBytes(Uint8List.fromList([1, 2, 3])); + await recipes.saveRecipe(recipe(photos: [photo])); + await recipes.deleteRecipe((await recipes.watchRecipe('r1').first)!); + expect(await recipes.watchRecipe('r1').first, isNull); + expect(photos.file(photo).existsSync(), isFalse); + }); + + test('deleting a Course keeps its Recipes without one', () async { + final course = await courses.addCourse('Suppe', emoji: '🍲'); + await recipes.saveRecipe(recipe(courseId: course.id)); + expect(await courses.recipeCount(course.id), 1); + await courses.deleteCourse(course.id); + expect((await recipes.watchRecipe('r1').first)!.courseId, isNull); + }); + + test('findOrCreate matches names ignoring case', () async { + final existing = await courses.findOrCreate('dessert'); + expect(existing.name, 'Dessert'); + final created = await courses.findOrCreate('Suppe'); + expect(created.position, defaultCourses.length); + }); + + test('reorder and update Courses', () async { + final all = await courses.allCourses(); + await courses.reorder(all.reversed.toList()); + await courses.updateCourse(all.first.id, name: 'Hauptspeise', emoji: null); + final reordered = await courses.allCourses(); + expect(reordered.last.name, 'Hauptspeise'); + expect(reordered.last.emoji, isNull); + }); +} diff --git a/test/recipes/suggestion_test.dart b/test/recipes/suggestion_test.dart new file mode 100644 index 0000000..9d96846 --- /dev/null +++ b/test/recipes/suggestion_test.dart @@ -0,0 +1,115 @@ +import 'dart:math'; + +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/localization/app_localizations_de.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_list_provider.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/time_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +void main() { + group('Idee für heute', () { + late AppDatabase db; + late PhotoStore photos; + late RecipeRepository recipes; + var now = DateTime(2026, 10, 20, 18); + + setUp(() async { + db = testDatabase(); + photos = await tempPhotoStore(); + recipes = RecipeRepository(db, photos); + for (final (i, name) in ['Avocado Pasta', 'Brot', 'Curry', 'Dürüm'].indexed) { + await recipes.saveRecipe(recipe(id: 'r$i', name: name)); + } + }); + + tearDown(() async { + await db.close(); + await photos.directory.delete(recursive: true); + }); + + Future provider() async { + final p = RecipeListProvider(recipes, CourseRepository(db), clock: () => now, random: Random(1)); + await Future.delayed(const Duration(milliseconds: 20)); + return p; + } + + test('is stable within a day and drawn at random from day to day', () async { + final p = await provider(); + final today = p.suggestion; + now = DateTime(2026, 10, 20, 23, 59); + expect(p.suggestion!.id, today!.id); + + // Not the alphabetical walk it used to be: over two weeks the picks jump around. + final picks = []; + for (var d = 0; d < 14; d++) { + now = DateTime(2026, 11, 1 + d, 12); + picks.add(p.suggestion!.id); + } + expect(picks.toSet().length, greaterThan(2)); + final steps = [ + for (var i = 1; i < picks.length; i++) + (int.parse(picks[i].substring(1)) - int.parse(picks[i - 1].substring(1))) % 4, + ]; + expect(steps.every((s) => s == 1), isFalse); + p.dispose(); + }); + + test('"Andere Idee" holds until midnight only', () async { + now = DateTime(2026, 10, 20, 18); + final p = await provider(); + p.shuffleSuggestion(); + final shuffled = p.suggestion!.id; + now = DateTime(2026, 10, 20, 23, 0); + expect(p.suggestion!.id, shuffled); + now = DateTime(2026, 10, 21, 8); + final p2 = await provider(); + expect(p.suggestion!.id, p2.suggestion!.id); + p.dispose(); + p2.dispose(); + }); + + test('"Andere Idee" always picks a different Recipe', () async { + final p = await provider(); + for (var i = 0; i < 10; i++) { + final before = p.suggestion!.id; + p.shuffleSuggestion(); + expect(p.suggestion!.id, isNot(before)); + } + p.dispose(); + }); + }); + + group('time format', () { + final l10n = AppLocalizationsDe(); + + test('formatMinutes', () { + expect(formatMinutes(l10n, 45), '45 Min.'); + expect(formatMinutes(l10n, 60), '1 Std.'); + expect(formatMinutes(l10n, 70), '1 Std. 10 Min.'); + }); + + test('greeting follows the time of day', () { + final cases = { + (2, 0): 'Schönen Abend', + (5, 0): 'Guten Morgen', + (11, 29): 'Guten Morgen', + (11, 30): 'Mahlzeit!', + (13, 59): 'Mahlzeit!', + (14, 0): 'Guten Tag', + (17, 0): 'Guten Abend', + (21, 59): 'Guten Abend', + (22, 0): 'Schönen Abend', + }; + cases.forEach((time, expected) { + final (hour, minute) = time; + expect(greeting(l10n, DateTime(2026, 1, 1, hour, minute)), expected, reason: '$hour:$minute'); + }); + }); + }); +} diff --git a/test/recipes/text_breaks_test.dart b/test/recipes/text_breaks_test.dart new file mode 100644 index 0000000..1db62b3 --- /dev/null +++ b/test/recipes/text_breaks_test.dart @@ -0,0 +1,37 @@ +import 'package:cookit/src/recipes/text_breaks.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + String shown(String text) => withNaturalBreaks(text).replaceAll('​', '|'); + + test('breaks after hyphens, ampersands and slashes, and between words written together', () { + expect(shown('ApfelWeißkohlPfanne&Schupfi'), 'Apfel|Weißkohl|Pfanne&|Schupfi'); + expect(shown('Brokkoli-Sahne-Nudeln'), 'Brokkoli-|Sahne-|Nudeln'); + expect(shown('AvocadoMinze Smoothie'), 'Avocado|Minze Smoothie'); + expect(shown('Salz/Pfeffer'), 'Salz/|Pfeffer'); + expect(shown('Öl-Ärger'), 'Öl-|Ärger'); + }); + + test('leaves ordinary names and abbreviations alone', () { + expect(shown('Tomatensoße mit Spätzle'), 'Tomatensoße mit Spätzle'); + expect(shown('BBQ Burger'), 'BBQ Burger'); + expect(shown('Gef. Hähnchenfilet'), 'Gef. Hähnchenfilet'); + expect(shown('Rösti - knusprig'), 'Rösti - knusprig'); + expect(withNaturalBreaks('ApfelWeißkohl').replaceAll('​', ''), 'ApfelWeißkohl'); + }); + + test('a line now ends at a natural break, not mid-word', () { + // Test font: every glyph is fontSize wide. Room for 15 glyphs. + List lineWidths(String text) { + final p = TextPainter( + text: TextSpan(text: text, style: const TextStyle(fontSize: 10)), + textDirection: TextDirection.ltr, + )..layout(maxWidth: 155); + return [for (final l in p.computeLineMetrics()) l.width]; + } + + expect(lineWidths('ApfelWeißkohlPfanne'), [150, 40]); // "ApfelWeißkohlPf" | "anne" + expect(lineWidths(withNaturalBreaks('ApfelWeißkohlPfanne')), [130, 60]); // "ApfelWeißkohl" | "Pfanne" + }); +} diff --git a/test/recipes/unit_repository_test.dart b/test/recipes/unit_repository_test.dart new file mode 100644 index 0000000..26923a7 --- /dev/null +++ b/test/recipes/unit_repository_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/models/unit.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/unit_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +void main() { + late AppDatabase db; + late PhotoStore photos; + late UnitRepository units; + late RecipeRepository recipes; + + setUp(() async { + db = testDatabase(); + photos = await tempPhotoStore(); + units = UnitRepository(db); + recipes = RecipeRepository(db, photos); + }); + + tearDown(() async { + await db.close(); + await photos.directory.delete(recursive: true); + }); + + test('a fresh database lists every built-in unit, the common ones offered directly', () async { + final all = await units.allUnits(); + expect(all.map((u) => u.key), Unit.values.map((u) => u.key)); + expect(all.where((u) => u.direct).map((u) => u.key), [ + for (final u in Unit.values) + if (u.common) u.key, + ]); + expect(all.every((u) => u.builtIn), isTrue); + }); + + test('own units can be added, renamed, offered directly or not, and sorted', () async { + final cube = await units.addOwn('Würfel'); + expect(cube.key, startsWith('own-')); + await units.rename(cube.key, 'Brühwürfel'); + await units.setDirect(cube.key, false); + await units.setDirect('gram', false); + + final all = await units.allUnits(); + final reordered = [all.last, ...all.take(all.length - 1)]; + await units.reorder(reordered); + + final after = await units.allUnits(); + expect(after.first.key, cube.key); + expect((after.first.label, after.first.direct, after.first.builtIn), ('Brühwürfel', false, false)); + expect(after.firstWhere((u) => u.key == 'gram').direct, isFalse); + }); + + test('built-in units keep their name', () async { + await units.rename('gram', 'Gramm!'); + expect((await units.allUnits()).firstWhere((u) => u.key == 'gram').label, isNull); + }); + + test('deleting an own unit moves its name into the Ingredient Lines that used it', () async { + final cube = await units.addOwn('Würfel'); + await recipes.saveRecipe( + recipe( + ingredientGroups: [ + IngredientGroup( + lines: [ + IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe'), + const IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl'), + ], + ), + ], + ), + ); + await recipes.saveRecipe(recipe(id: 'r2', name: 'Ohne Würfel')); + expect(await units.recipeCount(cube.key), 1); + + await units.deleteOwn(cube); + + final lines = (await recipes.getRecipe('r1'))!.ingredientGroups.single.lines; + expect((lines[0].amount, lines[0].unitKey, lines[0].name), (2, null, 'Würfel Brühe')); + expect((lines[1].unitKey, lines[1].name), ('gram', 'Mehl')); + expect((await units.allUnits()).any((u) => u.key == cube.key), isFalse); + }); + + test('an existing version 1 database gets the units table on upgrade', () async { + await db.close(); + final dir = await Directory.systemTemp.createTemp('cookit_db_'); + final file = File('${dir.path}/cookit.sqlite'); + + var fileDb = testDatabase(file: file); + await RecipeRepository(fileDb, photos).saveRecipe(recipe()); + // Make it look like the database before units existed. + await fileDb.customStatement('DROP TABLE units'); + await fileDb.customStatement('PRAGMA user_version = 1'); + await fileDb.close(); + + fileDb = testDatabase(file: file); + expect(await UnitRepository(fileDb).allUnits(), hasLength(Unit.values.length)); + expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia'); + await fileDb.close(); + await dir.delete(recursive: true); + db = testDatabase(); // for tearDown + }); +} diff --git a/test/sharing/recipe_code_test.dart b/test/sharing/recipe_code_test.dart new file mode 100644 index 0000000..267686e --- /dev/null +++ b/test/sharing/recipe_code_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:cookit/src/bundle/bundle_codec.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/sharing/recipe_code.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; + +void main() { + final now = DateTime(2026, 10, 23); + + Matcher fails(RecipeCodeError error) => throwsA(isA().having((e) => e.error, 'error', error)); + + group('Base45 (RFC 9285 examples)', () { + const examples = {'AB': 'BB8', 'Hello!!': '%69 VD92EX0', 'base-45': 'UJCLQE7W581', 'ietf!': 'QED8WEX0'}; + examples.forEach((plain, encoded) { + test(plain, () { + expect(base45Encode(utf8.encode(plain)), encoded); + expect(utf8.decode(base45Decode(encoded)), plain); + }); + }); + + test('rejects text that is not Base45', () { + expect(() => base45Decode('GGW'), throwsFormatException); // 65536 + expect(() => base45Decode('ab'), throwsFormatException); + }); + }); + + test('a Recipe survives the round trip, without what is personal', () { + final text = RecipeCode.encode( + recipe(favourite: true, reviewReasons: ['Portionen fehlen.'], photos: ['a.jpg']), + courseName: 'Brot', + units: const [BundleUnit(key: 'own-1', label: 'Würfel', direct: true)], + ); + expect(text, startsWith('COOKIT1:')); + expect(RegExp(r'^[0-9A-Z $%*+\-./:]+$').hasMatch(text), isTrue, reason: 'QR alphanumeric mode only'); + + final shared = RecipeCode.decode(text, now: now); + final r = shared.recipe; + expect(shared.courseName, 'Brot'); + expect(shared.units.single.label, 'Würfel'); + expect(r.name, 'Focaccia'); + expect(r.servings, 4); + expect(r.prepMinutes, 30); + expect(r.calories?.kcal, 775); + expect(r.ingredientGroups[0].lines[1].amountMax, 3); + expect(r.ingredientGroups[0].lines[1].remark, 'gutes'); + expect(r.ingredientGroups[1].name, 'Topping'); + expect(r.stepSections.single.steps, ['Kneten.', 'Gehen lassen.']); + expect(r.sources, ['https://example.org/focaccia']); + expect(r.notes, '22x22 cm Form'); + expect(r.favourite, isFalse); + expect(r.reviewReasons, isEmpty); + expect(r.photos, isEmpty); + expect(r.createdAt, now); + }); + + test('notes stay home when asked to', () { + final text = RecipeCode.encode(recipe(), includeNotes: false); + expect(RecipeCode.decode(text, now: now).recipe.notes, isNull); + }); + + test('other codes, newer versions and damaged codes are told apart', () { + final good = RecipeCode.encode(recipe()); + expect(() => RecipeCode.decode('https://example.org', now: now), fails(RecipeCodeError.notARecipeCode)); + expect(() => RecipeCode.decode('COOKITX:123', now: now), fails(RecipeCodeError.notARecipeCode)); + expect( + () => RecipeCode.decode(good.replaceFirst('COOKIT1', 'COOKIT2'), now: now), + fails(RecipeCodeError.newerVersion), + ); + expect(() => RecipeCode.decode(good.substring(0, good.length - 7), now: now), fails(RecipeCodeError.damaged)); + }); + + test('the QR code is as small as the text allows, and too much is refused', () { + final small = RecipeCode.qrCode(RecipeCode.encode(recipe())); + expect(small.typeNumber, lessThan(20)); + + // A long Recipe still fits one code… + final steps = [for (var i = 0; i < 40; i++) 'Schritt $i: ${'Rühren und abschmecken, dann weiter. ' * 2}']; + final long = recipe(stepSections: [StepSection(steps: steps)]); + expect(RecipeCode.qrCode(RecipeCode.encode(long)).typeNumber, greaterThan(small.typeNumber)); + + // …but text no compression can shrink does not. + final noise = [for (var i = 0; i < 3000; i++) String.fromCharCode(0x4E00 + (i * 7919) % 20000)].join(); + final huge = recipe( + stepSections: [ + StepSection(steps: [noise]), + ], + ); + expect(() => RecipeCode.qrCode(RecipeCode.encode(huge)), fails(RecipeCodeError.tooLong)); + }); +} diff --git a/test/sharing/recipe_share_service_test.dart b/test/sharing/recipe_share_service_test.dart new file mode 100644 index 0000000..d7a5677 --- /dev/null +++ b/test/sharing/recipe_share_service_test.dart @@ -0,0 +1,97 @@ +import 'dart:typed_data'; + +import 'package:cookit/src/bundle/bundle_service.dart'; +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/unit_repository.dart'; +import 'package:cookit/src/sharing/recipe_share_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_database.dart'; + +/// One cook's collection. +class Phone { + Phone._(this.db, this.photos) + : recipes = RecipeRepository(db, photos), + courses = CourseRepository(db), + units = UnitRepository(db) { + bundles = BundleService(recipes, courses, units, photos); + shares = RecipeShareService(recipes, courses, units, photos, bundles); + } + + static Future create() async { + final phone = Phone._(testDatabase(), await tempPhotoStore()); + addTearDown(() async { + await phone.db.close(); + await phone.photos.directory.delete(recursive: true); + }); + return phone; + } + + final AppDatabase db; + final PhotoStore photos; + final RecipeRepository recipes; + final CourseRepository courses; + final UnitRepository units; + late final BundleService bundles; + late final RecipeShareService shares; +} + +void main() { + test('a Recipe Code arrives as a new Recipe with its Course and own unit', () async { + final mine = await Phone.create(); + final theirs = await Phone.create(); + final course = await mine.courses.addCourse('Brotzeit'); + final cube = await mine.units.addOwn('Würfel'); + final sent = recipe(courseId: course.id, favourite: true).copyWith( + ingredientGroups: [ + IngredientGroup( + lines: [IngredientLine(amount: 1, unitKey: cube.key, name: 'Hefe')], + ), + ], + ); + await mine.recipes.saveRecipe(sent); + + final saved = await theirs.shares.save(theirs.shares.readCode(await mine.shares.recipeCode(sent))); + + expect(saved.id, isNot(sent.id)); + expect(saved.favourite, isFalse); + expect((await theirs.courses.allCourses()).firstWhere((c) => c.id == saved.courseId).name, 'Brotzeit'); + expect((await theirs.units.allUnits()).firstWhere((u) => u.key == cube.key).label, 'Würfel'); + expect((await theirs.recipes.allRecipes()).single.ingredientLines.single.unitKey, cube.key); + }); + + test('a shared file carries the Photos, and saving twice gives two Recipes', () async { + final mine = await Phone.create(); + final theirs = await Phone.create(); + final photo = await mine.photos.saveBytes(Uint8List.fromList([1, 2, 3])); + final sent = recipe(photos: [photo], reviewReasons: ['Prüfen']); + await mine.recipes.saveRecipe(sent); + + final file = await mine.shares.recipeFile(sent, includeNotes: false); + final shared = (await theirs.shares.readFile(file))!; + expect(shared.photos.single, [1, 2, 3]); + expect(shared.recipe.notes, isNull); + expect(shared.recipe.reviewReasons, isEmpty); + + expect(await theirs.shares.hasRecipeNamed('focaccia'), isFalse); + await theirs.shares.save(shared); + expect(await theirs.shares.hasRecipeNamed('focaccia'), isTrue); + await theirs.shares.save(shared); + final all = await theirs.recipes.allRecipes(); + expect(all, hasLength(2)); + expect(all.map((r) => r.id).toSet(), hasLength(2)); + expect(all.first.photos, hasLength(1)); + }); + + test('a file with a whole collection is not a single shared Recipe', () async { + final mine = await Phone.create(); + await mine.recipes.saveRecipe(recipe()); + await mine.recipes.saveRecipe(recipe(id: 'r2', name: 'Pizza')); + expect(await mine.shares.readFile(await mine.bundles.exportBundle()), isNull); + }); +} diff --git a/test/support/fixtures.dart b/test/support/fixtures.dart new file mode 100644 index 0000000..be3ed99 --- /dev/null +++ b/test/support/fixtures.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; + +final testTime = DateTime.utc(2026, 10, 20, 12); + +Recipe recipe({ + String id = 'r1', + String name = 'Focaccia', + String? courseId, + List tags = const [], + List? ingredientGroups, + List? stepSections, + List photos = const [], + bool favourite = false, + List reviewReasons = const [], +}) => Recipe( + id: id, + name: name, + courseId: courseId, + tags: tags, + servings: 4, + prepMinutes: 30, + cookMinutes: 20, + calories: const Calories(775, CaloriesBasis.perServing), + ingredientGroups: + ingredientGroups ?? + const [ + IngredientGroup( + lines: [ + IngredientLine(amount: 300, unitKey: 'gram', name: 'Mehl'), + IngredientLine(amount: 2, amountMax: 3, unitKey: 'tablespoon', name: 'Olivenöl', remark: 'gutes'), + ], + ), + IngredientGroup( + name: 'Topping', + lines: [IngredientLine(name: 'Rosmarin')], + ), + ], + stepSections: + stepSections ?? + const [ + StepSection(title: 'Teig', steps: ['Kneten.', 'Gehen lassen.']), + ], + photos: photos, + sources: const ['https://example.org/focaccia'], + notes: '22x22 cm Form', + favourite: favourite, + reviewReasons: reviewReasons, + createdAt: testTime, + updatedAt: testTime, +); + +Future tempPhotoStore() async => PhotoStore(await Directory.systemTemp.createTemp('cookit_photos_')); diff --git a/test/support/test_app.dart b/test/support/test_app.dart new file mode 100644 index 0000000..c7f5de7 --- /dev/null +++ b/test/support/test_app.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:cookit/src/app.dart'; +import 'package:cookit/src/bundle/bundle_service.dart'; +import 'package:cookit/src/database/database.dart'; +import 'package:cookit/src/planning/plan_repository.dart'; +import 'package:cookit/src/planning/plan_share_service.dart'; +import 'package:cookit/src/planning/week_plan_provider.dart'; +import 'package:cookit/src/recipes/course_repository.dart'; +import 'package:cookit/src/recipes/photo_store.dart'; +import 'package:cookit/src/recipes/recipe_list_provider.dart'; +import 'package:cookit/src/recipes/recipe_repository.dart'; +import 'package:cookit/src/recipes/unit_catalog.dart'; +import 'package:cookit/src/recipes/unit_repository.dart'; +import 'package:cookit/src/settings/settings_controller.dart'; +import 'package:cookit/src/sharing/recipe_share_service.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'fixtures.dart'; +import 'test_database.dart'; + +/// The real app wired to an in-memory database and a temporary photo folder. +class TestApp { + TestApp._(this.db, this.photos) + : recipes = RecipeRepository(db, photos), + courses = CourseRepository(db), + units = UnitRepository(db), + plan = PlanRepository(db), + bundles = BundleService(RecipeRepository(db, photos), CourseRepository(db), UnitRepository(db), photos); + + final AppDatabase db; + final PhotoStore photos; + final RecipeRepository recipes; + final CourseRepository courses; + final UnitRepository units; + final PlanRepository plan; + final BundleService bundles; + + static Future create(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + // A tall phone (400 x 2000 logical px) so long forms are built without scrolling. + tester.view.physicalSize = const Size(1200, 6000); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + final photos = (await tester.runAsync(tempPhotoStore))!; + return TestApp._(testDatabase(), photos); + } + + Widget build() => MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => SettingsController(SettingsService())), + 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(), + ); + + /// Lets database work and the resulting stream updates finish, then redraws. + Future settle(WidgetTester tester) async { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + } + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(() async { + await db.close(); + try { + await photos.directory.delete(recursive: true); + } on FileSystemException { + // Windows keeps a just-decoded image file open for a moment; the temp folder can stay. + } + }); + } +} diff --git a/test/support/test_database.dart b/test/support/test_database.dart new file mode 100644 index 0000000..77ace0b --- /dev/null +++ b/test/support/test_database.dart @@ -0,0 +1,23 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:cookit/src/database/database.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:sqlite3/open.dart'; + +/// An in-memory database for tests, or one in [file] to test migrations. On +/// Windows hosts the sqlite3 library comes from COOKIT_SQLITE_DLL if set, +/// otherwise from the system's winsqlite3.dll. +AppDatabase testDatabase({File? file}) { + if (Platform.isWindows) { + open.overrideFor(OperatingSystem.windows, () { + final custom = Platform.environment['COOKIT_SQLITE_DLL']; + return DynamicLibrary.open(custom ?? 'winsqlite3.dll'); + }); + } + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final executor = file == null ? NativeDatabase.memory() : NativeDatabase(file); + // Synchronous stream teardown keeps widget tests from hanging on pending timers. + return AppDatabase(DatabaseConnection(executor, closeStreamsSynchronously: true)); +} diff --git a/test/views/app_flow_test.dart b/test/views/app_flow_test.dart new file mode 100644 index 0000000..d8f210a --- /dev/null +++ b/test/views/app_flow_test.dart @@ -0,0 +1,232 @@ +import 'package:cookit/src/recipes/models/recipe.dart'; +import 'package:cookit/src/recipes/views/widgets/recipe_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_app.dart'; + +/// A Recipe name as shown on a grid card (the suggestion card may show it too). +Finder inGrid(String text) => find.descendant(of: find.byType(RecipeCard), matching: find.text(text)); + +void main() { + testWidgets('home grid shows Recipes and filters them by search, Course and review', (tester) async { + final app = await TestApp.create(tester); + final dessert = (await tester.runAsync(() => app.courses.findOrCreate('Dessert')))!; + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe(id: 'a', name: 'Käsespätzle', tags: ['schwäbisch'])); + await app.recipes.saveRecipe( + recipe(id: 'b', name: 'Tiramisu', courseId: dessert.id, reviewReasons: ['Portionen fehlen.']), + ); + }); + + await tester.pumpWidget(app.build()); + await app.settle(tester); + expect(inGrid('Käsespätzle'), findsOneWidget); + expect(inGrid('Tiramisu'), findsOneWidget); + expect(find.text('IDEE FÜR HEUTE'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'kase'); + await tester.pumpAndSettle(); + expect(inGrid('Käsespätzle'), findsOneWidget); + expect(inGrid('Tiramisu'), findsNothing); + + await tester.enterText(find.byType(TextField), ''); + await tester.tap(find.text('1 Rezept wartet auf deinen Blick')); + await tester.pumpAndSettle(); + expect(inGrid('Käsespätzle'), findsNothing); + expect(inGrid('Tiramisu'), findsOneWidget); + + await tester.tap(find.text('Nur Rezepte zum Prüfen – alle zeigen')); + await tester.pumpAndSettle(); + final chips = find.byWidgetPredicate((w) => w is ListView && w.scrollDirection == Axis.horizontal); + final dessertChip = find.descendant(of: chips, matching: find.text('Dessert')); + await tester.scrollUntilVisible( + dessertChip, + 200, + scrollable: find.descendant(of: chips, matching: find.byType(Scrollable)), + ); + await tester.tap(dessertChip); + await tester.pumpAndSettle(); + expect(inGrid('Tiramisu'), findsOneWidget); + expect(inGrid('Käsespätzle'), findsNothing); + + await app.dispose(tester); + }); + + testWidgets('Cooking Mode walks through the ingredients and every step', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await tester.tap(inGrid('Focaccia')); + await app.settle(tester); + + await tester.tap(find.text('Kochmodus starten')); + await app.settle(tester); + expect(find.text('Bereit zum Kochen?'), findsOneWidget); + expect(find.text('300 g'), findsOneWidget); + + await tester.tap(find.byTooltip('Mehr Portionen')); + await tester.pumpAndSettle(); + expect(find.text('5'), findsOneWidget); + expect(find.text('375 g'), findsOneWidget); + + await tester.tap(find.text('Los geht’s')); + await tester.pumpAndSettle(); + expect(find.text('Schritt 1 von 2'), findsOneWidget); + expect(find.text('Kneten.'), findsOneWidget); + expect(find.text('Teig'), findsOneWidget); + + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + expect(find.text('Gehen lassen.'), findsOneWidget); + + await tester.tap(find.text('Fertig')); + await tester.pumpAndSettle(); + expect(find.text('Guten Appetit!'), findsOneWidget); + + await tester.tap(find.text('Zurück zum Rezept')); + await app.settle(tester); + expect(find.text('Kochmodus starten'), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('the heart on a card toggles the favourite', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.byTooltip('Favorit'))); + await app.settle(tester); + expect((await tester.runAsync(app.recipes.allRecipes))!.single.favourite, isTrue); + + await app.dispose(tester); + }); + + testWidgets('empty collection explains how to start', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + expect(find.text('Noch keine Rezepte'), findsOneWidget); + await app.dispose(tester); + }); + + testWidgets('detail shows the Recipe, and "Erledigt" clears its Review Flag', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe(reviewReasons: ['Portionen fehlen.']))); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(inGrid('Focaccia')); + await app.settle(tester); + expect(find.text('Bitte prüfen'), findsOneWidget); + expect(find.text('• Portionen fehlen.'), findsOneWidget); + expect(find.text('300 g'), findsOneWidget); + expect(find.text('2–3 EL'), findsOneWidget); + expect(find.text('Topping'), findsOneWidget); + expect(find.text('775 kcal'), findsOneWidget); + expect(find.text('pro Portion'), findsOneWidget); + expect(find.text('50 Min.'), findsOneWidget); + + expect(find.text('22x22 cm Form'), findsOneWidget, reason: 'Notes show under the Zutaten tab too'); + await tester.tap(find.text('Zubereitung · 2')); + await tester.pumpAndSettle(); + expect(find.text('Teig'), findsOneWidget); + expect(find.text('Kneten.'), findsOneWidget); + expect(find.text('22x22 cm Form'), findsOneWidget); + + await tester.tap(find.text('Erledigt')); + await app.settle(tester); + expect(find.text('Bitte prüfen'), findsNothing); + + await app.dispose(tester); + }); + + testWidgets('a new Recipe can be created with ingredients and steps', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.byTooltip('Neues Rezept')); + await app.settle(tester); + + await tester.tap(find.text('Speichern')); + await tester.pumpAndSettle(); + expect(find.text('Bitte gib dem Rezept einen Namen.'), findsOneWidget); + + await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Pfannkuchen'); + await tester.enterText(find.widgetWithText(TextFormField, 'Portionen'), '2'); + final ingredientName = find.widgetWithText(TextFormField, 'Zutat'); + await tester.enterText(find.widgetWithText(TextFormField, 'Menge'), '1,5'); + await tester.enterText(ingredientName, 'Mehl'); + await tester.tap(find.text('–')); + await tester.pumpAndSettle(); + expect(find.text('Bund'), findsNothing); + await tester.tap(find.text('Weitere Einheiten …')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ChoiceChip, 'Bund')); + await tester.pumpAndSettle(); + expect(find.text('Bund'), findsOneWidget); + await tester.enterText(find.widgetWithText(TextFormField, 'Schritt 1'), 'Alles verrühren.'); + + await tester.tap(find.text('Speichern')); + await app.settle(tester); + + expect(inGrid('Pfannkuchen'), findsOneWidget); + final saved = (await tester.runAsync(app.recipes.allRecipes))!.single; + expect(saved.servings, 2); + expect(saved.ingredientGroups.single.lines.single.amount, 1.5); + expect(saved.ingredientGroups.single.lines.single.name, 'Mehl'); + expect(saved.ingredientGroups.single.lines.single.unitKey, 'bunch'); + expect(saved.stepSections.single.steps, ['Alles verrühren.']); + expect(saved.calories, isNull); + expect(saved.needsReview, isFalse); + + await app.dispose(tester); + }); + + testWidgets('an invalid amount blocks saving', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await tester.tap(find.byTooltip('Neues Rezept')); + await app.settle(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Test'); + await tester.enterText(find.widgetWithText(TextFormField, 'Menge'), 'viel'); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + expect(find.text('z. B. 2, 1,5, ½ oder 2-3'), findsOneWidget); + expect(await tester.runAsync(app.recipes.allRecipes), isEmpty); + + await app.dispose(tester); + }); + + testWidgets('editing keeps favourite and Review Flag', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe(favourite: true, reviewReasons: ['x']))); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await tester.tap(inGrid('Focaccia')); + await app.settle(tester); + await tester.tap(find.byTooltip('Bearbeiten')); + await app.settle(tester); + + await tester.enterText(find.widgetWithText(TextFormField, 'Name'), 'Focaccia mit Rosmarin'); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + + final saved = (await tester.runAsync(app.recipes.allRecipes))!.single; + expect(saved.name, 'Focaccia mit Rosmarin'); + expect(saved.favourite, isTrue); + expect(saved.reviewReasons, ['x']); + expect(saved.calories!.basis, CaloriesBasis.perServing); + expect(saved.ingredientGroups.map((g) => g.name), [null, 'Topping']); + expect(saved.stepSections.single.title, 'Teig'); + + await app.dispose(tester); + }); +} diff --git a/test/views/new_features_test.dart b/test/views/new_features_test.dart new file mode 100644 index 0000000..9ccb011 --- /dev/null +++ b/test/views/new_features_test.dart @@ -0,0 +1,183 @@ +import 'package:cookit/src/recipes/views/widgets/recipe_card.dart'; +import 'package:cookit/src/recipes/views/widgets/recipe_photo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_app.dart'; + +Finder inSheet(Finder finder) => find.descendant(of: find.byType(BottomSheet), matching: finder); + +void main() { + testWidgets('"Hilf mir entscheiden" narrows down by Course and time', (tester) async { + final app = await TestApp.create(tester); + final courses = (await tester.runAsync(app.courses.allCourses))!; + final dessert = courses.firstWhere((c) => c.name == 'Dessert'); + final main = courses.firstWhere((c) => c.name == 'Hauptgericht'); + await tester.runAsync(() async { + await app.recipes.saveRecipe( + recipe( + id: 'a', + name: 'Kaiserschmarrn', + courseId: dessert.id, + ).copyWith(prepMinutes: () => 10, cookMinutes: () => 10), + ); + await app.recipes.saveRecipe(recipe(id: 'b', name: 'Tiramisu', courseId: dessert.id)); // 50 min + await app.recipes.saveRecipe(recipe(id: 'c', name: 'Rinderrouladen', courseId: main.id)); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.byTooltip('Hilf mir entscheiden')); + await tester.pumpAndSettle(); + expect(find.text('Worauf hast du Lust?'), findsOneWidget); + await tester.tap(inSheet(find.text('Dessert'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + + expect(find.text('Wie viel Zeit hast du?'), findsOneWidget); + await tester.tap(find.text('bis 30 Min.')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Vorschlag zeigen')); + await tester.pumpAndSettle(); + expect(find.text('1 Rezept passt'), findsOneWidget); + expect(inSheet(find.text('Kaiserschmarrn')), findsOneWidget); + + await tester.tap(find.text('Rezept öffnen')); + await app.settle(tester); + expect(find.text('Kochmodus starten'), findsOneWidget); + expect(find.text('Kaiserschmarrn'), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('the decision helper offers a looser choice when nothing fits', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); // 50 min, no Course + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.byTooltip('Hilf mir entscheiden')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('bis 30 Min.')); + await tester.tap(find.text('Vorschlag zeigen')); + await tester.pumpAndSettle(); + expect(find.text('Dazu passt leider nichts.'), findsOneWidget); + + await tester.tap(find.text('Ohne Zeitlimit: 1')); + await tester.pumpAndSettle(); + expect(find.text('1 Rezept passt'), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('Darstellung switches between light and dark', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + ThemeMode mode() => tester.widget(find.byType(MaterialApp)).themeMode!; + expect(mode(), ThemeMode.light); + + await tester.tap(find.byTooltip('Einstellungen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dunkel')); + await app.settle(tester); + expect(mode(), ThemeMode.dark); + + await app.dispose(tester); + }); + + testWidgets('an own unit added in the settings is offered in the unit picker', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.byTooltip('Einstellungen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Maßeinheiten bearbeiten')); + await app.settle(tester); + await tester.tap(find.text('Einheit hinzufügen')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'Würfel'); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + expect(find.text('Würfel'), findsOneWidget); + expect(find.text('Eigene Einheit'), findsOneWidget); + + await tester.tap(find.byTooltip('Zurück')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Zurück')); + await app.settle(tester); + await tester.tap(find.byTooltip('Neues Rezept')); + await app.settle(tester); + await tester.tap(find.text('–')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ChoiceChip, 'Würfel'), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('tapping a photo can make it the main Photo', (tester) async { + final app = await TestApp.create(tester); + final (first, second) = (await tester.runAsync(() async { + final a = await app.photos.saveBytes(Uint8List.fromList([1])); + final b = await app.photos.saveBytes(Uint8List.fromList([2])); + await app.recipes.saveRecipe(recipe(photos: [a, b])); + return (a, b); + }))!; + await tester.pumpWidget(app.build()); + await app.settle(tester); + await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia'))); + await app.settle(tester); + await tester.tap(find.byTooltip('Bearbeiten')); + await app.settle(tester); + + expect(find.textContaining('das erste Foto ist das Hauptfoto'), findsOneWidget); + // The photo is covered by its own tap layer, which is what receives the tap. + await tester.tap(find.byType(RecipePhoto).at(1), warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.tap(find.text('Als Hauptfoto festlegen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + + expect((await tester.runAsync(() => app.recipes.getRecipe('r1')))!.photos, [second, first]); + + await app.dispose(tester); + }); + + testWidgets('"Zutaten teilen" shares the ingredients for the chosen servings', (tester) async { + final shared = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/share'), + (call) async { + shared.add(call.arguments as Map); + return 'dev.fluttercommunity.plus/share/unavailable'; + }, + ); + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia'))); + await app.settle(tester); + + await tester.scrollUntilVisible(find.text('Zutaten teilen'), 200, scrollable: find.byType(Scrollable).first); + await tester.tap(find.text('Zutaten teilen')); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.byTooltip('Mehr Portionen'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Teilen')); + await tester.pumpAndSettle(); + + expect(shared, hasLength(1)); + expect(shared.single['text'], '375 g Mehl\n2½–3¾ EL Olivenöl (gutes)\n\nRosmarin'); + expect(shared.single['subject'], 'Focaccia – Zutaten für 5 Portionen'); + + await app.dispose(tester); + }); +} diff --git a/test/views/sharing_test.dart b/test/views/sharing_test.dart new file mode 100644 index 0000000..770b07b --- /dev/null +++ b/test/views/sharing_test.dart @@ -0,0 +1,95 @@ +import 'package:cookit/src/planning/day.dart'; +import 'package:cookit/src/planning/plan_share_service.dart'; +import 'package:cookit/src/planning/views/shared_week_view.dart'; +import 'package:cookit/src/recipes/views/widgets/recipe_card.dart'; +import 'package:cookit/src/sharing/recipe_code.dart'; +import 'package:cookit/src/sharing/views/shared_recipe_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../support/fixtures.dart'; +import '../support/test_app.dart'; + +void main() { + testWidgets('"Rezept teilen" shows the Recipe Code and can leave the notes out', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + await tester.pumpWidget(app.build()); + await app.settle(tester); + expect(find.byTooltip('Rezept scannen'), findsOneWidget); + + await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia'))); + await app.settle(tester); + await tester.tap(find.byTooltip('Rezept teilen')); + await app.settle(tester); + + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text('Als Datei teilen'), findsOneWidget); + await tester.tap(find.text('Notizen mitschicken')); + await app.settle(tester); + expect(tester.widget(find.byType(SwitchListTile)).value, isFalse); + expect(find.byType(QrImageView), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('a received Recipe is previewed, flagged as a double and saved as a copy', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + final shared = RecipeCode.decode(RecipeCode.encode(recipe(), courseName: 'Brot'), now: DateTime(2026)); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + tester.state(find.byType(Navigator)).pushNamed(SharedRecipeView.routeName, arguments: shared); + await app.settle(tester); + expect(find.text('Rezept empfangen'), findsOneWidget); + expect(find.text('Brot · 4 Portionen · 50 Min.'), findsOneWidget); + expect(find.text('Mehl'), findsOneWidget); + expect(find.text('1. Kneten.'), findsOneWidget); + expect(find.textContaining('Du hast schon ein Rezept „Focaccia“'), findsOneWidget); + + await tester.tap(find.text('Speichern')); + await app.settle(tester); + expect(find.text('„Focaccia“ gespeichert'), findsOneWidget); + expect(find.text('Kochmodus starten'), findsOneWidget); + final all = (await tester.runAsync(app.recipes.allRecipes))!; + expect(all, hasLength(2)); + final courses = (await tester.runAsync(app.courses.allCourses))!; + expect(courses.map((c) => c.name), contains('Brot')); + + await app.dispose(tester); + }); + + testWidgets('a received week is previewed and adopted next to what is planned', (tester) async { + final app = await TestApp.create(tester); + final monday = DateTime.utc(2026, 10, 19); + final week = (await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe()); + final meals = await app.plan.allMeals(); + final abend = meals.firstWhere((m) => m.name == 'Abend'); + await app.plan.addEntry(monday, abend.id, recipeId: 'r1'); + await app.plan.addEntry(addDays(monday, 2), abend.id, note: 'Essen gehen'); + final shares = PlanShareService(app.recipes, app.courses, app.units, app.photos, app.plan, app.bundles); + return SharedWeek(await app.bundles.read(await shares.weekFile(monday))); + }))!; + await tester.pumpWidget(app.build()); + await app.settle(tester); + + tester.state(find.byType(Navigator)).pushNamed(SharedWeekView.routeName, arguments: week); + await app.settle(tester); + expect(find.text('Wochenplan empfangen'), findsOneWidget); + expect(find.text('2 Einträge · keine neuen Rezepte'), findsOneWidget); + expect(find.textContaining('schon 2 Einträge'), findsOneWidget); + expect(find.text('Focaccia'), findsOneWidget); + expect(find.text('Essen gehen'), findsOneWidget); + + await tester.tap(find.text('Übernehmen')); + await app.settle(tester); + expect(find.text('Wochenplan 19.–25. Okt. übernommen'), findsOneWidget); + expect(await tester.runAsync(app.plan.allEntries), hasLength(4)); + expect(await tester.runAsync(app.recipes.allRecipes), hasLength(1)); + + await app.dispose(tester); + }); +} diff --git a/test/views/week_plan_test.dart b/test/views/week_plan_test.dart new file mode 100644 index 0000000..086c96c --- /dev/null +++ b/test/views/week_plan_test.dart @@ -0,0 +1,352 @@ +import 'package:cookit/src/planning/day.dart'; +import 'package:cookit/src/planning/models/meal.dart'; +import 'package:cookit/src/recipes/views/widgets/recipe_card.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/fixtures.dart'; +import '../support/test_app.dart'; + +final today = dayOf(DateTime.now()); + +/// The grid cell of [meal] on [day]. +Finder cell(DateTime day, Meal meal) => find.byKey(ValueKey('${dayKey(day)}-${meal.id}')); + +Finder inCell(DateTime day, Meal meal, Finder finder) => find.descendant(of: cell(day, meal), matching: finder); + +Finder inSheet(Finder finder) => find.descendant(of: find.byType(BottomSheet), matching: finder); + +Future openWeekPlan(WidgetTester tester, TestApp app) async { + await tester.tap(find.byTooltip('Wochenplan')); + await app.settle(tester); +} + +Future<(Meal, Meal)> meals(WidgetTester tester, TestApp app) async { + final all = (await tester.runAsync(app.plan.allMeals))!; + return (all.firstWhere((m) => m.name == 'Mittag'), all.firstWhere((m) => m.name == 'Abend')); +} + +/// Long-presses [from] and drops it on [to]. +Future drag(WidgetTester tester, TestApp app, Finder from, Finder to) async { + final start = tester.getCenter(from); + final gesture = await tester.startGesture(start); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100)); + // While lifted, the entry's place shows an empty outline, so [from] finds nothing. + await gesture.moveTo(start + const Offset(0, 20)); + await tester.pump(); + await gesture.moveTo(tester.getCenter(to)); + await tester.pump(); + await gesture.up(); + await app.settle(tester); +} + +void main() { + testWidgets('"Einplanen" puts a Recipe on today\'s Abend, shown in the grid and the banners', (tester) async { + final app = await TestApp.create(tester); + await tester.runAsync(() => app.recipes.saveRecipe(recipe())); + final (_, abend) = await meals(tester, app); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.descendant(of: find.byType(RecipeCard), matching: find.text('Focaccia'))); + await app.settle(tester); + await tester.tap(find.byTooltip('Einplanen')); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.text('Einplanen'))); + await app.settle(tester); + expect(find.text('Eingeplant: Heute · Abend'), findsOneWidget); + + await tester.tap(find.byTooltip('Zurück')); + await app.settle(tester); + expect(find.text('HEUTE GEPLANT'), findsOneWidget); + expect(find.text('Abend · 50 Min.'), findsOneWidget); + + await tester.tap(find.byTooltip('Lieber eine Idee')); + await tester.pumpAndSettle(); + expect(find.text('IDEE FÜR HEUTE'), findsOneWidget); + + await openWeekPlan(tester, app); + expect(find.text('Diese Woche'), findsOneWidget); + expect(inCell(today, abend, find.text('Focaccia')), findsOneWidget); + expect(find.textContaining('HEUTE ·'), findsOneWidget); + expect(find.text('Kochen'), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('a note is added with "+", removed from its cell and brought back', (tester) async { + final app = await TestApp.create(tester); + final (mittag, _) = await meals(tester, app); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await tester.tap(cell(today, mittag)); + await tester.pumpAndSettle(); + await tester.enterText(inSheet(find.byType(TextField)), 'Essen gehen'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Als Notiz: „Essen gehen“')); + await app.settle(tester); + expect(inCell(today, mittag, find.text('Essen gehen')), findsOneWidget); + + await tester.tap(cell(today, mittag)); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.text('Entfernen'))); + await app.settle(tester); + expect(inCell(today, mittag, find.text('Essen gehen')), findsNothing); + expect(find.text('„Essen gehen“ entfernt'), findsOneWidget); + + await tester.tap(find.text('Rückgängig')); + await app.settle(tester); + expect(inCell(today, mittag, find.text('Essen gehen')), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('dragging an entry onto another cell adds it there, and each entry drags on its own', (tester) async { + final app = await TestApp.create(tester); + final (mittag, abend) = await meals(tester, app); + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe()); + await app.plan.addEntry(today, abend.id, recipeId: 'r1'); + await app.plan.addEntry(today, mittag.id, note: 'Salat'); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await drag(tester, app, cell(today, abend), cell(today, mittag)); + + final entries = (await tester.runAsync(app.plan.allEntries))!; + expect(entries.map((e) => (e.mealId, e.recipeId ?? e.note)), [(mittag.id, 'Salat'), (mittag.id, 'r1')]); + // Both show in the cell, one slim line each. + expect(inCell(today, mittag, find.text('Salat')), findsOneWidget); + expect(inCell(today, mittag, find.text('Focaccia')), findsOneWidget); + // The navigation bar is back once the finger is up. + expect(find.text('Hier ablegen zum Entfernen'), findsNothing); + + // The second one can be dragged on its own. + await drag(tester, app, inCell(today, mittag, find.text('Focaccia')), cell(today, abend)); + final after = (await tester.runAsync(app.plan.allEntries))!; + expect( + after.map((e) => (e.mealId, e.recipeId ?? e.note)), + unorderedEquals([(mittag.id, 'Salat'), (abend.id, 'r1')]), + ); + + await app.dispose(tester); + }); + + testWidgets('dropping an entry on "Nächste Woche" moves it seven days on', (tester) async { + final app = await TestApp.create(tester); + final (_, abend) = await meals(tester, app); + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe()); + await app.plan.addEntry(today, abend.id, recipeId: 'r1'); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + final gesture = await tester.startGesture(tester.getCenter(cell(today, abend))); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100)); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(); + expect(find.text('Hier ablegen zum Entfernen'), findsOneWidget); + await gesture.moveTo(tester.getCenter(find.text('Nächste Woche'))); + await tester.pump(); + await gesture.up(); + await app.settle(tester); + + expect((await tester.runAsync(app.plan.allEntries))!.single.day, addDays(today, 7)); + expect(find.text('Nächste Woche'), findsOneWidget); // the grid followed it + expect(inCell(addDays(today, 7), abend, find.text('Focaccia')), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('dropping an entry on the remove zone removes it, with undo', (tester) async { + final app = await TestApp.create(tester); + final (_, abend) = await meals(tester, app); + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe()); + await app.plan.addEntry(today, abend.id, recipeId: 'r1'); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + final gesture = await tester.startGesture(tester.getCenter(cell(today, abend))); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100)); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(); + await gesture.moveTo(tester.getCenter(find.text('Hier ablegen zum Entfernen'))); + await tester.pump(); + await gesture.up(); + await app.settle(tester); + + expect(await tester.runAsync(app.plan.allEntries), isEmpty); + await tester.tap(find.text('Rückgängig')); + await app.settle(tester); + expect((await tester.runAsync(app.plan.allEntries))!.single.recipeId, 'r1'); + + await app.dispose(tester); + }); + + testWidgets('"Woche füllen" gives every Abend from today on a different Hauptgericht', (tester) async { + final app = await TestApp.create(tester); + final courses = (await tester.runAsync(app.courses.allCourses))!; + final main = courses.firstWhere((c) => c.name == 'Hauptgericht'); + final dessert = courses.firstWhere((c) => c.name == 'Dessert'); + await tester.runAsync(() async { + for (var i = 0; i < 7; i++) { + await app.recipes.saveRecipe(recipe(id: 'm$i', name: 'Gericht $i', courseId: main.id)); + } + await app.recipes.saveRecipe(recipe(id: 'd', name: 'Pudding', courseId: dessert.id)); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await tester.tap(find.byTooltip('Woche füllen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Füllen')); + await app.settle(tester); + + final daysLeft = addDays(weekStartOf(today, DateTime.monday), 6).difference(today).inDays + 1; + final entries = (await tester.runAsync(app.plan.allEntries))!; + final (_, abend) = await meals(tester, app); + expect(entries, hasLength(daysLeft)); + expect(entries.map((e) => e.recipeId).toSet(), hasLength(daysLeft)); + expect(entries.every((e) => e.mealId == abend.id && e.recipeId!.startsWith('m')), isTrue); + expect(find.text(daysLeft == 1 ? '1 Tag gefüllt.' : '$daysLeft Tage gefüllt.'), findsOneWidget); + // Cells show the Course's icon. + expect(inCell(today, abend, find.text('🍝')), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('"Zutaten der Woche teilen" adds up what is planned', (tester) async { + final shared = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/share'), + (call) async { + shared.add(call.arguments as Map); + return 'dev.fluttercommunity.plus/share/unavailable'; + }, + ); + final app = await TestApp.create(tester); + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe()); + for (final meal in await app.plan.allMeals()) { + await app.plan.addEntry(today, meal.id, recipeId: 'r1'); + } + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await tester.tap(find.byTooltip('Teilen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Zutaten der Woche teilen')); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.text('Teilen'))); + await tester.pumpAndSettle(); + + expect(shared.single['text'], '600 g Mehl\n4–6 EL Olivenöl (gutes)\nRosmarin'); + + await app.dispose(tester); + }); + + testWidgets('a Meal added in the settings becomes a column of the grid', (tester) async { + final app = await TestApp.create(tester); + await tester.pumpWidget(app.build()); + await app.settle(tester); + + await tester.tap(find.byTooltip('Einstellungen')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Mahlzeiten bearbeiten')); + await app.settle(tester); + await tester.tap(find.text('Mahlzeit hinzufügen')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'Frühstück'); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + await tester.tap(find.byTooltip('Zurück')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Zurück')); + await app.settle(tester); + + await openWeekPlan(tester, app); + expect(find.text('Frühstück'), findsOneWidget); + final fruehstueck = (await tester.runAsync(app.plan.allMeals))!.last; + expect(cell(today, fruehstueck), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('the add sheet filters Recipes by Course', (tester) async { + final app = await TestApp.create(tester); + final courses = (await tester.runAsync(app.courses.allCourses))!; + final dessert = courses.firstWhere((c) => c.name == 'Dessert'); + final main = courses.firstWhere((c) => c.name == 'Hauptgericht'); + final (mittag, _) = await meals(tester, app); + await tester.runAsync(() async { + await app.recipes.saveRecipe(recipe(id: 'a', name: 'Tiramisu', courseId: dessert.id)); + await app.recipes.saveRecipe(recipe(id: 'b', name: 'Rinderrouladen', courseId: main.id)); + }); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await tester.tap(cell(today, mittag)); + await tester.pumpAndSettle(); + expect(inSheet(find.text('Tiramisu')), findsOneWidget); + expect(inSheet(find.text('Rinderrouladen')), findsOneWidget); + + // The Course chips scroll sideways; Dessert is far to the right. + await tester.dragUntilVisible( + inSheet(find.text(dessert.label)), + inSheet(find.byType(ListView)).first, + const Offset(-200, 0), + ); + await tester.ensureVisible(inSheet(find.text(dessert.label))); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.text(dessert.label))); + await tester.pumpAndSettle(); + expect(inSheet(find.text('Tiramisu')), findsOneWidget); + expect(inSheet(find.text('Rinderrouladen')), findsNothing); + + await tester.tap(inSheet(find.text('Tiramisu'))); + await app.settle(tester); + expect(inCell(today, mittag, find.text('Tiramisu')), findsOneWidget); + + await app.dispose(tester); + }); + + testWidgets('a note can be edited from its cell', (tester) async { + final app = await TestApp.create(tester); + final (mittag, _) = await meals(tester, app); + await tester.runAsync(() => app.plan.addEntry(today, mittag.id, note: 'Essen gehen')); + await tester.pumpWidget(app.build()); + await app.settle(tester); + await openWeekPlan(tester, app); + + await tester.tap(cell(today, mittag)); + await tester.pumpAndSettle(); + await tester.tap(inSheet(find.text('Bearbeiten'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextField)), + 'Pizza bestellen', + ); + await tester.tap(find.text('Speichern')); + await app.settle(tester); + + expect(inCell(today, mittag, find.text('Pizza bestellen')), findsOneWidget); + expect((await tester.runAsync(app.plan.allEntries))!.single.note, 'Pizza bestellen'); + + await app.dispose(tester); + }); +} diff --git a/tools/excel-import/README.md b/tools/excel-import/README.md new file mode 100644 index 0000000..ea364ea --- /dev/null +++ b/tools/excel-import/README.md @@ -0,0 +1,38 @@ +# Excel import + +A one-time converter from `Wochenspeiseplan.xlsm` to a Recipe Bundle ([format](../../docs/recipe-bundle-format.md)) that you then import into CookIt on the phone. The workbook and the bundle are private data and stay out of git. + +```sh +cd tools/excel-import +python -m venv .venv +.venv/Scripts/pip install -r requirements.txt # Windows; use .venv/bin/pip elsewhere +.venv/Scripts/python convert.py ../../Wochenspeiseplan.xlsm +# -> out/cookit-rezepte.zip; copy it to the phone and use Einstellungen → Rezepte importieren +.venv/Scripts/python -m pytest +``` + +The tests in `tests/test_workbook.py` run only when the workbook is at the repo root, or wherever `COOKIT_WORKBOOK` points. + +## What it does + +- Reads every sheet built from the recipe template (`C2 = Menge`, `E2 = Zutat`) and skips `Leeres Rezept` and `Spinat-Feta-Pizza`. +- **Ingredients:** C/D/E from row 3 down. + - A blank row starts a new (unnamed) Ingredient Group. + - An `ODER` row starts a group named "Alternative". + - Cell comments such as "Zutaten für die Sauce" name the group starting at that row. + - Units are mapped to canonical keys, including typos. + - "100 g" typed into the unit column is split into amount and unit. + - Rows without an ingredient name that hold only numbers (left over from rescaling) are dropped. + - A repeated copy of the ingredient list is dropped. +- **Steps:** text from the F column. + - Blank lines separate Steps. + - If there are no blank lines, each line is a Step. + - Short lines ("Teig", "Soße:") and inline headings ("Salat:Die Zwiebel…") become Step Sections. +- **Calories:** more than 1500 kcal counts as the whole recipe, otherwise per serving. A "Pro Portion" comment on the cell settles it. The guess isn't flagged: on review it matched every value in the workbook. +- **Notes & Sources:** + - The comment field (B12) and any cell outside the template go into Notes, one line per sheet row. + - URLs anywhere become Sources. + - The template text "Löschen Sie dieses Rezeptarbeitsblatt nicht." is dropped. +- **Photos:** pictures on the sheet, top-left first, re-encoded as JPEG, at most 1600 px. The template's stock picture (copied onto every sheet made from `Leeres Rezept`) is dropped, so those Recipes simply have no Photo. +- **Review Flags:** anything the converter guessed or moved adds a German reason to `reviewReasons`, shown in the app under "zu prüfen". +- **IDs:** Recipe IDs come from the sheet name (UUIDv5), so converting again gives the same IDs and a second import skips Recipes already there. diff --git a/tools/excel-import/convert.py b/tools/excel-import/convert.py new file mode 100644 index 0000000..8b46021 --- /dev/null +++ b/tools/excel-import/convert.py @@ -0,0 +1,45 @@ +"""Convert the Wochenspeiseplan workbook into a CookIt Recipe Bundle. + +Usage: python convert.py [-o out/cookit-rezepte.zip] +""" + +from __future__ import annotations + +import argparse +import os +import sys +from collections import Counter + +from cookit_import.bundle import write_bundle +from cookit_import.recipe import build_recipe, is_recipe_sheet, real_photos, template_photo_hashes +from cookit_import.workbook import read_sheets + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("workbook") + parser.add_argument("-o", "--output", default=os.path.join("out", "cookit-rezepte.zip")) + args = parser.parse_args(argv) + + sys.stdout.reconfigure(encoding="utf-8") + print(f"Lese {args.workbook} …") + all_sheets = read_sheets(args.workbook) + placeholders = template_photo_hashes(all_sheets) + sheets = [s for s in all_sheets if is_recipe_sheet(s)] + recipes = [(build_recipe(s), real_photos(s, placeholders)) for s in sheets] + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + write_bundle(args.output, recipes) + + flagged = [r for r, _ in recipes if r["reviewReasons"]] + reasons = Counter(reason.split(":")[0] for r in flagged for reason in r["reviewReasons"]) + print(f"{len(recipes)} Rezepte, {sum(len(r['photos']) for r, _ in recipes)} Fotos") + print(f"{len(flagged)} Rezepte mit Prüfhinweis:") + for reason, count in reasons.most_common(): + print(f" {count:4d} {reason}") + print(f"Geschrieben: {args.output} ({os.path.getsize(args.output) / 1_000_000:.1f} MB)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/excel-import/cookit_import/__init__.py b/tools/excel-import/cookit_import/__init__.py new file mode 100644 index 0000000..5f251e7 --- /dev/null +++ b/tools/excel-import/cookit_import/__init__.py @@ -0,0 +1 @@ +"""One-time converter from the Wochenspeiseplan workbook to a CookIt Recipe Bundle.""" diff --git a/tools/excel-import/cookit_import/bundle.py b/tools/excel-import/cookit_import/bundle.py new file mode 100644 index 0000000..0eda232 --- /dev/null +++ b/tools/excel-import/cookit_import/bundle.py @@ -0,0 +1,58 @@ +"""Writes a Recipe Bundle: a zip with bundle.json and downscaled photos (see docs/recipe-bundle-format.md).""" + +from __future__ import annotations + +import io +import json +import zipfile +from datetime import datetime, timezone + +from PIL import Image, ImageOps, UnidentifiedImageError + +FORMAT = "cookit.recipe-bundle" +VERSION = 1 +MAX_PHOTO_EDGE = 1600 +JPEG_QUALITY = 82 + + +def downscale(data: bytes) -> bytes | None: + """JPEG no larger than MAX_PHOTO_EDGE on its long side, or None if the picture can't be read.""" + try: + img = Image.open(io.BytesIO(data)) + img = ImageOps.exif_transpose(img) + except (UnidentifiedImageError, OSError, ValueError): + return None + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGBA") + background = Image.new("RGB", img.size, "white") + background.paste(img, mask=img.getchannel("A")) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + img.thumbnail((MAX_PHOTO_EDGE, MAX_PHOTO_EDGE), Image.Resampling.LANCZOS) + out = io.BytesIO() + img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True) + return out.getvalue() + + +def write_bundle(path: str, recipes: list[tuple[dict, list[bytes]]]) -> None: + """recipes: (recipe dict, raw photo bytes in display order).""" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + entries = [] + for recipe, photos in recipes: + recipe["photos"] = [] + for raw in photos: + jpeg = downscale(raw) + if jpeg is None: + continue + name = f"photos/{recipe['id']}-{len(recipe['photos']) + 1}.jpg" + z.writestr(zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)), jpeg, zipfile.ZIP_STORED) + recipe["photos"].append(name) + entries.append(recipe) + manifest = { + "format": FORMAT, + "version": VERSION, + "createdAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "recipes": entries, + } + z.writestr("bundle.json", json.dumps(manifest, ensure_ascii=False, indent=2)) diff --git a/tools/excel-import/cookit_import/parse.py b/tools/excel-import/cookit_import/parse.py new file mode 100644 index 0000000..e6a309e --- /dev/null +++ b/tools/excel-import/cookit_import/parse.py @@ -0,0 +1,238 @@ +"""Pure parsing helpers for single cell values. No workbook access here.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +# Canonical unit keys (shared with the app) and the spellings found in the workbook. +UNIT_ALIASES: dict[str, list[str]] = { + "gram": ["gramm", "g", "gr"], + "kilogram": ["kilogramm", "kg"], + "milliliter": ["milliliter", "mililiter", "ml"], + "liter": ["liter", "l"], + "tablespoon": ["esslöffel", "el"], + "teaspoon": ["teelöffel", "tl"], + "pinch": ["prise", "prisen"], + "knifeTip": ["msp.", "msp", "messerspitze"], + "pack": ["packung", "pkt.", "pkt", "päckchen"], + "can": ["dose"], + "jar": ["glas"], + "tub": ["becher"], + "bag": ["beutel"], + "carton": ["karton"], + "bunch": ["bund"], + "head": ["kopf"], + "slice": ["scheibe/n", "scheibe", "scheiben"], + "clove": ["zehe/n", "zehe", "zehen"], + "sprig": ["zweig (e)", "zweig", "zweige"], + "handful": ["handvoll", "hand voll"], + "cup": ["tasse", "tassen"], + "grain": ["körner", "korn"], + "piece": ["stück", "stk.", "stk"], + "small": ["kleine", "klein", "kleiner", "kleines"], + "medium": ["mittel", "mittlere", "mittelgroße"], + "large": ["große", "groß", "großer", "großes"], + "some": ["etwas"], +} +_UNIT_LOOKUP = {alias: key for key, aliases in UNIT_ALIASES.items() for alias in aliases} + +_FRACTIONS = {"½": 0.5, "¼": 0.25, "¾": 0.75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": 0.125} +_NUMBER = r"\d+(?:[.,]\d+)?" + + +def clean(value) -> str | None: + """Cell value as stripped text, or None when empty.""" + if value is None: + return None + text = str(value).replace("\r\n", "\n").strip() + return text or None + + +def _round(x: float) -> float | int: + x = round(x, 3) + return int(x) if x == int(x) else x + + +def _single_amount(text: str) -> float | None: + text = text.strip() + if text in _FRACTIONS: + return _FRACTIONS[text] + m = re.fullmatch(rf"(\d+)\s*([½¼¾⅓⅔⅛])", text) + if m: + return int(m.group(1)) + _FRACTIONS[m.group(2)] + m = re.fullmatch(r"(?:(\d+)\s+)?(\d+)\s*/\s*(\d+)", text) + if m and int(m.group(3)) != 0: + return int(m.group(1) or 0) + int(m.group(2)) / int(m.group(3)) + if re.fullmatch(_NUMBER, text): + return float(text.replace(",", ".")) + return None + + +def parse_amount(value) -> tuple[float | int | None, float | int | None] | None: + """(amount, amountMax) from a quantity cell; None when the text is not a quantity.""" + if value is None: + return (None, None) + if isinstance(value, (int, float)): + return (_round(float(value)), None) if value != 0 else (None, None) + text = clean(value) + if text is None: + return (None, None) + parts = re.split(r"\s*(?:-|–|bis)\s*", text) + if len(parts) == 2: + low, high = _single_amount(parts[0]), _single_amount(parts[1]) + if low is not None and high is not None: + return (_round(low), _round(high)) + single = _single_amount(text) + if single is not None: + return (_round(single), None) if single != 0 else (None, None) + return None + + +def parse_unit(value) -> str | None: + """Canonical unit key for a unit cell, or None when unrecognised.""" + text = clean(value) + if text is None: + return None + return _UNIT_LOOKUP.get(text.lower()) + + +def split_amount_unit(value) -> tuple[float | int, str | None] | None: + """'100 g' / '1 TL' / '3' typed into the unit column -> (amount, unit key).""" + text = clean(value) + if text is None: + return None + m = re.fullmatch(rf"({_NUMBER})\s*(.*)", text) + if not m: + return None + amount = _round(float(m.group(1).replace(",", "."))) + rest = m.group(2).strip() + if not rest: + return (amount, None) + unit = parse_unit(rest) + return (amount, unit) if unit else None + + +def is_number(value) -> bool: + if isinstance(value, (int, float)): + return True + text = clean(value) + return text is not None and re.fullmatch(_NUMBER, text) is not None + + +def parse_minutes(value) -> int | None | str: + """Minutes from a time cell. Returns the original text when it cannot be read.""" + if value is None: + return None + if isinstance(value, (int, float)): + return int(round(value)) or None + text = clean(value) + if text is None: + return None + total, matched = 0.0, False + for num, unit in re.findall(rf"({_NUMBER})\s*(stunden|stunde|std\.?|h|minuten|min\.?|m)?\b", text.lower()): + n = float(num.replace(",", ".")) + total += n * 60 if unit and unit[0] in "sh" else n + matched = True + if not matched: + return text + return int(round(total)) or None + + +@dataclass +class Servings: + value: float | int | None + remark: str | None = None # leftover text such as "8 Stk." that does not fit the number + + +def parse_servings(value) -> Servings: + if value is None: + return Servings(None) + if isinstance(value, (int, float)): + return Servings(_round(float(value)) or None) + text = clean(value) + if text is None: + return Servings(None) + m = re.match(rf"({_NUMBER})\s*(.*)", text, re.S) + if not m: + return Servings(None, text) + number = _round(float(m.group(1).replace(",", "."))) + rest = m.group(2).strip() + if rest.lower() in ("", "portion", "portionen", "person", "personen"): + return Servings(number) + return Servings(number, text) + + +WHOLE_RECIPE_THRESHOLD = 1500 + + +def calories_basis(kcal: float) -> str: + """Guessed basis for a bare kcal value (agreed rule: above 1500 means the whole recipe).""" + return "wholeRecipe" if kcal > WHOLE_RECIPE_THRESHOLD else "perServing" + + +_URL = re.compile(r"https?://\S+") + + +def extract_urls(text: str) -> tuple[list[str], str | None]: + """URLs found in text, and the text that remains without them.""" + urls = _URL.findall(text) + rest = _URL.sub("", text).strip() + return urls, (rest or None) + + +# --- Steps ------------------------------------------------------------------- + +LONG_STEP_CHARS = 600 + +_INLINE_HEADING = re.compile(r"^([A-ZÄÖÜ][\wäöüß\-]*(?: [\wäöüß\-]+){0,2}):\s*(\S.*)$", re.S) +_ENUMERATION = re.compile(r"^(?:schritt\s*)?\d+\s*[.):]\s+", re.I) + + +def _is_heading(line: str) -> bool: + return len(line) <= 40 and not re.search(r"[.!?]$", line) and len(line.split()) <= 4 + + +@dataclass +class StepSection: + title: str | None + steps: list[str] = field(default_factory=list) + + +def split_steps(text: str | None) -> list[StepSection]: + """Instruction text -> Step Sections. Blank lines separate Steps; short lines become headings.""" + if not text: + return [] + lines = [line.strip() for line in text.replace("\r\n", "\n").split("\n")] + body = "\n".join(lines).strip() + if not body: + return [] + paragraphs = [p.strip() for p in re.split(r"\n{2,}", body) if p.strip()] + if len(paragraphs) == 1 and "\n" in paragraphs[0]: + paragraphs = [line for line in paragraphs[0].split("\n") if line] + + sections = [StepSection(None)] + + def add_step(step: str) -> None: + step = _ENUMERATION.sub("", step).strip() + if step: + sections[-1].steps.append(step) + + for i, para in enumerate(paragraphs): + head, *rest = para.split("\n") + is_last = i == len(paragraphs) - 1 + if _is_heading(head) and (rest or not is_last): + # "Teig\nMehl verkneten…" or a lone "Teig" line before the next paragraph. + sections.append(StepSection(head.rstrip(":").strip())) + if rest: + add_step("\n".join(rest)) + continue + m = _INLINE_HEADING.match(para) + if m: + # "Soße:Die vegane Mayonnaise…" + sections.append(StepSection(m.group(1))) + add_step(m.group(2)) + continue + add_step(para) + + return [s for s in sections if s.steps] diff --git a/tools/excel-import/cookit_import/recipe.py b/tools/excel-import/cookit_import/recipe.py new file mode 100644 index 0000000..b8155a6 --- /dev/null +++ b/tools/excel-import/cookit_import/recipe.py @@ -0,0 +1,382 @@ +"""Turns one recipe sheet of the workbook template into a Recipe Bundle recipe.""" + +from __future__ import annotations + +import hashlib +import re +import uuid +from dataclasses import dataclass, field + +from openpyxl.utils.cell import coordinate_from_string, column_index_from_string + +from . import parse +from .workbook import Sheet + +SKIPPED_SHEETS = {"Leeres Rezept", "Spinat-Feta-Pizza"} # the template and its example recipe + +# Stable IDs: re-running the converter yields the same Recipe IDs, so a re-import skips them. +ID_NAMESPACE = uuid.UUID("6f1d3c3e-2b7a-4c55-9a51-0c6b8f2e9d10") + +# Cells the template itself fills or that this module reads explicitly. +TEMPLATE_LABELS = {"A1", "C1", "F1", "G1", "C2", "D2", "E2", "F2", "G2", "B5", "G6", "B8", "G10", "B11"} +B12_BOILERPLATE = "Löschen Sie dieses Rezeptarbeitsblatt nicht." +PLACEHOLDER_PREFIXES = ("Geben Sie", "Kopieren Sie dieses Arbeitsblatt", "Stichwort 1") +NO_COURSE = {"Gang"} +NUTRITION_LABELS = {"fett", "fette", "kohlenhydrate", "eiweiß", "ballaststoffe", "kalorien/ 100g", "kalorien gesamt"} +FIRST_INGREDIENT_ROW = 3 + + +class Reason: + """Review Flag reasons, shown to the cook in the app.""" + + STRAY_TEXT = "Zusätzlicher Text aus der Tabelle wurde in die Notizen übernommen." + NO_SERVINGS = "Portionen fehlen." + UNKNOWN_UNIT = "Einheit nicht erkannt und in den Zutatennamen übernommen: {unit}" + UNKNOWN_AMOUNT = "Menge nicht erkannt und als Hinweis übernommen: {amount}" + MERGED_INSTRUCTIONS = "Anleitung war auf mehrere Zellen verteilt und wurde zusammengeführt." + LONG_STEP = "Sehr langer Schritt – eventuell aufteilen." + NAMELESS_LINES = "Zutatenzeilen ohne Zutat wurden verworfen: {values}" + DUPLICATE_LIST = "Doppelte Zutatenliste wurde verworfen." + ALTERNATIVE = "„ODER“-Alternative wurde als eigene Zutatengruppe übernommen." + UNREADABLE_TIME = "Zeitangabe nicht erkannt und in die Notizen übernommen." + + + +def is_recipe_sheet(sheet: Sheet) -> bool: + return ( + sheet.title not in SKIPPED_SHEETS + and parse.clean(sheet.cells.get("C2")) == "Menge" + and parse.clean(sheet.cells.get("E2")) == "Zutat" + ) + + +TEMPLATE_SHEET = "Leeres Rezept" + + +def template_photo_hashes(sheets: list[Sheet]) -> set[str]: + """The template's stock picture, which was copied onto every sheet made from it.""" + return {hashlib.md5(p).hexdigest() for s in sheets if s.title == TEMPLATE_SHEET for p in s.photos} + + +def real_photos(sheet: Sheet, placeholders: set[str]) -> list[bytes]: + return [p for p in sheet.photos if hashlib.md5(p).hexdigest() not in placeholders] + + +def recipe_id(sheet_title: str) -> str: + return str(uuid.uuid5(ID_NAMESPACE, sheet_title)) + + +def _rc(coord: str) -> tuple[int, int]: + col, row = coordinate_from_string(coord) + return row, column_index_from_string(col) + + +@dataclass +class _Builder: + sheet: Sheet + notes: list[str] = field(default_factory=list) + sources: list[str] = field(default_factory=list) + reasons: list[str] = field(default_factory=list) + used: set[str] = field(default_factory=set) + + def get(self, coord: str): + self.used.add(coord) + return self.sheet.cells.get(coord) + + def text(self, coord: str) -> str | None: + value = parse.clean(self.get(coord)) + if value and value.startswith(PLACEHOLDER_PREFIXES): + return None + return value + + def flag(self, reason: str) -> None: + if reason not in self.reasons: + self.reasons.append(reason) + + def add_note_text(self, text: str, stray: bool) -> None: + urls, rest = parse.extract_urls(text) + for url in urls: + if url not in self.sources: + self.sources.append(url) + if rest: + self.notes.append(rest) + if stray: + self.flag(Reason.STRAY_TEXT) + + +def build_recipe(sheet: Sheet) -> dict: + b = _Builder(sheet) + name = _name(b.text("C1"), sheet.title) + course = b.text("F1") + if course in NO_COURSE: + course = None + + comment_b12 = b.text("B12") + if comment_b12 and comment_b12 != B12_BOILERPLATE: + b.add_note_text(comment_b12, stray=False) + + tags = _tags(b.text("B9")) + prep = _minutes(b, "G3", "Vorbereitungszeit") + cook = _minutes(b, "G7", "Kochzeit") + servings = _servings(b) + calories = _calories(b) + groups = _ingredient_groups(b) + sections = _steps(b) + _stray_cells(b) + _stray_comments(b) + + return { + "id": recipe_id(sheet.title), + "name": name, + "course": course, + "tags": tags, + "servings": servings, + "prepMinutes": prep, + "cookMinutes": cook, + "calories": calories, + "ingredientGroups": groups, + "stepSections": sections, + "photos": [], + "sources": b.sources, + "notes": "\n\n".join(b.notes) or None, + "favourite": False, + "reviewReasons": b.reasons, + } + + +def _name(title_cell: str | None, sheet_title: str) -> str: + """Sheet tabs are cut at 31 characters, title cells are sometimes abbreviated: keep the longer one.""" + if not title_cell or title_cell in SKIPPED_SHEETS: + return sheet_title.strip() + return max(title_cell, sheet_title.strip(), key=len) + + +def _tags(text: str | None) -> list[str]: + tags, seen = [], set() + for tag in (text or "").replace(";", ",").split(","): + tag = tag.strip() + if tag and tag.casefold() not in seen: + seen.add(tag.casefold()) + tags.append(tag) + return tags + + +def _minutes(b: _Builder, coord: str, label: str) -> int | None: + value = parse.parse_minutes(b.get(coord)) + if isinstance(value, str): + if not value.startswith(PLACEHOLDER_PREFIXES): + b.notes.append(f"{label}: {value}") + b.flag(Reason.UNREADABLE_TIME) + return None + return value + + +def _servings(b: _Builder): + raw = b.get("G11") + if parse.clean(raw) and str(raw).startswith(PLACEHOLDER_PREFIXES): + raw = None + servings = parse.parse_servings(raw) + if servings.remark: + b.add_note_text(f"Ergebnis: {servings.remark}", stray=True) + if servings.value is None: + b.flag(Reason.NO_SERVINGS) + return servings.value + + +def _calories(b: _Builder) -> dict | None: + raw = b.get("B6") + comment = b.sheet.comments.get("B6") + b.used.add("comment:B6") + if not parse.is_number(raw): + text = parse.clean(raw) + if text and not text.startswith(PLACEHOLDER_PREFIXES): + b.add_note_text(f"Kalorien: {text}", stray=True) + return None + kcal = float(str(raw).replace(",", ".")) + if kcal <= 0: + return None + if comment and "pro portion" in comment.lower(): + basis = "perServing" + else: + # Guessed, but not flagged: the threshold matched every value in the workbook on review. + basis = parse.calories_basis(kcal) + if comment: + b.add_note_text(f"Kalorien: {comment}", stray=True) + return {"kcal": int(round(kcal)), "basis": basis} + + +# --- Ingredients ------------------------------------------------------------- + + +def _row_cells(b: _Builder, row: int): + return b.sheet.cells.get(f"C{row}"), b.sheet.cells.get(f"D{row}"), b.sheet.cells.get(f"E{row}") + + +def _ingredient_rows(b: _Builder) -> list[int]: + rows = { + _rc(coord)[0] + for coord in b.sheet.cells + if coord[0] in "CDE" and _rc(coord)[0] >= FIRST_INGREDIENT_ROW and _rc(coord)[1] in (3, 4, 5) + } + return sorted(rows) + + +def _blocks(rows: list[int]) -> list[list[int]]: + """Runs of consecutive rows; a blank row in the sheet separates Ingredient Groups.""" + blocks: list[list[int]] = [] + for row in rows: + if blocks and row == blocks[-1][-1] + 1: + blocks[-1].append(row) + else: + blocks.append([row]) + return blocks + + +def _group_marker(comment: str | None) -> str | None: + """A cell comment such as "Ab hier: Zutaten für die Sauce" names the group starting at that row.""" + if not comment or "zutaten" not in comment.lower(): + return None + m = re.search(r"zutaten\s+für\s+(?:die|den|das)?\s*(.+)", comment, re.I | re.S) + name = (m.group(1) if m else comment).strip().rstrip(":").strip() + return name[:1].upper() + name[1:] + + +def _ingredient_groups(b: _Builder) -> list[dict]: + groups: list[dict] = [] + seen_names: set[str] = set() + pending_name: str | None = None + dropped: list[str] = [] + + for block in _blocks(_ingredient_rows(b)): + for row in block: + b.used.update({f"C{row}", f"D{row}", f"E{row}"}) + + block_rows = [(row, *_row_cells(b, row)) for row in block] + + # Nutrition tables pasted into the ingredient columns belong in the Notes. + if any((parse.clean(d) or "").lower() in NUTRITION_LABELS for _, _, d, _ in block_rows): + lines = [" ".join(str(v).strip() for v in (c, d, e) if parse.clean(v)) for _, c, d, e in block_rows] + b.add_note_text("\n".join(lines), stray=True) + continue + + named = [parse.clean(e).casefold() for _, _, _, e in block_rows if parse.clean(e)] + if len(named) >= 3 and sum(n in seen_names for n in named) / len(named) >= 0.8: + b.flag(Reason.DUPLICATE_LIST) + continue + + current: dict | None = None + for row, c, d, e in block_rows: + if any((parse.clean(v) or "").upper() == "ODER" for v in (c, e)) and not parse.clean(d): + pending_name = "Alternative" + b.flag(Reason.ALTERNATIVE) + current = None + continue + if not parse.clean(e): + if all(v is None or parse.is_number(v) for v in (c, d)): + continue # leftover numbers from rescaling a recipe + if parse.clean(c) or parse.clean(d): + dropped.append(" ".join(str(v).strip() for v in (c, d) if parse.clean(v))) + continue + + marker = None + for col in "CDE": + marker = marker or _group_marker(b.sheet.comments.get(f"{col}{row}")) + if marker: + pending_name = marker + current = None + + if current is None: + current = {"name": pending_name, "lines": []} + pending_name = None + groups.append(current) + current["lines"].append(_line(b, row, c, d, e)) + seen_names.add(parse.clean(e).casefold()) + + if dropped: + b.flag(Reason.NAMELESS_LINES.format(values=", ".join(dropped))) + return [g for g in groups if g["lines"]] + + +def _line(b: _Builder, row: int, c, d, e) -> dict: + name = parse.clean(e) + remarks: list[str] = [] + amount_text = parse.clean(c) + if amount_text and amount_text.startswith("(") and name.endswith(")"): + # "(2 | Teelöffel | Oregano)": the whole line is in parentheses, i.e. optional. + c, name = amount_text[1:], name[:-1].strip() + remarks.append("optional") + amount = parse.parse_amount(c) + unit = parse.parse_unit(d) + unit_text = parse.clean(d) + + if amount is None: + b.flag(Reason.UNKNOWN_AMOUNT.format(amount=parse.clean(c))) + remarks.append(parse.clean(c)) + amount = (None, None) + + if unit_text and unit is None: + combined = parse.split_amount_unit(unit_text) + if combined and amount == (None, None): + amount, unit = (combined[0], None), combined[1] + else: + b.flag(Reason.UNKNOWN_UNIT.format(unit=unit_text)) + name = f"{unit_text} {name}" + + for col in "CDE": + comment = b.sheet.comments.get(f"{col}{row}") + b.used.add(f"comment:{col}{row}") + if comment and not _group_marker(comment): + remarks.append(comment) + + return { + "amount": amount[0], + "amountMax": amount[1], + "unit": unit, + "name": name, + "remark": "; ".join(remarks) or None, + } + + +# --- Steps ------------------------------------------------------------------- + + +def _steps(b: _Builder) -> list[dict]: + instruction_cells = sorted((c for c in b.sheet.cells if c.startswith("F") and c not in ("F1", "F2")), key=_rc) + texts = [] + for coord in instruction_cells: + text = b.text(coord) + if text: + texts.append(text) + b.used.update(instruction_cells) + if len(texts) > 1: + b.flag(Reason.MERGED_INSTRUCTIONS) + + sections = parse.split_steps("\n\n".join(texts)) + if any(len(step) > parse.LONG_STEP_CHARS for s in sections for step in s.steps): + b.flag(Reason.LONG_STEP) + return [{"title": s.title, "steps": s.steps} for s in sections] + + +# --- Everything else ----------------------------------------------------------- + + +def _stray_cells(b: _Builder) -> None: + rows: dict[int, list[tuple[int, str]]] = {} + for coord, value in b.sheet.cells.items(): + if coord in b.used or coord in TEMPLATE_LABELS: + continue + text = parse.clean(value) + if not text or text == B12_BOILERPLATE or text.startswith(PLACEHOLDER_PREFIXES): + continue + row, col = _rc(coord) + rows.setdefault(row, []).append((col, text)) + for row in sorted(rows): + line = " ".join(text for _, text in sorted(rows[row])) + b.add_note_text(line, stray=True) + + +def _stray_comments(b: _Builder) -> None: + for coord, comment in b.sheet.comments.items(): + if f"comment:{coord}" in b.used or _group_marker(comment): + continue + b.add_note_text(comment, stray=True) diff --git a/tools/excel-import/cookit_import/workbook.py b/tools/excel-import/cookit_import/workbook.py new file mode 100644 index 0000000..8610621 --- /dev/null +++ b/tools/excel-import/cookit_import/workbook.py @@ -0,0 +1,112 @@ +"""Reads what the converter needs from the .xlsm: cell values, cell comments and embedded photos.""" + +from __future__ import annotations + +import posixpath +import re +import zipfile +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field + +import openpyxl + +NS = { + "main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "rel": "http://schemas.openxmlformats.org/package/2006/relationships", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", +} + + +@dataclass +class Sheet: + title: str + cells: dict[str, object] # "C3" -> raw value (cached formula results) + comments: dict[str, str] = field(default_factory=dict) # "C3" -> comment text without author + photos: list[bytes] = field(default_factory=list) # embedded pictures, top-left first + + +def read_sheets(path: str) -> list[Sheet]: + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + sheets = [] + for ws in wb.worksheets: + cells = {} + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None and str(cell.value).strip() != "": + cells[cell.coordinate] = cell.value + sheets.append(Sheet(ws.title, cells)) + wb.close() + + with zipfile.ZipFile(path) as z: + parts = _sheet_parts(z) + for sheet in sheets: + part = parts.get(sheet.title) + if part: + sheet.comments = _comments(z, part) + sheet.photos = _photos(z, part) + return sheets + + +def _rels(z: zipfile.ZipFile, part: str) -> dict[str, str]: + """Relationship id -> absolute part path for a part's .rels file.""" + rels_path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels") + if rels_path not in z.namelist(): + return {} + root = ET.fromstring(z.read(rels_path)) + out = {} + for rel in root.findall("rel:Relationship", NS): + target = rel.get("Target") + if rel.get("TargetMode") == "External": + continue + out[rel.get("Id")] = ( + target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join(posixpath.dirname(part), target)) + ) + return out + + +def _sheet_parts(z: zipfile.ZipFile) -> dict[str, str]: + root = ET.fromstring(z.read("xl/workbook.xml")) + rels = _rels(z, "xl/workbook.xml") + return { + s.get("name"): rels[s.get(f"{{{NS['r']}}}id")] + for s in root.find("main:sheets", NS) + if s.get(f"{{{NS['r']}}}id") in rels + } + + +def _comments(z: zipfile.ZipFile, sheet_part: str) -> dict[str, str]: + out = {} + for target in _rels(z, sheet_part).values(): + if not re.search(r"comments\d*\.xml$", target): + continue + root = ET.fromstring(z.read(target)) + authors = [a.text or "" for a in root.find("main:authors", NS)] + for c in root.find("main:commentList", NS): + text = "".join(t.text or "" for t in c.iter(f"{{{NS['main']}}}t")).strip() + for author in authors: + if author and text.startswith(author + ":"): + text = text[len(author) + 1 :].strip() + if text: + out[c.get("ref")] = text + return out + + +def _photos(z: zipfile.ZipFile, sheet_part: str) -> list[bytes]: + found = [] + for target in _rels(z, sheet_part).values(): + if not re.search(r"drawings/drawing\d+\.xml$", target): + continue + media = _rels(z, target) + root = ET.fromstring(z.read(target)) + for anchor in root: + frm = anchor.find("xdr:from", NS) + row = int(frm.find("xdr:row", NS).text) if frm is not None else 0 + col = int(frm.find("xdr:col", NS).text) if frm is not None else 0 + for blip in anchor.iter(f"{{{NS['a']}}}blip"): + embed = blip.get(f"{{{NS['r']}}}embed") + if embed in media: + found.append((row, col, z.read(media[embed]))) + found.sort(key=lambda f: (f[0], f[1])) + return [data for _, _, data in found] diff --git a/tools/excel-import/pytest.ini b/tools/excel-import/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/tools/excel-import/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/tools/excel-import/requirements.txt b/tools/excel-import/requirements.txt new file mode 100644 index 0000000..ec1793c --- /dev/null +++ b/tools/excel-import/requirements.txt @@ -0,0 +1,3 @@ +openpyxl==3.1.5 +pillow==13.0.0 +pytest==9.1.1 diff --git a/tools/excel-import/tests/test_parse.py b/tools/excel-import/tests/test_parse.py new file mode 100644 index 0000000..a6ca528 --- /dev/null +++ b/tools/excel-import/tests/test_parse.py @@ -0,0 +1,135 @@ +import pytest + +from cookit_import import parse + + +@pytest.mark.parametrize( + "value, expected", + [ + (275, (275, None)), + (1.5, (1.5, None)), + (0.08333333333333333, (0.083, None)), + ("0,5", (0.5, None)), + ("½", (0.5, None)), + ("1/2", (0.5, None)), + ("1 1/2", (1.5, None)), + ("3/4", (0.75, None)), + ("2-3", (2, 3)), + ("10 - 12", (10, 12)), + ("200-250", (200, 250)), + (None, (None, None)), + (0, (None, None)), + ], +) +def test_parse_amount(value, expected): + assert parse.parse_amount(value) == expected + + +@pytest.mark.parametrize("value", ["opt. als Topping", "(2", "ODER"]) +def test_parse_amount_rejects_text(value): + assert parse.parse_amount(value) is None + + +@pytest.mark.parametrize( + "value, key", + [ + ("Gramm", "gram"), + ("Mililiter", "milliliter"), + ("milliliter", "milliliter"), + ("Zehe/n", "clove"), + ("Zehen", "clove"), + ("Kleine", "small"), + ("kleine", "small"), + ("Große", "large"), + ("Zweig (e)", "sprig"), + ("Msp.", "knifeTip"), + ("Etwas", "some"), + ("Tassen", "cup"), + ], +) +def test_parse_unit(value, key): + assert parse.parse_unit(value) == key + + +def test_parse_unit_unknown(): + assert parse.parse_unit("Fett") is None + + +@pytest.mark.parametrize( + "value, expected", + [("100 g", (100, "gram")), ("125 ml", (125, "milliliter")), ("1 TL", (1, "teaspoon")), ("1 Pkt.", (1, "pack")), ("3", (3, None))], +) +def test_split_amount_unit(value, expected): + assert parse.split_amount_unit(value) == expected + + +@pytest.mark.parametrize( + "value, expected", + [(30, 30), ("45 Minuten", 45), ("2 Stunden", 120), ("1h 30min", 90), ("0 Minuten", None), (None, None), ("über Nacht", "über Nacht")], +) +def test_parse_minutes(value, expected): + assert parse.parse_minutes(value) == expected + + +@pytest.mark.parametrize( + "value, number, remark", + [ + (4, 4, None), + (3.5, 3.5, None), + ("6 Portionen", 6, None), + ("4 Personen", 4, None), + ("8 Stk.", 8, "8 Stk."), + ("2 Rollen;\neine Rolle entspricht 8 Scheiben", 2, "2 Rollen;\neine Rolle entspricht 8 Scheiben"), + (None, None, None), + ], +) +def test_parse_servings(value, number, remark): + s = parse.parse_servings(value) + assert (s.value, s.remark) == (number, remark) + + +def test_calories_basis_threshold(): + assert parse.calories_basis(1500) == "perServing" + assert parse.calories_basis(1501) == "wholeRecipe" + + +def test_extract_urls(): + urls, rest = parse.extract_urls("Quelle: https://sallys-blog.de/rezepte/focaccia") + assert urls == ["https://sallys-blog.de/rezepte/focaccia"] + assert rest == "Quelle:" + + +class TestSplitSteps: + def test_blank_lines_separate_steps(self): + sections = parse.split_steps("\nZutaten mischen.\n\nTeig gehen lassen.\n\n\nBacken.") + assert [(s.title, s.steps) for s in sections] == [(None, ["Zutaten mischen.", "Teig gehen lassen.", "Backen."])] + + def test_heading_line_starts_a_section(self): + sections = parse.split_steps("Vorteig\nVerrühre die Zutaten.\n\nTeig\nFüge das Mehl hinzu.") + assert [(s.title, s.steps) for s in sections] == [ + ("Vorteig", ["Verrühre die Zutaten."]), + ("Teig", ["Füge das Mehl hinzu."]), + ] + + def test_lone_heading_paragraph(self): + sections = parse.split_steps("Soße:\n\nAlles verrühren.\n\nAbschmecken.") + assert [(s.title, s.steps) for s in sections] == [("Soße", ["Alles verrühren.", "Abschmecken."])] + + def test_inline_heading(self): + sections = parse.split_steps("Salat:Die Zwiebel schneiden.\n\nSoße:Mayo verrühren.") + assert [(s.title, s.steps) for s in sections] == [("Salat", ["Die Zwiebel schneiden."]), ("Soße", ["Mayo verrühren."])] + + def test_single_paragraph_splits_on_lines(self): + sections = parse.split_steps(" Mehl und Salz mischen.\n Wasser zugeben und kneten.") + assert sections[0].steps == ["Mehl und Salz mischen.", "Wasser zugeben und kneten."] + + def test_short_last_line_stays_a_step(self): + sections = parse.split_steps("Alles anbraten.\n\nServieren") + assert sections[0].steps == ["Alles anbraten.", "Servieren"] + + def test_strips_enumeration(self): + sections = parse.split_steps("1. Wasser kochen.\n\n2) Nudeln garen.") + assert sections[0].steps == ["Wasser kochen.", "Nudeln garen."] + + def test_empty(self): + assert parse.split_steps("\n\n ") == [] diff --git a/tools/excel-import/tests/test_recipe.py b/tools/excel-import/tests/test_recipe.py new file mode 100644 index 0000000..4a43b7d --- /dev/null +++ b/tools/excel-import/tests/test_recipe.py @@ -0,0 +1,178 @@ +from cookit_import.recipe import Reason, build_recipe, is_recipe_sheet, recipe_id +from cookit_import.workbook import Sheet + +TEMPLATE = { + "C1": "Testrezept", + "F1": "Hauptgericht", + "G1": "Rezeptindex", + "C2": "Menge", + "D2": "Maßeinheit", + "E2": "Zutat", + "F2": "Anleitungen", + "G2": "Vorbereitungszeit", + "B5": "Kalorien", + "G6": "Kochzeit", + "B8": "Rezept-Stichwörter", + "G10": "Ergebnis (Portionen)", + "B11": "Kommentare", + "B12": "Löschen Sie dieses Rezeptarbeitsblatt nicht.", +} + + +def sheet(title="Testrezept", comments=None, **cells) -> Sheet: + return Sheet(title, {**TEMPLATE, **cells}, comments or {}) + + +def lines(recipe): + return [[(l["amount"], l["unit"], l["name"]) for l in g["lines"]] for g in recipe["ingredientGroups"]] + + +def test_recognises_recipe_sheets_and_skips_template_ones(): + assert is_recipe_sheet(sheet()) + assert not is_recipe_sheet(sheet(title="Leeres Rezept")) + assert not is_recipe_sheet(sheet(title="Spinat-Feta-Pizza")) + assert not is_recipe_sheet(Sheet("Speiseplan", {"C2": "Montag"})) + + +def test_basic_fields(): + r = build_recipe(sheet(G3=20, G7="15 Minuten", G11=4, B6=775, B9="Tomaten, Nudeln, tomaten, Käse", F3="Kochen.\n\nEssen.")) + assert r["id"] == recipe_id("Testrezept") + assert r["name"] == "Testrezept" + assert r["course"] == "Hauptgericht" + assert r["tags"] == ["Tomaten", "Nudeln", "Käse"] + assert (r["prepMinutes"], r["cookMinutes"], r["servings"]) == (20, 15, 4) + assert r["calories"] == {"kcal": 775, "basis": "perServing"} + assert r["stepSections"] == [{"title": None, "steps": ["Kochen.", "Essen."]}] + assert r["notes"] is None + assert r["reviewReasons"] == [] + + +def test_ids_are_stable_per_sheet(): + assert recipe_id("Focaccia") == recipe_id("Focaccia") != recipe_id("Brot") + + +def test_placeholder_course_and_missing_servings(): + r = build_recipe(sheet(F1="Gang")) + assert r["course"] is None + assert Reason.NO_SERVINGS in r["reviewReasons"] + + +def test_longer_name_wins_and_template_title_is_ignored(): + assert build_recipe(sheet(title="Flammkuchen, low carb", C1="Flammkuchen"))["name"] == "Flammkuchen, low carb" + assert build_recipe(sheet(title="Kohlrabi-Kartoffel-Auflauf", C1="Leeres Rezept"))["name"] == "Kohlrabi-Kartoffel-Auflauf" + + +def test_blank_rows_separate_ingredient_groups_and_units_are_canonical(): + r = build_recipe(sheet(G11=1, C3=300, D3="Gramm", E3="Wasser", C4=1, D4="Teelöffel", E4="Zucker", E6="Rosmarin")) + assert lines(r) == [[(300, "gram", "Wasser"), (1, "teaspoon", "Zucker")], [(None, None, "Rosmarin")]] + assert [g["name"] for g in r["ingredientGroups"]] == [None, None] + + +def test_rescaling_helper_numbers_are_dropped_silently(): + r = build_recipe(sheet(G11=4, C3=350, D3="Gramm", E3="Rigatoni", C33=350, D33=87.5, C34=1, D34=0.25, C10=125)) + assert lines(r) == [[(350, "gram", "Rigatoni")]] + assert r["reviewReasons"] == [] + + +def test_oder_row_starts_alternative_group(): + r = build_recipe(sheet(G11=8, C3=375, D3="Gramm", E3="Mehl", C5="ODER", C7=295, D7="Gramm", E7="Mehl", C8=80, D8="Gramm", E8="Proteinpulver")) + assert [g["name"] for g in r["ingredientGroups"]] == [None, "Alternative"] + assert Reason.ALTERNATIVE in r["reviewReasons"] + + +def test_duplicate_ingredient_list_is_dropped(): + first = dict(C3=100, D3="Gramm", E3="Mehl", C4=3, E4="Eier", C5=125, D5="Milliliter", E5="Milch") + copy = dict(D20="100 g", E20="Mehl", D21="3", E21="Eier", D22="125 ml", E22="Milch") + r = build_recipe(sheet(G11=2, **first, **copy)) + assert len(r["ingredientGroups"]) == 1 + assert Reason.DUPLICATE_LIST in r["reviewReasons"] + + +def test_amount_typed_into_unit_column(): + r = build_recipe(sheet(G11=2, D3="100 g", E3="Mehl", D4="1 TL", E4="Zucker")) + assert lines(r) == [[(100, "gram", "Mehl"), (1, "teaspoon", "Zucker")]] + + +def test_ranges_fractions_and_unknown_amounts(): + r = build_recipe(sheet(G11=2, C3="2-3", D3="Esslöffel", E3="Kakao", C4="½", D4="Bund", E4="Schnittlauch", C5="opt. als Topping", E5="Sesam")) + first, second, third = r["ingredientGroups"][0]["lines"] + assert (first["amount"], first["amountMax"]) == (2, 3) + assert second["amount"] == 0.5 + assert (third["amount"], third["remark"]) == (None, "opt. als Topping") + assert Reason.UNKNOWN_AMOUNT.format(amount="opt. als Topping") in r["reviewReasons"] + + +def test_parenthesised_line_is_optional(): + r = build_recipe(sheet(G11=4, C3="(2", D3="Teelöffel", E3="Oregano)")) + line = r["ingredientGroups"][0]["lines"][0] + assert (line["amount"], line["unit"], line["name"], line["remark"]) == (2, "teaspoon", "Oregano", "optional") + + +def test_unknown_unit_moves_into_name(): + r = build_recipe(sheet(G11=2, C3=2, D3="Würfel", E3="Brühe")) + assert lines(r) == [[(2, None, "Würfel Brühe")]] + assert Reason.UNKNOWN_UNIT.format(unit="Würfel") in r["reviewReasons"] + + +def test_comment_group_markers_name_groups_and_other_comments_become_remarks(): + comments = {"C3": "Zutaten für die Rouladen", "C5": "Ab hier: \nZutaten für die Sauce", "E4": "kleiner Espresso-Löffel"} + r = build_recipe(sheet(G11=6, comments=comments, C3=6, E3="Rinderrouladen", C4=1, D4="Teelöffel", E4="Senf", C5=50, D5="Gramm", E5="Butter")) + assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce"] + assert r["ingredientGroups"][0]["lines"][1]["remark"] == "kleiner Espresso-Löffel" + + +def test_pro_portion_comment_settles_calories_basis(): + r = build_recipe(sheet(G11=4, B6=662, comments={"B6": "Pro Portion"})) + assert r["calories"] == {"kcal": 662, "basis": "perServing"} + assert r["reviewReasons"] == [] + + +def test_high_calories_mean_whole_recipe(): + r = build_recipe(sheet(G11=1, B6=3831)) + assert r["calories"]["basis"] == "wholeRecipe" + + +def test_comment_b12_and_stray_cells_go_to_notes_and_urls_to_sources(): + r = build_recipe( + sheet( + G11=1, + B12="Springform 26 cm", + I12="ca.", + J12="15", + K12="Dampfnudeln", + B17="https://sallys-blog.de/rezepte/focaccia", + ) + ) + assert r["notes"] == "Springform 26 cm\n\nca. 15 Dampfnudeln" + assert r["sources"] == ["https://sallys-blog.de/rezepte/focaccia"] + assert Reason.STRAY_TEXT in r["reviewReasons"] + + +def test_template_comment_is_not_a_note(): + r = build_recipe(sheet(G11=1)) + assert r["notes"] is None + assert r["reviewReasons"] == [] + + +def test_nutrition_rows_in_ingredient_columns_become_notes(): + r = build_recipe(sheet(G11=1, C3=3, E3="Eier", D18="Kalorien/ 100g", E18="263.47", D19="Fett", E19="17.92")) + assert lines(r) == [[(3, None, "Eier")]] + assert r["notes"] == "Kalorien/ 100g 263.47\nFett 17.92" + + +def test_instructions_split_over_cells_are_merged_and_flagged(): + r = build_recipe(sheet(G11=6, F3="Teig rühren.", F19="Spätzle abschrecken.")) + assert r["stepSections"][0]["steps"] == ["Teig rühren.", "Spätzle abschrecken."] + assert Reason.MERGED_INSTRUCTIONS in r["reviewReasons"] + + +def test_long_step_is_flagged(): + r = build_recipe(sheet(G11=1, F3="Rühren. " * 100)) + assert Reason.LONG_STEP in r["reviewReasons"] + + +def test_unreadable_time_goes_to_notes(): + r = build_recipe(sheet(G11=1, G3="über Nacht")) + assert r["prepMinutes"] is None + assert r["notes"] == "Vorbereitungszeit: über Nacht" + assert Reason.UNREADABLE_TIME in r["reviewReasons"] diff --git a/tools/excel-import/tests/test_workbook.py b/tools/excel-import/tests/test_workbook.py new file mode 100644 index 0000000..5b53c5a --- /dev/null +++ b/tools/excel-import/tests/test_workbook.py @@ -0,0 +1,63 @@ +"""Checks against the real (private, untracked) workbook. Skipped when it isn't there.""" + +import io +import json +import os +import zipfile + +import pytest +from PIL import Image + +from cookit_import.bundle import MAX_PHOTO_EDGE, write_bundle +from cookit_import.recipe import build_recipe, is_recipe_sheet, real_photos, template_photo_hashes +from cookit_import.workbook import read_sheets + +WORKBOOK = os.environ.get("COOKIT_WORKBOOK", os.path.join(os.path.dirname(__file__), "..", "..", "..", "Wochenspeiseplan.xlsm")) + +pytestmark = pytest.mark.skipif(not os.path.exists(WORKBOOK), reason="Wochenspeiseplan.xlsm not available") + + +@pytest.fixture(scope="module") +def recipes(): + all_sheets = read_sheets(WORKBOOK) + placeholders = template_photo_hashes(all_sheets) + return {s.title: (build_recipe(s), real_photos(s, placeholders)) for s in all_sheets if is_recipe_sheet(s)} + + +def test_all_recipe_sheets_found(recipes): + assert len(recipes) == 214 + assert "Leeres Rezept" not in recipes and "Spinat-Feta-Pizza" not in recipes + + +def test_focaccia(recipes): + r, photos = recipes["Focaccia"] + assert [s["title"] for s in r["stepSections"]][:2] == ["Vorteig", "Teig"] + assert len(r["ingredientGroups"]) == 3 + assert len(r["sources"]) == 2 + assert r["notes"] == "22x22 cm eckige Form" + assert photos == [] # only the template's stock picture was on the sheet + + +def test_template_stock_photo_is_dropped_but_real_photos_kept(recipes): + without = [title for title, (_, photos) in recipes.items() if not photos] + assert 25 <= len(without) <= 35 + assert len(recipes["Melonen-Minze-Shake"][1]) == 3 + + +def test_rinderrouladen_groups_from_comments(recipes): + r, _ = recipes["Klassische Rinderrouladen"] + assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce", "Zusätzliche Zutaten"] + + +def test_bundle_round_trip(recipes, tmp_path): + subset = [recipes[t] for t in ("Focaccia", "Melonen-Minze-Shake", "Hähnchen")] + out = tmp_path / "bundle.zip" + write_bundle(str(out), [(dict(r), photos) for r, photos in subset]) + with zipfile.ZipFile(out) as z: + manifest = json.loads(z.read("bundle.json")) + assert manifest["format"] == "cookit.recipe-bundle" and manifest["version"] == 1 + shake = next(r for r in manifest["recipes"] if r["name"] == "Melonen-Minze-Shake") + assert len(shake["photos"]) == 3 + for name in shake["photos"]: + img = Image.open(io.BytesIO(z.read(name))) + assert img.format == "JPEG" and max(img.size) <= MAX_PHOTO_EDGE