Initial commit: CookIT recipe collection and week planner

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

No files matched your search

+25
View File
@@ -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
+30
View File
@@ -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'
+104
View File
@@ -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
+36
View File
@@ -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
```
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+14
View File
@@ -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
+54
View File
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+66
View File
@@ -0,0 +1,66 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="CookIt"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- A Recipe Bundle ("Focaccia.cookit", a zip) opened with CookIt, e.g. after Quick Share. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="application/zip"/>
<data android:mimeType="application/octet-stream"/>
</intent-filter>
<!-- …or shared to CookIt from another app. -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/zip"/>
<data android:mimeType="application/octet-stream"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Opening Recipe Sources in the browser (url_launcher). -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>
</manifest>
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cookit_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cookit_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ draws its own splash screen: keep it in CookIt's mint, day or night. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/cookit_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/cookit_background</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- CookIt starts in its light look even when the phone is in dark mode (the cook can
switch to "Dunkel" in the app), so the launch screen stays light too. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/cookit_background</item>
</style>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ draws its own splash screen: keep it in CookIt's mint, day or night. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/cookit_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/cookit_background</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Adaptive launcher icon background (from flutter_launcher_icons, see pubspec.yaml). -->
<color name="ic_launcher_background">#65A86C</color>
<!-- CookIt's light background (CookItColors.light.background); the app starts light by default. -->
<color name="cookit_background">#E3F1D5</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+21
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+5
View File
@@ -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
+25
View File
@@ -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")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -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.
+93
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -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.
@@ -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.
+9
View File
@@ -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-<uuid>` 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.
+10
View File
@@ -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.
+91
View File
@@ -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/<recipe-id>-1.jpg
photos/<recipe-id>-2.jpg
```
Photos are JPEG, at most 1600 px on the long side.
"Als Datei teilen" sends one Recipe as a Recipe Bundle named `<Recipe name>.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).
+8
View File
@@ -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
+12
View File
@@ -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
+6
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'src/app.dart';
import 'src/bundle/bundle_service.dart';
import 'src/database/database.dart';
import 'src/planning/plan_repository.dart';
import 'src/planning/plan_share_service.dart';
import 'src/planning/week_plan_provider.dart';
import 'src/recipes/course_repository.dart';
import 'src/recipes/photo_store.dart';
import 'src/recipes/recipe_list_provider.dart';
import 'src/recipes/recipe_repository.dart';
import 'src/recipes/unit_catalog.dart';
import 'src/recipes/unit_repository.dart';
import 'src/settings/settings_controller.dart';
import 'src/sharing/recipe_share_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
final db = AppDatabase(driftDatabase(name: 'cookit'));
final photos = await PhotoStore.open();
final recipes = RecipeRepository(db, photos);
final courses = CourseRepository(db);
final units = UnitRepository(db);
final plan = PlanRepository(db);
final bundles = BundleService(recipes, courses, units, photos);
final settings = SettingsController(SettingsService());
await settings.loadSettings();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: settings),
Provider.value(value: photos),
Provider.value(value: recipes),
Provider.value(value: courses),
Provider.value(value: units),
Provider.value(value: plan),
ChangeNotifierProvider(create: (_) => UnitCatalog(units)),
Provider.value(value: bundles),
Provider(create: (_) => RecipeShareService(recipes, courses, units, photos, bundles)),
Provider(create: (_) => PlanShareService(recipes, courses, units, photos, plan, bundles)),
ChangeNotifierProvider(create: (_) => RecipeListProvider(recipes, courses)),
ChangeNotifierProvider(create: (_) => WeekPlanProvider(plan)),
],
child: const CookItApp(),
),
);
}
+111
View File
@@ -0,0 +1,111 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'bundle/bundle_codec.dart';
import 'bundle/bundle_service.dart';
import 'cooking_mode/cooking_mode_view.dart';
import 'design/app_theme.dart';
import 'home/home_view.dart';
import 'localization/app_localizations.dart';
import 'planning/plan_share_service.dart';
import 'planning/views/shared_week_view.dart';
import 'recipes/views/recipe_detail_view.dart';
import 'recipes/views/recipe_edit_view.dart';
import 'settings/courses_view.dart';
import 'settings/meals_view.dart';
import 'settings/settings_controller.dart';
import 'settings/settings_view.dart';
import 'settings/units_view.dart';
import 'sharing/cookit_platform.dart';
import 'sharing/recipe_share_service.dart';
import 'sharing/shared_recipe.dart';
import 'sharing/views/scan_recipe_view.dart';
import 'sharing/views/share_recipe_view.dart';
import 'sharing/views/shared_recipe_view.dart';
class CookItApp extends StatefulWidget {
const CookItApp({super.key});
@override
State<CookItApp> createState() => _CookItAppState();
}
class _CookItAppState extends State<CookItApp> {
final _navigator = GlobalKey<NavigatorState>();
final _messenger = GlobalKey<ScaffoldMessengerState>();
@override
void initState() {
super.initState();
CookItPlatform.listenForFiles(_openFile);
}
/// A file opened with or shared to CookIt: a shared week opens its preview,
/// so does a single Recipe; a whole collection is imported as in the settings.
Future<void> _openFile(Uint8List bytes) async {
final shares = context.read<RecipeShareService>();
final bundles = context.read<BundleService>();
String message(Object e) => e is BundleFormatException ? e.message : '$e';
try {
final contents = await bundles.read(bytes);
if (contents.plan != null) {
await _navigator.currentState?.pushNamed(SharedWeekView.routeName, arguments: SharedWeek(contents));
return;
}
final shared = shares.fromContents(contents);
if (shared != null) {
await _navigator.currentState?.pushNamed(SharedRecipeView.routeName, arguments: shared);
return;
}
final result = await bundles.importBundle(bytes);
final l10n = AppLocalizations.of(_navigator.currentContext!);
_messenger.currentState?.showSnackBar(SnackBar(content: Text(l10n.importResult(result.added, result.skipped))));
} catch (e) {
final navigatorContext = _navigator.currentContext;
if (navigatorContext == null || !navigatorContext.mounted) return;
final l10n = AppLocalizations.of(navigatorContext);
_messenger.currentState?.showSnackBar(SnackBar(content: Text(l10n.fileUnreadable(message(e)))));
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: _navigator,
scaffoldMessengerKey: _messenger,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: context.watch<SettingsController>().themeMode,
restorationScopeId: 'app',
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
settings: settings,
builder: (context) => switch (settings.name) {
RecipeDetailView.routeName => RecipeDetailView(recipeId: settings.arguments! as String),
CookingModeView.routeName => CookingModeView(recipeId: settings.arguments! as String),
RecipeEditView.routeName => RecipeEditView(recipeId: settings.arguments as String?),
SettingsView.routeName => const SettingsView(),
CoursesView.routeName => const CoursesView(),
UnitsView.routeName => const UnitsView(),
MealsView.routeName => const MealsView(),
ShareRecipeView.routeName => ShareRecipeView(recipeId: settings.arguments! as String),
ScanRecipeView.routeName => const ScanRecipeView(),
SharedRecipeView.routeName => SharedRecipeView(shared: settings.arguments! as SharedRecipe),
SharedWeekView.routeName => SharedWeekView(week: settings.arguments! as SharedWeek),
_ => const HomeView(),
},
),
);
}
}
+193
View File
@@ -0,0 +1,193 @@
import '../recipes/models/recipe.dart';
/// JSON side of the Recipe Bundle format (docs/recipe-bundle-format.md).
class BundleCodec {
static const format = 'cookit.recipe-bundle';
/// The newest version this app reads. 2 added the cook's own units
/// (`units`), 3 a week of the Week Plan (`plan`). A bundle is written with
/// the lowest version its content needs, so older apps still read what they can.
static const version = 3;
static const manifestName = 'bundle.json';
/// [photoPaths] are the paths inside the zip, in the Recipe's photo order.
static Map<String, dynamic> encodeRecipe(
Recipe recipe, {
required String? courseName,
required List<String> photoPaths,
}) => {
'id': recipe.id,
'name': recipe.name,
'course': courseName,
'tags': recipe.tags,
'servings': recipe.servings,
'prepMinutes': recipe.prepMinutes,
'cookMinutes': recipe.cookMinutes,
'calories': recipe.calories?.toJson(),
'ingredientGroups': [for (final g in recipe.ingredientGroups) g.toJson()],
'stepSections': [for (final s in recipe.stepSections) s.toJson()],
'photos': photoPaths,
'sources': recipe.sources,
'notes': recipe.notes,
'favourite': recipe.favourite,
'reviewReasons': recipe.reviewReasons,
};
static Map<String, dynamic> encodeManifest(
List<Map<String, dynamic>> recipes, {
required DateTime createdAt,
List<BundleUnit> units = const [],
BundlePlan? plan,
}) => {
'format': format,
'version': plan == null ? 2 : 3,
'createdAt': createdAt.toUtc().toIso8601String(),
'units': [for (final u in units) u.toJson()],
'recipes': recipes,
if (plan != null) 'plan': plan.toJson(),
};
/// The week of the Week Plan a bundle carries, if any (version 3).
static BundlePlan? decodePlan(Object? json) {
if (json is! Map<String, dynamic> || json['plan'] == null) return null;
try {
return BundlePlan.fromJson(json['plan'] as Map<String, dynamic>);
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// The cook's own units carried in a bundle (none in version 1 bundles).
static List<BundleUnit> decodeUnits(Object? json) {
if (json is! Map<String, dynamic>) return const [];
try {
return [for (final u in json['units'] as List? ?? const []) BundleUnit.fromJson(u as Map<String, dynamic>)];
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// Throws [BundleFormatException] when [json] isn't a bundle this app can read.
static List<BundleRecipe> decodeManifest(Object? json, {required DateTime now}) {
if (json is! Map<String, dynamic> || json['format'] != format) {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
final v = json['version'];
if (v is! int || v > version) {
throw const BundleFormatException('Dieses Rezeptpaket stammt aus einer neueren CookIt-Version.');
}
try {
return [for (final r in json['recipes'] as List) decodeRecipe(r as Map<String, dynamic>, now: now)];
} on BundleFormatException {
rethrow;
} catch (e) {
throw BundleFormatException('Das Rezeptpaket ist beschädigt ($e).');
}
}
/// One Recipe as [encodeRecipe] wrote it. A Recipe Code leaves out the id
/// (the receiver gives it a new one), so it may be missing here.
static BundleRecipe decodeRecipe(Map<String, dynamic> json, {required DateTime now}) {
final calories = json['calories'];
final recipe = Recipe(
id: json['id'] as String? ?? '',
name: json['name'] as String,
tags: _strings(json['tags']),
servings: (json['servings'] as num?)?.toDouble(),
prepMinutes: (json['prepMinutes'] as num?)?.toInt(),
cookMinutes: (json['cookMinutes'] as num?)?.toInt(),
calories: calories == null ? null : Calories.fromJson(calories as Map<String, dynamic>),
ingredientGroups: [
for (final g in json['ingredientGroups'] as List? ?? const [])
IngredientGroup.fromJson(g as Map<String, dynamic>),
],
stepSections: [
for (final s in json['stepSections'] as List? ?? const []) StepSection.fromJson(s as Map<String, dynamic>),
],
sources: _strings(json['sources']),
notes: json['notes'] as String?,
favourite: json['favourite'] as bool? ?? false,
reviewReasons: _strings(json['reviewReasons']),
createdAt: now,
updatedAt: now,
);
return BundleRecipe(recipe, courseName: json['course'] as String?, photoPaths: _strings(json['photos']));
}
static List<String> _strings(Object? value) => [for (final s in value as List? ?? const []) s as String];
}
/// One of the cook's own units as a bundle carries it.
class BundleUnit {
const BundleUnit({required this.key, required this.label, required this.direct});
final String key;
final String label;
final bool direct;
Map<String, dynamic> toJson() => {'key': key, 'label': label, 'direct': direct};
static BundleUnit fromJson(Map<String, dynamic> json) =>
BundleUnit(key: json['key'] as String, label: json['label'] as String, direct: json['direct'] as bool? ?? true);
}
/// A week of the Week Plan as a bundle carries it: its entries with their
/// day, their Meal by name, and either the id of a Recipe in the bundle or a note.
class BundlePlan {
const BundlePlan({required this.weekStart, required this.meals, required this.entries});
/// "2026-10-19"
final String weekStart;
/// The sender's Meals the entries use, in the sender's order.
final List<String> meals;
final List<BundlePlanEntry> entries;
Map<String, dynamic> toJson() => {
'weekStart': weekStart,
'meals': meals,
'entries': [for (final e in entries) e.toJson()],
};
static BundlePlan fromJson(Map<String, dynamic> json) => BundlePlan(
weekStart: json['weekStart'] as String,
meals: [for (final m in json['meals'] as List) m as String],
entries: [for (final e in json['entries'] as List) BundlePlanEntry.fromJson(e as Map<String, dynamic>)],
);
}
class BundlePlanEntry {
const BundlePlanEntry({required this.day, required this.meal, this.recipeId, this.note});
final String day;
final String meal;
final String? recipeId;
final String? note;
Map<String, dynamic> toJson() => {'day': day, 'meal': meal, 'recipe': recipeId, 'note': note};
static BundlePlanEntry fromJson(Map<String, dynamic> json) => BundlePlanEntry(
day: json['day'] as String,
meal: json['meal'] as String,
recipeId: json['recipe'] as String?,
note: json['note'] as String?,
);
}
/// A Recipe read from a bundle, before its Course and Photos are resolved locally.
class BundleRecipe {
const BundleRecipe(this.recipe, {required this.courseName, required this.photoPaths});
final Recipe recipe;
final String? courseName;
final List<String> photoPaths;
}
class BundleFormatException implements Exception {
const BundleFormatException(this.message);
final String message;
@override
String toString() => message;
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:convert';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/recipe.dart';
import '../recipes/photo_store.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/unit_repository.dart';
import 'bundle_codec.dart';
/// What a Recipe Bundle holds, read but not yet added to the collection.
class BundleContents {
const BundleContents({required this.recipes, required this.units, required this.files, this.plan});
final List<BundleRecipe> recipes;
final List<BundleUnit> units;
/// A week of the Week Plan, when the bundle was shared from there.
final BundlePlan? plan;
/// Every file in the zip by its path, Photos included.
final Map<String, Uint8List> files;
}
class ImportResult {
const ImportResult({required this.added, required this.skipped});
final int added;
final int skipped;
}
/// Reads and writes Recipe Bundles: "Rezepte importieren" and "Sammlung exportieren".
class BundleService {
BundleService(this._recipes, this._courses, this._units, this._photos, {DateTime Function()? clock})
: _clock = clock ?? DateTime.now;
final RecipeRepository _recipes;
final CourseRepository _courses;
final UnitRepository _units;
final PhotoStore _photos;
final DateTime Function() _clock;
/// The bundle's Recipes, the cook's own units it carries and its files.
/// Throws [BundleFormatException].
Future<BundleContents> read(Uint8List zipBytes) async {
final files = await Isolate.run(() => _unzip(zipBytes));
final manifest = files[BundleCodec.manifestName];
if (manifest == null) {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
final Object? json;
try {
json = jsonDecode(utf8.decode(manifest));
} on FormatException {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
return BundleContents(
recipes: BundleCodec.decodeManifest(json, now: _clock()),
units: BundleCodec.decodeUnits(json),
files: files,
plan: BundleCodec.decodePlan(json),
);
}
/// Adds every Recipe from the bundle that isn't in the collection yet.
Future<ImportResult> importBundle(Uint8List zipBytes) async {
final contents = await read(zipBytes);
final entries = contents.recipes;
final files = contents.files;
final known = {for (final u in await _units.allUnits()) u.key};
for (final unit in contents.units) {
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
}
final existing = await _recipes.existingIds();
final fresh = entries.where((e) => !existing.contains(e.recipe.id)).toList();
final courseIds = <String, String>{};
final recipes = [
for (final entry in fresh)
entry.recipe.copyWith(
courseId: () => null,
photos: [
for (final path in entry.photoPaths)
if (files[path] case final bytes?) await _photos.saveBytes(bytes),
],
),
];
for (final (i, entry) in fresh.indexed) {
final name = entry.courseName;
if (name == null) continue;
final id = courseIds[name] ??= (await _courses.findOrCreate(name)).id;
recipes[i] = recipes[i].copyWith(courseId: () => id);
}
await _recipes.insertAll(recipes);
return ImportResult(added: fresh.length, skipped: entries.length - fresh.length);
}
/// The whole collection, Photos included, as a zip.
Future<Uint8List> exportBundle() async {
final ownUnits = [
for (final u in await _units.allUnits())
if (!u.builtIn) BundleUnit(key: u.key, label: u.label ?? u.key, direct: u.direct),
];
return _export(await _recipes.allRecipes(), units: ownUnits);
}
/// One Recipe for another cook ("Als Datei teilen"): with its Photos and
/// the own units it uses, without what is personal (Favourite, Review Flag,
/// and the Notes unless [includeNotes]).
Future<Uint8List> exportRecipe(Recipe recipe, {bool includeNotes = true}) async {
final shared = recipe.copyWith(favourite: false, reviewReasons: const [], notes: includeNotes ? null : () => null);
return _export([shared], units: await ownUnitsOf([recipe]));
}
/// A week of the Week Plan for another cook: its entries and the Recipes
/// they plan, with Photos, without what is personal.
Future<Uint8List> exportWeek(List<Recipe> recipes, BundlePlan plan) async {
final shared = [for (final r in recipes) r.copyWith(favourite: false, reviewReasons: const [])];
return _export(shared, units: await ownUnitsOf(recipes), plan: plan);
}
/// The cook's own units [recipes] use, as a bundle or Recipe Code carries them.
Future<List<BundleUnit>> ownUnitsOf(Iterable<Recipe> recipes) async {
final used = {
for (final recipe in recipes)
for (final line in recipe.ingredientLines) line.unitKey,
};
return [
for (final u in await _units.allUnits())
if (!u.builtIn && used.contains(u.key)) BundleUnit(key: u.key, label: u.label ?? u.key, direct: u.direct),
];
}
Future<Uint8List> _export(List<Recipe> recipes, {required List<BundleUnit> units, BundlePlan? plan}) async {
final courses = {for (final c in await _courses.allCourses()) c.id: c.name};
final files = <String, Uint8List>{};
final entries = <Map<String, dynamic>>[];
for (final recipe in recipes) {
final paths = <String>[];
for (final (i, photo) in recipe.photos.indexed) {
final path = 'photos/${recipe.id}-${i + 1}.jpg';
try {
files[path] = await _photos.read(photo);
paths.add(path);
} on Exception {
// A missing photo file shouldn't block the backup of everything else.
}
}
entries.add(BundleCodec.encodeRecipe(recipe, courseName: courses[recipe.courseId], photoPaths: paths));
}
final manifest = BundleCodec.encodeManifest(entries, createdAt: _clock(), units: units, plan: plan);
files[BundleCodec.manifestName] = utf8.encode(const JsonEncoder.withIndent(' ').convert(manifest));
return Isolate.run(() => _zip(files));
}
static Map<String, Uint8List> _unzip(Uint8List bytes) {
try {
final archive = ZipDecoder().decodeBytes(bytes);
return {
for (final file in archive.files)
if (file.isFile) file.name: file.content,
};
} on Exception {
throw const BundleFormatException('Die Datei ist kein CookIt-Rezeptpaket.');
}
}
static Uint8List _zip(Map<String, Uint8List> files) {
final archive = Archive();
for (final MapEntry(key: name, value: bytes) in files.entries) {
final file = ArchiveFile.bytes(name, bytes);
// Photos are JPEG already; deflating them only costs time.
if (name.endsWith('.jpg')) file.compression = CompressionType.none;
archive.add(file);
}
return ZipEncoder().encodeBytes(archive);
}
}
+415
View File
@@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../design/cookit_colors.dart';
import '../design/herb_pattern.dart';
import '../localization/app_localizations.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/views/widgets/ingredient_checklist.dart';
import '../recipes/views/widgets/servings_stepper.dart';
import 'cooking_session.dart';
/// Cooking Mode: the ingredients to get ready, then one Step per page in large
/// type, with the screen kept on.
class CookingModeView extends StatefulWidget {
const CookingModeView({super.key, required this.recipeId});
static const routeName = '/recipe/cook';
final String recipeId;
@override
State<CookingModeView> createState() => _CookingModeViewState();
}
class _CookingModeViewState extends State<CookingModeView> {
final _pages = PageController();
final _checked = <String>{};
CookingSession? _session;
int _page = 0;
@override
void initState() {
super.initState();
_keepScreenOn(true);
context.read<RecipeRepository>().getRecipe(widget.recipeId).then((recipe) {
if (mounted && recipe != null) setState(() => _session = CookingSession(recipe));
});
}
@override
void dispose() {
_keepScreenOn(false);
_pages.dispose();
super.dispose();
}
Future<void> _keepScreenOn(bool on) async {
try {
await WakelockPlus.toggle(enable: on);
} on PlatformException {
// Not fatal: cooking works with the normal screen timeout too.
} on MissingPluginException {
// Widget tests and platforms without the plugin.
}
}
/// Start page, one page per Step, end page.
int get _pageCount => _session!.steps.length + 2;
void _goTo(int page) =>
_pages.animateToPage(page, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic);
void _toggle(String key) => setState(() => _checked.contains(key) ? _checked.remove(key) : _checked.add(key));
void _showIngredients() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.92,
builder: (context, scroll) => ListView(
controller: scroll,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
children: [
Text(AppLocalizations.of(context).ingredients, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
IngredientChecklist(
groups: _session!.ingredientGroups,
checked: _checked,
large: true,
onToggle: (key) {
_toggle(key);
setSheetState(() {});
},
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final session = _session;
return Scaffold(
backgroundColor: c.background,
body: Stack(
children: [
const Positioned.fill(child: Opacity(opacity: 0.6, child: HerbPattern())),
SafeArea(
child: session == null
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_TopBar(
progress: _page / (_pageCount - 1),
showIngredients: _page > 0 && _page < _pageCount - 1,
onClose: () => Navigator.maybePop(context),
onIngredients: _showIngredients,
),
Expanded(
child: PageView(
controller: _pages,
onPageChanged: (page) => setState(() => _page = page),
children: [
_StartPage(
session: session,
checked: _checked,
onToggle: _toggle,
onServingsChanged: () => setState(() {}),
),
for (final step in session.steps) _StepPage(step: step, total: session.steps.length),
const _DonePage(),
],
),
),
if (_page < _pageCount - 1)
_BottomBar(
page: _page,
lastStep: _page == _pageCount - 2,
onPrevious: () => _goTo(_page - 1),
onNext: () => _goTo(_page + 1),
),
],
),
),
],
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({
required this.progress,
required this.showIngredients,
required this.onClose,
required this.onIngredients,
});
final double progress;
final bool showIngredients;
final VoidCallback onClose;
final VoidCallback onIngredients;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final round = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(48, 48),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
IconButton(tooltip: l10n.cookClose, onPressed: onClose, style: round, icon: const Icon(Icons.close_rounded)),
const SizedBox(width: 12),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(value: progress, minHeight: 10, color: c.brand, backgroundColor: c.navBar),
),
),
const SizedBox(width: 12),
AnimatedOpacity(
opacity: showIngredients ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: IconButton(
tooltip: l10n.ingredients,
onPressed: showIngredients ? onIngredients : null,
style: round,
icon: const Icon(Icons.shopping_basket_outlined),
),
),
],
),
);
}
}
class _StartPage extends StatelessWidget {
const _StartPage({
required this.session,
required this.checked,
required this.onToggle,
required this.onServingsChanged,
});
final CookingSession session;
final Set<String> checked;
final ValueChanged<String> onToggle;
final VoidCallback onServingsChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
Text(
session.recipe.name.toUpperCase(),
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: c.green),
),
const SizedBox(height: 4),
Text(
l10n.cookReadyTitle,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 30, height: 1.1, color: c.ink),
),
const SizedBox(height: 6),
Text(l10n.cookReadyBody, style: TextStyle(fontSize: 16, color: c.inkMuted)),
if (session.canScale) ...[
const SizedBox(height: 16),
ServingsStepper(
servings: session.servings!,
onFewer: session.servings! > 1
? () {
session.fewerServings();
onServingsChanged();
}
: null,
onMore: () {
session.moreServings();
onServingsChanged();
},
),
],
const SizedBox(height: 12),
IngredientChecklist(groups: session.ingredientGroups, checked: checked, onToggle: onToggle, large: true),
if (session.steps.isEmpty) ...[
const SizedBox(height: 16),
Text(l10n.cookNoSteps, style: TextStyle(fontSize: 16, color: c.inkMuted)),
],
],
);
}
}
class _StepPage extends StatelessWidget {
const _StepPage({required this.step, required this.total});
final CookingStep step;
final int total;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
children: [
Row(
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(color: c.lime, shape: BoxShape.circle),
child: Text(
'${step.number}',
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 20, color: c.onBrand),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.cookStepOf(step.number, total),
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 15, color: c.inkMuted),
),
),
if (step.section != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(12)),
child: Text(
step.section!,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 15, color: c.green),
),
),
],
),
const SizedBox(height: 24),
Text(
step.text,
style: TextStyle(fontSize: 24, height: 1.45, fontWeight: FontWeight.w600, color: c.ink),
),
],
);
}
}
class _DonePage extends StatelessWidget {
const _DonePage();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(color: c.lime, shape: BoxShape.circle),
child: Icon(Icons.restaurant_rounded, size: 56, color: c.onBrand),
),
const SizedBox(height: 24),
Text(
l10n.cookDoneTitle,
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 32, color: c.ink),
),
const SizedBox(height: 8),
Text(
l10n.cookDoneBody,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 17, color: c.inkMuted),
),
const SizedBox(height: 32),
SizedBox(
height: 56,
child: FilledButton(
onPressed: () => Navigator.maybePop(context),
style: FilledButton.styleFrom(
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 32),
textStyle: const TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 17),
),
child: Text(l10n.cookBackToRecipe),
),
),
],
),
);
}
}
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.page, required this.lastStep, required this.onPrevious, required this.onNext});
final int page;
final bool lastStep;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
const textStyle = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 18);
final label = page == 0 ? l10n.cookLetsGo : (lastStep ? l10n.cookFinish : l10n.cookNext);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
if (page > 0) ...[
IconButton(
tooltip: l10n.cookPrevious,
onPressed: onPrevious,
style: IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(64, 64),
side: BorderSide(color: c.chipBorder, width: 2),
),
icon: const Icon(Icons.arrow_back_rounded, size: 28),
),
const SizedBox(width: 12),
],
Expanded(
child: SizedBox(
height: 64,
child: FilledButton(
onPressed: onNext,
style: FilledButton.styleFrom(shape: const StadiumBorder(), textStyle: textStyle),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label),
const SizedBox(width: 8),
Icon(lastStep ? Icons.check_rounded : Icons.arrow_forward_rounded),
],
),
),
),
),
],
),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import '../recipes/models/recipe.dart';
/// One Step as Cooking Mode shows it: its text, its Step Section and its number.
class CookingStep {
const CookingStep({required this.number, required this.text, this.section});
final int number;
final String text;
final String? section;
}
/// A Recipe being cooked: its Steps one after another and its ingredients
/// for the number of servings the cook chose.
class CookingSession {
CookingSession(this.recipe) : servings = recipe.servings;
final Recipe recipe;
/// Servings the cook is making; null when the Recipe has no Servings to scale from.
double? servings;
late final List<CookingStep> steps = () {
var number = 0;
return [
for (final section in recipe.stepSections)
for (final step in section.steps) CookingStep(number: ++number, text: step, section: section.title),
];
}();
bool get canScale => recipe.servings != null && recipe.servings! > 0;
double get factor => canScale && servings != null ? servings! / recipe.servings! : 1;
List<IngredientGroup> get ingredientGroups => [
for (final g in recipe.ingredientGroups)
IngredientGroup(name: g.name, lines: [for (final l in g.lines) l.scaled(factor)]),
];
void moreServings() {
if (servings == null) return;
servings = servings!.floorToDouble() + 1;
}
void fewerServings() {
if (servings == null) return;
final next = servings!.ceilToDouble() - 1;
servings = next < 1 ? 1 : next;
}
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../recipes/models/course.dart';
import '../recipes/models/recipe.dart';
import '../planning/models/meal.dart';
import '../recipes/models/unit.dart';
part 'database.g.dart';
/// Courses the cook can assign to Recipes, in the order they edited.
@DataClassName('CourseRow')
class Courses extends Table {
TextColumn get id => text()();
TextColumn get name => text().unique()();
TextColumn get emoji => text().nullable()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {id};
}
/// Units the cook can pick for an Ingredient Line: the built-in ones (labels come
/// from the localization files) and the cook's own (with a [label]). See docs/adr/0003.
@DataClassName('UnitRow')
class Units extends Table {
TextColumn get key => text()();
TextColumn get label => text().nullable()();
BoolColumn get builtIn => boolean()();
/// Offered directly in the unit picker rather than under "Weitere Einheiten".
BoolColumn get direct => boolean()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {key};
}
/// One row per Recipe. The parts that are only ever read and written together
/// with their Recipe are stored as JSON (see docs/adr/0002).
@DataClassName('RecipeRow')
class Recipes extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get courseId => text().nullable().references(Courses, #id, onDelete: KeyAction.setNull)();
TextColumn get tags => text().map(const StringListConverter())();
RealColumn get servings => real().nullable()();
IntColumn get prepMinutes => integer().nullable()();
IntColumn get cookMinutes => integer().nullable()();
IntColumn get kcal => integer().nullable()();
TextColumn get caloriesBasis => text().nullable()();
TextColumn get ingredientGroups => text().map(const IngredientGroupsConverter())();
TextColumn get stepSections => text().map(const StepSectionsConverter())();
TextColumn get photos => text().map(const StringListConverter())();
TextColumn get sources => text().map(const StringListConverter())();
TextColumn get notes => text().nullable()();
BoolColumn get favourite => boolean().withDefault(const Constant(false))();
TextColumn get reviewReasons => text().map(const StringListConverter())();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// The Meals each day of the Week Plan is divided into, in the cook's order. A
/// deleted Meal that past Plan Entries still use stays as [retired], so the
/// history keeps its name.
@DataClassName('MealRow')
class Meals extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
IntColumn get position => integer()();
BoolColumn get retired => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
/// One Plan Entry: a Recipe or a note for a Meal on a day ("yyyy-MM-dd").
@DataClassName('PlanEntryRow')
class PlanEntries extends Table {
TextColumn get id => text()();
TextColumn get day => text()();
TextColumn get mealId => text().references(Meals, #id)();
TextColumn get recipeId => text().nullable().references(Recipes, #id, onDelete: KeyAction.setNull)();
TextColumn get note => text().nullable()();
IntColumn get position => integer()();
@override
Set<Column> get primaryKey => {id};
}
@DriftDatabase(tables: [Courses, Recipes, Units, Meals, PlanEntries])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.executor);
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await batch((b) {
for (final (i, (name, emoji)) in defaultCourses.indexed) {
b.insert(
courses,
CoursesCompanion.insert(id: const Uuid().v4(), name: name, emoji: Value(emoji), position: i),
);
}
});
await _seedUnits();
await _seedMeals();
},
onUpgrade: (m, from, to) async {
if (from < 2) {
await m.createTable(units);
await _seedUnits();
}
if (from < 3) {
await m.createTable(meals);
await m.createTable(planEntries);
await _seedMeals();
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
extension on AppDatabase {
Future<void> _seedUnits() => batch((b) {
for (final (i, unit) in Unit.values.indexed) {
b.insert(units, UnitsCompanion.insert(key: unit.key, builtIn: true, direct: unit.common, position: i));
}
});
Future<void> _seedMeals() => batch((b) {
for (final (i, name) in defaultMeals.indexed) {
b.insert(meals, MealsCompanion.insert(id: const Uuid().v4(), name: name, position: i));
}
});
}
class StringListConverter extends TypeConverter<List<String>, String> {
const StringListConverter();
@override
List<String> fromSql(String fromDb) => [for (final s in jsonDecode(fromDb) as List) s as String];
@override
String toSql(List<String> value) => jsonEncode(value);
}
class IngredientGroupsConverter extends TypeConverter<List<IngredientGroup>, String> {
const IngredientGroupsConverter();
@override
List<IngredientGroup> fromSql(String fromDb) => [
for (final g in jsonDecode(fromDb) as List) IngredientGroup.fromJson(g as Map<String, dynamic>),
];
@override
String toSql(List<IngredientGroup> value) => jsonEncode([for (final g in value) g.toJson()]);
}
class StepSectionsConverter extends TypeConverter<List<StepSection>, String> {
const StepSectionsConverter();
@override
List<StepSection> fromSql(String fromDb) => [
for (final s in jsonDecode(fromDb) as List) StepSection.fromJson(s as Map<String, dynamic>),
];
@override
String toSql(List<StepSection> value) => jsonEncode([for (final s in value) s.toJson()]);
}
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'cookit_colors.dart';
/// The "Gewürzregal" look: mint ground, lime and sage accents, Nunito for
/// headings and Nunito Sans for reading. Colours live in [CookItColors].
class AppTheme {
static const seed = Color(0xFF65A86C);
static const headingFont = 'Nunito';
static const bodyFont = 'NunitoSans';
static const radius = 22.0;
static ThemeData light() => _build(Brightness.light, CookItColors.light);
static ThemeData dark() => _build(Brightness.dark, CookItColors.dark);
static ThemeData _build(Brightness brightness, CookItColors c) {
final scheme =
ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
).copyWith(
primary: c.green,
onPrimary: brightness == Brightness.light ? Colors.white : c.onBrand,
secondaryContainer: c.sage,
onSecondaryContainer: c.ink,
surface: c.background,
onSurface: c.ink,
onSurfaceVariant: c.inkMuted,
surfaceContainerLow: c.surface,
surfaceContainerHighest: brightness == Brightness.light ? const Color(0xFFD6EAC6) : const Color(0xFF35573B),
);
final base = ThemeData(colorScheme: scheme, useMaterial3: true, fontFamily: bodyFont);
final text = base.textTheme.apply(bodyColor: c.ink, displayColor: c.ink);
TextStyle? heading(TextStyle? s, FontWeight w) => s?.copyWith(fontFamily: headingFont, fontWeight: w);
return base.copyWith(
extensions: [c],
scaffoldBackgroundColor: c.background,
textTheme: text.copyWith(
displaySmall: heading(text.displaySmall, FontWeight.w900),
headlineLarge: heading(text.headlineLarge, FontWeight.w900),
headlineMedium: heading(text.headlineMedium, FontWeight.w900),
headlineSmall: heading(text.headlineSmall, FontWeight.w800),
titleLarge: heading(text.titleLarge, FontWeight.w800),
titleMedium: heading(text.titleMedium, FontWeight.w800),
titleSmall: heading(text.titleSmall, FontWeight.w800),
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w800),
),
appBarTheme: AppBarTheme(
backgroundColor: c.background,
surfaceTintColor: Colors.transparent,
foregroundColor: c.ink,
centerTitle: false,
titleTextStyle: heading(text.titleLarge, FontWeight.w900)?.copyWith(color: c.ink),
),
cardTheme: CardThemeData(
elevation: 0,
color: c.surface,
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
side: c.cardBorder == null ? BorderSide.none : BorderSide(color: c.cardBorder!),
),
),
chipTheme: ChipThemeData(
shape: const StadiumBorder(),
side: BorderSide(color: c.chipBorder, width: 2),
backgroundColor: c.surface,
labelStyle: TextStyle(fontWeight: FontWeight.w800, color: c.ink),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: c.surface,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: c.brand, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: c.brand,
foregroundColor: c.onBrand,
textStyle: const TextStyle(fontFamily: bodyFont, fontWeight: FontWeight.w800),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: c.green,
side: BorderSide(color: c.chipBorder, width: 2),
textStyle: const TextStyle(fontFamily: bodyFont, fontWeight: FontWeight.w800),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? c.sage : c.surface,
),
foregroundColor: WidgetStatePropertyAll(c.ink),
side: WidgetStatePropertyAll(BorderSide(color: c.chipBorder, width: 2)),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: c.brand,
foregroundColor: c.onBrand,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)),
),
dialogTheme: DialogThemeData(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius + 6)),
),
popupMenuTheme: PopupMenuThemeData(color: c.surface),
bottomSheetTheme: BottomSheetThemeData(backgroundColor: c.surface),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
// Above the floating navigation bar and the "Kochmodus starten" button.
insetPadding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
/// The "Gewürzregal" palette: soft mint ground, lime and sage highlights, honey
/// for things to check. Read it with `CookItColors.of(context)`.
@immutable
class CookItColors extends ThemeExtension<CookItColors> {
const CookItColors({
required this.background,
required this.surface,
this.cardBorder,
required this.ink,
required this.inkMuted,
required this.green,
required this.brand,
required this.onBrand,
required this.lime,
required this.sage,
required this.chipBorder,
required this.navBar,
required this.honey,
required this.onHoney,
required this.favourite,
required this.tiles,
required this.patternOpacity,
required this.planCell,
required this.weekendTint,
required this.noteFill,
required this.noteBorder,
required this.noteInk,
});
/// Page background behind the herb pattern.
final Color background;
/// Cards, search field, chips.
final Color surface;
/// Hairline around Recipe cards where the surface alone does not stand out enough.
final Color? cardBorder;
final Color ink;
final Color inkMuted;
/// Readable green for text, icons and tags on light surfaces.
final Color green;
/// CookIt green (#65A86C) for the add button and selected states.
final Color brand;
final Color onBrand;
final Color lime;
/// Soft sage for the suggestion card and the active navigation pill.
final Color sage;
final Color chipBorder;
final Color navBar;
/// Review Flag colours.
final Color honey;
final Color onHoney;
final Color favourite;
/// Soft lime, sage and peach for small decorative tiles (e.g. a Recipe's facts).
final List<Color> tiles;
final double patternOpacity;
/// Week Plan grid: a filled cell, the weekend rows, and notes (warm, so they
/// stand apart from Recipes).
final Color planCell;
final Color weekendTint;
final Color noteFill;
final Color noteBorder;
final Color noteInk;
static const light = CookItColors(
background: Color(0xFFE3F1D5),
surface: Color(0xFFFFFFFF),
ink: Color(0xFF17331A),
inkMuted: Color(0xFF3C5A35),
green: Color(0xFF2E6B37),
brand: Color(0xFF65A86C),
onBrand: Color(0xFF0E2A13),
lime: Color(0xFFD4EC6B),
sage: Color(0xFFA8D896),
chipBorder: Color(0xFFA8D896),
navBar: Color(0xFFCFE8BE),
honey: Color(0xFFFFE29A),
onHoney: Color(0xFF4F3A00),
favourite: Color(0xFFB3261E),
tiles: [Color(0xFFEAF5C4), Color(0xFFD9EFCC), Color(0xFFFBE4D6)],
patternOpacity: 1,
planCell: Color(0xFFF3F9EE),
weekendTint: Color(0xFFDDEBCF),
noteFill: Color(0xFFFFF4D6),
noteBorder: Color(0xFFF2DC9A),
noteInk: Color(0xFF5A4A1A),
);
// A deep forest ground with clearly lifted cards, so they stand apart.
static const dark = CookItColors(
background: Color(0xFF14231A),
surface: Color(0xFF2D4C35),
cardBorder: Color(0xFF41694A),
ink: Color(0xFFEAF5E1),
inkMuted: Color(0xFFC3DBB8),
green: Color(0xFFB4E0A3),
brand: Color(0xFF7BBE82),
onBrand: Color(0xFF0E2A13),
lime: Color(0xFFD4EC6B),
sage: Color(0xFF4A7D50),
chipBorder: Color(0xFF4A7D50),
navBar: Color(0xFF35573B),
honey: Color(0xFF6B5310),
onHoney: Color(0xFFFFE6A8),
favourite: Color(0xFFFFB4AB),
tiles: [Color(0xFF41522A), Color(0xFF335639), Color(0xFF5A4433)],
patternOpacity: 0.35,
planCell: Color(0xFF365A3F),
weekendTint: Color(0xFF3A5536),
noteFill: Color(0xFF4A4122),
noteBorder: Color(0xFF6E5E2C),
noteInk: Color(0xFFF4E3B0),
);
static CookItColors of(BuildContext context) => Theme.of(context).extension<CookItColors>() ?? light;
@override
CookItColors copyWith() => this;
@override
CookItColors lerp(CookItColors? other, double t) => t < 0.5 || other == null ? this : other;
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'cookit_colors.dart';
class NavDestination {
const NavDestination({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
}
/// The floating, soft-green bar at the bottom: the selected destination is a
/// white pill with its label, the others are icons.
class CookItNavBar extends StatelessWidget {
const CookItNavBar({super.key, required this.destinations, required this.selected});
static const height = 68.0;
final List<NavDestination> destinations;
final int selected;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: c.navBar,
borderRadius: BorderRadius.circular(height / 2),
boxShadow: [BoxShadow(color: c.green.withValues(alpha: 0.16), blurRadius: 20, offset: const Offset(0, 6))],
),
child: Row(
children: [
for (final (i, d) in destinations.indexed)
Expanded(
// The selected pill carries a label, so it gets more of the width.
flex: i == selected ? 2 : 1,
child: Center(
child: i == selected
? Semantics(
selected: true,
button: true,
child: Material(
color: c.surface,
shape: const StadiumBorder(),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: d.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(d.icon, size: 20, color: c.green),
const SizedBox(width: 8),
Flexible(
child: Text(
d.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: c.green, fontWeight: FontWeight.w900, fontSize: 14),
),
),
],
),
),
),
),
)
: IconButton(
tooltip: d.label,
onPressed: d.onTap,
icon: Icon(d.icon, color: c.green),
),
),
),
],
),
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'cookit_colors.dart';
// The "Gewürzregal" decorations, drawn for a 390 px wide phone and scaled to the screen width.
const _designWidth = 390.0;
const _leaf =
'<path d="M0 60C0 24 30 0 72 0c0 42-30 66-72 60z"/>'
'<path d="M6 54 66 6" stroke="#1F4D27" stroke-opacity=".15" stroke-width="3" stroke-linecap="round" fill="none"/>';
const _mint = '''
<path d="M24 47V12" fill="none" stroke="#9BD18A" stroke-width="2.4" stroke-linecap="round"/>
<ellipse cx="24" cy="9" rx="5" ry="8"/>
<ellipse cx="15" cy="21" rx="5" ry="9" transform="rotate(-55 15 21)"/>
<ellipse cx="33" cy="21" rx="5" ry="9" transform="rotate(55 33 21)"/>
<ellipse cx="14" cy="35" rx="5.5" ry="10" transform="rotate(-62 14 35)"/>
<ellipse cx="34" cy="35" rx="5.5" ry="10" transform="rotate(62 34 35)"/>
<path d="M24 4v10M10 17l9 6M38 17l-9 6M8 30l11 7M40 30l-11 7" fill="none" stroke="#FFFFFF" stroke-opacity=".5" stroke-width="1.4" stroke-linecap="round"/>''';
const _parsley = '''
<path d="M24 47V26M24 26 14 15M24 26 34 15M24 26V11" fill="none" stroke="#65A86C" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="5"/><circle cx="7" cy="17" r="4"/><circle cx="15" cy="7" r="4"/>
<circle cx="24" cy="8" r="5"/><circle cx="20" cy="4" r="3.5"/><circle cx="28" cy="4" r="3.5"/>
<circle cx="36" cy="12" r="5"/><circle cx="41" cy="17" r="4"/><circle cx="33" cy="7" r="4"/>''';
const _chili = '''
<path d="M8 38c12 3 25-5 29-22" stroke-width="6"/>
<path d="M37 16c1-5 4-8 8-8" stroke-width="2.4"/>''';
const _pepper = '<circle cx="16" cy="26" r="5.5"/><circle cx="29" cy="22" r="5.5"/><circle cx="23" cy="35" r="5.5"/>';
/// The cluster around the home screen's title: big lime leaf, sage leaf and mint.
const _headerHeight = 220.0;
const _headerSvg =
'''
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="220" viewBox="0 0 390 220">
<g transform="rotate(20 325 28) translate(270 -18) scale(1.53)" fill="#D4EC6B">$_leaf</g>
<g transform="rotate(110 358 119) translate(330 96) scale(0.778)" fill="#A8D896">$_leaf</g>
<g transform="rotate(-18 249 99) translate(222 72) scale(1.125)" fill="#9BD18A">$_mint</g>
</svg>''';
/// One tile of edge decorations; tiles repeat down the page.
const _edgeHeight = 844.0;
const _edgeSvg =
'''
<svg xmlns="http://www.w3.org/2000/svg" width="390" height="844" viewBox="0 0 390 844">
<g transform="rotate(25 13 329) translate(-12 304) scale(1.042)" fill="#65A86C" opacity=".55">$_parsley</g>
<g transform="rotate(140 377 409) translate(352 388) scale(0.694)" fill="#D4EC6B">$_leaf</g>
<g transform="translate(-12 560) scale(1.042)" fill="none" stroke="#A8D896" stroke-linecap="round">$_chili</g>
<g transform="translate(356 600) scale(0.625)" fill="#65A86C" opacity=".45">$_pepper</g>
<g transform="rotate(-150 372 790) translate(350 774) scale(0.6)" fill="#A8D896">$_leaf</g>
<g transform="rotate(-30 10 110) translate(-14 92) scale(0.55)" fill="#A8D896">$_leaf</g>
<g transform="translate(360 196) scale(0.5)" fill="#65A86C" opacity=".4">$_pepper</g>
</svg>''';
Widget _faded(BuildContext context, Widget child) => ExcludeSemantics(
child: Opacity(opacity: CookItColors.of(context).patternOpacity, child: child),
);
/// Decorations that belong to a page's header and scroll with it. Place it in a
/// Stack behind the header content, anchored at the top.
class HeaderDecor extends StatelessWidget {
const HeaderDecor({super.key});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return _faded(
context,
SizedBox(
width: width,
height: width / _designWidth * _headerHeight,
child: SvgPicture.string(_headerSvg, fit: BoxFit.fill),
),
);
}
}
/// The scattered edge decorations, repeating down the page and moving at
/// [speed] times the scroll speed of [controller], for a sense of depth.
/// Without a controller they stand still.
class ParallaxEdgePattern extends StatelessWidget {
const ParallaxEdgePattern({super.key, this.controller, this.speed = 1 / 3});
final ScrollController? controller;
final double speed;
@override
Widget build(BuildContext context) {
return _faded(
context,
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final tile = width / _designWidth * _edgeHeight;
final count = (constraints.maxHeight / tile).ceil() + 1;
final picture = SvgPicture.string(_edgeSvg, fit: BoxFit.fill);
Widget tiles(double shift) => Stack(
children: [
for (var i = 0; i < count; i++)
Positioned(left: 0, top: i * tile - shift, width: width, height: tile, child: picture),
],
);
final scroll = controller;
if (scroll == null) return ClipRect(child: tiles(0));
return ClipRect(
child: AnimatedBuilder(
animation: scroll,
builder: (context, _) {
// Negative offsets too: the Week Plan scrolls up into past weeks.
final offset = scroll.hasClients ? scroll.offset : 0.0;
return tiles((offset * speed) % tile);
},
),
);
},
),
);
}
}
/// Header and edge decorations together, standing still (for calm screens like Cooking Mode).
class HerbPattern extends StatelessWidget {
const HerbPattern({super.key});
@override
Widget build(BuildContext context) {
return const Stack(
children: [
Positioned.fill(child: ParallaxEdgePattern()),
Positioned(top: 0, left: 0, right: 0, child: HeaderDecor()),
],
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
/// The herb pattern's leaf shape, for decorating cards and tiles.
class LeafPainter extends CustomPainter {
const LeafPainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final sx = size.width / 72;
final sy = size.height / 60;
final leaf = Path()
..moveTo(0, 60 * sy)
..cubicTo(0, 24 * sy, 30 * sx, 0, 72 * sx, 0)
..cubicTo(72 * sx, 42 * sy, 42 * sx, 66 * sy, 0, 60 * sy)
..close();
canvas.drawPath(leaf, Paint()..color = color);
canvas.drawLine(
Offset(6 * sx, 54 * sy),
Offset(66 * sx, 6 * sy),
Paint()
..color = const Color(0x261F4D27)
..strokeWidth = 3 * sx
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(LeafPainter old) => old.color != color;
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../design/cookit_nav_bar.dart';
import '../localization/app_localizations.dart';
import '../planning/views/week_plan_view.dart';
import '../recipes/views/recipe_list_view.dart';
/// The two main tabs, Rezepte and Wochenplan, under the floating navigation bar.
class HomeView extends StatefulWidget {
const HomeView({super.key});
static const routeName = '/';
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
var _tab = 0;
/// An entry of the Week Plan is being dragged: the plan shows "remove" where the bar was.
var _dragging = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottomInset = MediaQuery.paddingOf(context).bottom;
return PopScope(
// Back on the Wochenplan returns to the Recipes first.
canPop: _tab == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(() => _tab = 0);
},
child: Scaffold(
body: Stack(
children: [
IndexedStack(
index: _tab,
children: [
const RecipeListView(),
WeekPlanView(onDraggingChanged: (dragging) => setState(() => _dragging = dragging)),
],
),
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
child: IgnorePointer(
ignoring: _dragging,
child: AnimatedOpacity(
opacity: _dragging ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: CookItNavBar(
selected: _tab,
destinations: [
NavDestination(
icon: Icons.menu_book_rounded,
label: l10n.navRecipes,
onTap: () => setState(() => _tab = 0),
),
NavDestination(
icon: Icons.calendar_month_rounded,
label: l10n.navWeekPlan,
onTap: () => setState(() => _tab = 1),
),
],
),
),
),
),
],
),
),
);
}
}
+349
View File
@@ -0,0 +1,349 @@
{
"@@locale": "de",
"appTitle": "CookIt",
"searchHint": "Rezept, Zutat oder Stichwort",
"homeTitle": "Was kochen wir heute?",
"greetingMorning": "Guten Morgen",
"greetingDay": "Guten Tag",
"greetingEvening": "Guten Abend",
"greetingLunch": "Mahlzeit!",
"greetingLate": "Schönen Abend",
"suggestionLabel": "Idee für heute",
"anotherIdea": "Andere Idee",
"decisionButton": "Hilf mir entscheiden",
"decisionCourseQuestion": "Worauf hast du Lust?",
"decisionCourseHint": "Mehrere möglich – nichts gewählt heißt egal.",
"decisionTimeQuestion": "Wie viel Zeit hast du?",
"decisionUpTo30": "bis 30 Min.",
"decisionUpTo60": "bis 60 Min.",
"decisionAnyTime": "Egal",
"decisionNext": "Weiter",
"decisionShow": "Vorschlag zeigen",
"decisionMatches": "{count, plural, =1{1 Rezept passt} other{{count} Rezepte passen}}",
"@decisionMatches": { "placeholders": { "count": { "type": "int" } } },
"decisionAnother": "Noch einer",
"decisionOpen": "Rezept öffnen",
"decisionStartOver": "Neu wählen",
"decisionNoneTitle": "Dazu passt leider nichts.",
"decisionWithoutTime": "Ohne Zeitlimit: {count}",
"@decisionWithoutTime": { "placeholders": { "count": { "type": "int" } } },
"decisionAllCourses": "Alle Gänge: {count}",
"@decisionAllCourses": { "placeholders": { "count": { "type": "int" } } },
"reviewBanner": "{count, plural, =1{1 Rezept wartet auf deinen Blick} other{{count} Rezepte warten auf deinen Blick}}",
"@reviewBanner": { "placeholders": { "count": { "type": "int" } } },
"reviewBannerActive": "Nur Rezepte zum Prüfen – alle zeigen",
"filterAll": "Alle",
"navRecipes": "Rezepte",
"navWeekPlan": "Wochenplan",
"durationMinutes": "{minutes} Min.",
"@durationMinutes": { "placeholders": { "minutes": { "type": "int" } } },
"durationHours": "{hours} Std.",
"@durationHours": { "placeholders": { "hours": { "type": "int" } } },
"factTotal": "Gesamt",
"factServings": "Portionen",
"factCalories": "Kalorien",
"kcalValue": "{kcal} kcal",
"@kcalValue": { "placeholders": { "kcal": { "type": "int" } } },
"tabIngredients": "Zutaten · {count}",
"@tabIngredients": { "placeholders": { "count": { "type": "int" } } },
"tabSteps": "Zubereitung · {count}",
"@tabSteps": { "placeholders": { "count": { "type": "int" } } },
"back": "Zurück",
"cookStart": "Kochmodus starten",
"cookClose": "Kochmodus beenden",
"cookReadyTitle": "Bereit zum Kochen?",
"cookReadyBody": "Leg dir alles bereit – abhaken hilft.",
"cookLetsGo": "Los geht’s",
"cookStepOf": "Schritt {number} von {total}",
"@cookStepOf": { "placeholders": { "number": { "type": "int" }, "total": { "type": "int" } } },
"cookNext": "Weiter",
"cookPrevious": "Zurück",
"cookFinish": "Fertig",
"cookDoneTitle": "Guten Appetit!",
"cookDoneBody": "Alle Schritte geschafft. Lass es dir schmecken.",
"cookBackToRecipe": "Zurück zum Rezept",
"cookNoSteps": "Für dieses Rezept ist keine Zubereitung eingetragen.",
"servingsFewer": "Weniger Portionen",
"servingsMore": "Mehr Portionen",
"shareIngredients": "Zutaten teilen",
"shareIngredientsHint": "Jede Zutat steht in einer eigenen Zeile – so kann z. B. Bring! sie als einzelne Artikel übernehmen.",
"shareIngredientsSubject": "{name} – Zutaten",
"@shareIngredientsSubject": { "placeholders": { "name": { "type": "String" } } },
"shareIngredientsSubjectServings": "{name} – Zutaten für {servings} Portionen",
"@shareIngredientsSubjectServings": { "placeholders": { "name": { "type": "String" }, "servings": { "type": "String" } } },
"share": "Teilen",
"shareRecipe": "Rezept teilen",
"shareRecipeHint": "Auf dem anderen Handy in CookIt oben auf „Rezept scannen“ tippen und diesen Code scannen – ganz ohne Internet. Fotos reisen nur als Datei mit.",
"includeNotes": "Notizen mitschicken",
"shareAsFile": "Als Datei teilen",
"shareAsFileHint": "Mit Fotos – z. B. über Quick Share, auch ohne Internet.",
"codeTooLong": "Dieses Rezept ist zu lang für einen Code. Teile es als Datei.",
"scanRecipe": "Rezept scannen",
"scanHint": "Halte die Kamera auf den Rezept-Code.",
"notARecipeCode": "Das ist kein CookIt-Rezept-Code.",
"codeFromNewerVersion": "Dieser Code stammt aus einer neueren CookIt-Version. Bitte CookIt aktualisieren.",
"codeDamaged": "Der Code ließ sich nicht lesen. Versuch es noch einmal.",
"cameraUnavailable": "Die Kamera ist nicht verfügbar. Erlaube CookIt den Kamerazugriff in den Android-Einstellungen.",
"receivedRecipe": "Rezept empfangen",
"recipeNameTaken": "Du hast schon ein Rezept „{name}“. Beim Speichern kommt dieses als eigenes dazu.",
"@recipeNameTaken": { "placeholders": { "name": { "type": "String" } } },
"recipeSaved": "„{name}“ gespeichert",
"@recipeSaved": { "placeholders": { "name": { "type": "String" } } },
"fileUnreadable": "Die Datei ließ sich nicht öffnen: {message}",
"@fileUnreadable": { "placeholders": { "message": { "type": "String" } } },
"plannedToday": "Heute geplant",
"plannedTodayMore": "{count, plural, =1{+1 weiterer Eintrag} other{+{count} weitere Einträge}}",
"@plannedTodayMore": { "placeholders": { "count": { "type": "int" } } },
"showIdea": "Lieber eine Idee",
"weekPlanTitle": "Wochenplan",
"jumpToToday": "Heute",
"thisWeek": "Diese Woche",
"nextWeek": "Nächste Woche",
"lastWeek": "Letzte Woche",
"calendarWeek": "KW {number}",
"@calendarWeek": { "placeholders": { "number": { "type": "int" } } },
"todayMarker": "heute",
"previousWeek": "Vorige Woche",
"dropToRemove": "Hier ablegen zum Entfernen",
"dropAdd": "+ dazulegen",
"cook": "Kochen",
"todayBanner": "Heute · {weekday}",
"@todayBanner": { "placeholders": { "weekday": { "type": "String" } } },
"addAnother": "Weiteres hinzufügen",
"entryRemoved": "„{name}“ entfernt",
"@entryRemoved": { "placeholders": { "name": { "type": "String" } } },
"undo": "Rückgängig",
"entryEdit": "Bearbeiten",
"editNoteTitle": "Notiz bearbeiten",
"copy": "Kopieren",
"copied": "In die Zwischenablage kopiert",
"shareMenu": "Teilen",
"shareWeekPlan": "Wochenplan teilen",
"shareWeekPlanHint": "Als Datei an eine andere CookIt-App – mit allen Rezepten und Fotos.",
"shareWeekIngredientsHint": "Zusammengezählt, z. B. für die Einkaufsliste.",
"weekPlanFileName": "Wochenplan {range}",
"@weekPlanFileName": { "placeholders": { "range": { "type": "String" } } },
"receivedWeek": "Wochenplan empfangen",
"receivedWeekEntries": "{count, plural, =1{1 Eintrag} other{{count} Einträge}}",
"@receivedWeekEntries": { "placeholders": { "count": { "type": "int" } } },
"receivedWeekNewRecipes": "{count, plural, =0{keine neuen Rezepte} =1{1 neues Rezept} other{{count} neue Rezepte}}",
"@receivedWeekNewRecipes": { "placeholders": { "count": { "type": "int" } } },
"receivedWeekExisting": "{count, plural, =1{Du hast in dieser Woche schon 1 Eintrag – die neuen kommen dazu.} other{Du hast in dieser Woche schon {count} Einträge – die neuen kommen dazu.}}",
"@receivedWeekExisting": { "placeholders": { "count": { "type": "int" } } },
"adopt": "Übernehmen",
"weekAdopted": "Wochenplan {range} übernommen",
"@weekAdopted": { "placeholders": { "range": { "type": "String" } } },
"addEntry": "Eintrag hinzufügen",
"entryReroll": "Neu würfeln",
"entryMove": "Verschieben …",
"entryRemove": "Entfernen",
"noOtherRecipe": "Kein anderes passendes Rezept gefunden.",
"addEntryTitle": "{meal} · {day}",
"@addEntryTitle": { "placeholders": { "meal": { "type": "String" }, "day": { "type": "String" } } },
"addEntryHint": "Rezept suchen oder Notiz schreiben",
"addAsNote": "Als Notiz: „{text}“",
"@addAsNote": { "placeholders": { "text": { "type": "String" } } },
"planRecipe": "Einplanen",
"planWhichDay": "An welchem Tag?",
"planWhichMeal": "Zu welcher Mahlzeit?",
"planOtherDate": "Anderes Datum …",
"tomorrow": "Morgen",
"planned": "Eingeplant: {day} · {meal}",
"@planned": { "placeholders": { "day": { "type": "String" }, "meal": { "type": "String" } } },
"moveEntryTitle": "Verschieben",
"noMeals": "Lege zuerst in den Einstellungen eine Mahlzeit an.",
"fillWeek": "Woche füllen",
"fillWeekHint": "Füllt die leeren Tage ab heute mit zufälligen Rezepten – ohne Wiederholung in der Woche, und lieber mit Rezepten, die länger nicht dran waren.",
"fillWhichMeal": "Welche Mahlzeit?",
"fillAction": "Füllen",
"fillResult": "{count, plural, =0{Keine leeren Tage gefüllt.} =1{1 Tag gefüllt.} other{{count} Tage gefüllt.}}",
"@fillResult": { "placeholders": { "count": { "type": "int" } } },
"fillLeftEmpty": "{count, plural, =1{1 Tag bleibt leer – keine passenden Rezepte mehr.} other{{count} Tage bleiben leer – keine passenden Rezepte mehr.}}",
"@fillLeftEmpty": { "placeholders": { "count": { "type": "int" } } },
"fillNothingEmpty": "An den Tagen ab heute ist schon alles geplant.",
"shareWeek": "Zutaten der Woche teilen",
"shareWeekHint": "Gleiche Zutaten werden zusammengezählt. Jedes Rezept zählt mit seinen eigenen Portionen.",
"shareWeekSubject": "Zutaten für {range}",
"@shareWeekSubject": { "placeholders": { "range": { "type": "String" } } },
"shareWeekEmpty": "An den gewählten Tagen ist kein Rezept geplant.",
"settingsSectionPlanning": "Wochenplan",
"settingsMeals": "Mahlzeiten bearbeiten",
"settingsMealsSubtitle": "z. B. Frühstück, Mittag, Abend",
"settingsWeekStart": "Woche beginnt am",
"mealsTitle": "Mahlzeiten",
"addMeal": "Mahlzeit hinzufügen",
"editMeal": "Mahlzeit umbenennen",
"mealNameTaken": "Diese Mahlzeit gibt es schon.",
"deleteMealTitle": "Mahlzeit löschen?",
"deleteMealBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 geplanter Eintrag ab heute wird entfernt.} other{„{name}“ wird gelöscht. {count} geplante Einträge ab heute werden entfernt.}} Vergangene Wochen behalten sie.",
"@deleteMealBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"more": "Mehr",
"filterFavourites": "Favoriten",
"filterReview": "Zu prüfen ({count})",
"@filterReview": { "placeholders": { "count": { "type": "int" } } },
"newRecipe": "Neues Rezept",
"settings": "Einstellungen",
"emptyCollectionTitle": "Noch keine Rezepte",
"emptyCollectionBody": "Leg dein erstes Rezept an oder importiere ein Rezeptpaket in den Einstellungen.",
"noMatchesTitle": "Nichts gefunden",
"noMatchesBody": "Versuch es mit einem anderen Suchbegriff oder weniger Filtern.",
"resetFilters": "Filter zurücksetzen",
"recipeCount": "{count, plural, =1{1 Rezept} other{{count} Rezepte}}",
"@recipeCount": { "placeholders": { "count": { "type": "int" } } },
"favourite": "Favorit",
"removeFavourite": "Kein Favorit mehr",
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"save": "Speichern",
"deleteRecipeTitle": "Rezept löschen?",
"deleteRecipeBody": "„{name}“ und seine Fotos werden endgültig gelöscht.",
"@deleteRecipeBody": { "placeholders": { "name": { "type": "String" } } },
"recipeNotFound": "Dieses Rezept gibt es nicht mehr.",
"reviewTitle": "Bitte prüfen",
"reviewDone": "Erledigt",
"servings": "Portionen",
"servingsOne": "1 Portion",
"servingsValue": "{value} Portionen",
"@servingsValue": { "placeholders": { "value": { "type": "String" } } },
"minutesValue": "{value} Min.",
"@minutesValue": { "placeholders": { "value": { "type": "int" } } },
"prepTime": "Vorbereitung",
"cookTime": "Kochen",
"totalTime": "Gesamt",
"caloriesPerServing": "{kcal} kcal pro Portion",
"@caloriesPerServing": { "placeholders": { "kcal": { "type": "int" } } },
"caloriesWholeRecipe": "{kcal} kcal gesamt",
"@caloriesWholeRecipe": { "placeholders": { "kcal": { "type": "int" } } },
"ingredients": "Zutaten",
"steps": "Zubereitung",
"notes": "Notizen",
"sources": "Quellen",
"tags": "Stichwörter",
"noIngredients": "Keine Zutaten eingetragen.",
"noSteps": "Keine Zubereitung eingetragen.",
"editRecipe": "Rezept bearbeiten",
"discardTitle": "Änderungen verwerfen?",
"discardBody": "Deine Änderungen an diesem Rezept gehen verloren.",
"discard": "Verwerfen",
"keepEditing": "Weiter bearbeiten",
"fieldName": "Name",
"fieldNameRequired": "Bitte gib dem Rezept einen Namen.",
"fieldCourse": "Gang",
"noCourse": "Kein Gang",
"fieldServings": "Portionen",
"fieldPrep": "Vorbereitung",
"fieldCook": "Kochen",
"minutesSuffix": "Min.",
"fieldKcal": "Kalorien (kcal)",
"basisPerServing": "pro Portion",
"basisWholeRecipe": "gesamt",
"fieldTags": "Stichwörter",
"fieldTagsHint": "durch Komma getrennt, z. B. vegan, schnell",
"fieldNotes": "Notizen",
"fieldSources": "Quellen",
"fieldSourcesHint": "ein Link pro Zeile",
"invalidNumber": "Keine gültige Zahl",
"photos": "Fotos",
"addPhoto": "Foto hinzufügen",
"photoFromCamera": "Kamera",
"photoFromGallery": "Galerie",
"mainPhoto": "Hauptfoto",
"setMainPhoto": "Als Hauptfoto festlegen",
"photoHint": "Gedrückt halten und ziehen zum Sortieren – das erste Foto ist das Hauptfoto. Antippen für mehr.",
"removePhoto": "Foto entfernen",
"addIngredient": "Zutat",
"addIngredientGroup": "Gruppe",
"ingredientAmount": "Menge",
"ingredientUnit": "Einheit",
"ingredientName": "Zutat",
"ingredientRemark": "Hinweis, z. B. gehackt",
"addRemark": "Hinweis hinzufügen",
"invalidAmount": "z. B. 2, 1,5, ½ oder 2-3",
"groupName": "Gruppenname (optional)",
"noUnit": "–",
"addStep": "Schritt",
"addStepSection": "Abschnitt",
"stepText": "Schritt {number}",
"@stepText": { "placeholders": { "number": { "type": "int" } } },
"sectionTitle": "Abschnitt, z. B. Teig",
"removeItem": "Entfernen",
"unnamedGroup": "Weitere Zutaten",
"settingsCourses": "Gänge bearbeiten",
"settingsCoursesSubtitle": "Hinzufügen, umbenennen, sortieren",
"settingsImport": "Rezepte importieren",
"settingsImportSubtitle": "Rezeptpaket (.zip) laden",
"settingsExport": "Sammlung exportieren",
"settingsExportSubtitle": "Alle Rezepte mit Fotos als Backup speichern",
"settingsSectionCollection": "Sammlung",
"settingsSectionData": "Daten",
"settingsSectionAppearance": "Darstellung",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"importing": "Rezepte werden importiert …",
"importResult": "{added} neu, {skipped} übersprungen",
"@importResult": { "placeholders": { "added": { "type": "int" }, "skipped": { "type": "int" } } },
"importFailed": "Import fehlgeschlagen: {message}",
"@importFailed": { "placeholders": { "message": { "type": "String" } } },
"exporting": "Sammlung wird exportiert …",
"exportDone": "Sammlung gespeichert.",
"exportFailed": "Export fehlgeschlagen: {message}",
"@exportFailed": { "placeholders": { "message": { "type": "String" } } },
"settingsUnits": "Maßeinheiten bearbeiten",
"settingsUnitsSubtitle": "Eigene hinzufügen, sortieren, direkt anzeigen",
"unitsTitle": "Maßeinheiten",
"unitsDirectHint": "Eingeschaltete Einheiten erscheinen direkt in der Auswahl, die anderen unter „Weitere Einheiten“. Ziehen zum Sortieren.",
"addUnit": "Einheit hinzufügen",
"editUnit": "Einheit umbenennen",
"unitName": "Name",
"ownUnit": "Eigene Einheit",
"unitNameTaken": "Diese Einheit gibt es schon.",
"showDirectly": "Direkt anzeigen",
"deleteUnitTitle": "Einheit löschen?",
"deleteUnitBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 Rezept verwendet diese Einheit – dort wandert das Wort in den Zutatennamen.} other{„{name}“ wird gelöscht. {count} Rezepte verwenden diese Einheit – dort wandert das Wort in den Zutatennamen.}}",
"@deleteUnitBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"coursesTitle": "Gänge",
"addCourse": "Gang hinzufügen",
"editCourse": "Gang bearbeiten",
"courseName": "Name",
"courseEmoji": "Emoji (optional)",
"courseNameTaken": "Diesen Gang gibt es schon.",
"deleteCourseTitle": "Gang löschen?",
"deleteCourseBody": "{count, plural, =0{„{name}“ wird gelöscht.} =1{„{name}“ wird gelöscht. 1 Rezept hat danach keinen Gang mehr.} other{„{name}“ wird gelöscht. {count} Rezepte haben danach keinen Gang mehr.}}",
"@deleteCourseBody": { "placeholders": { "name": { "type": "String" }, "count": { "type": "int" } } },
"unitGram": "g",
"unitKilogram": "kg",
"unitMilliliter": "ml",
"unitLiter": "l",
"unitTablespoon": "EL",
"unitTeaspoon": "TL",
"unitPinch": "Prise",
"unitKnifeTip": "Msp.",
"unitPack": "Pck.",
"unitPiece": "Stück",
"unitSlice": "Scheibe(n)",
"unitClove": "Zehe(n)",
"unitSprig": "Zweig(e)",
"unitBunch": "Bund",
"unitHandful": "Handvoll",
"unitCup": "Tasse(n)",
"unitCan": "Dose",
"unitJar": "Glas",
"unitTub": "Becher",
"unitBag": "Beutel",
"unitCarton": "Karton",
"unitHead": "Kopf",
"unitGrain": "Korn",
"unitSmall": "kleine(r)",
"unitMedium": "mittlere(r)",
"unitLarge": "große(r)",
"unitSome": "etwas",
"moreUnits": "Weitere Einheiten …"
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
/// Calendar days for the Week Plan. A day is a UTC midnight, so adding days
/// never trips over daylight saving time.
DateTime dayOf(DateTime local) => DateTime.utc(local.year, local.month, local.day);
DateTime addDays(DateTime day, int days) => DateTime.utc(day.year, day.month, day.day + days);
/// "2026-10-23", as days are stored.
String dayKey(DateTime day) =>
'${day.year.toString().padLeft(4, '0')}-${day.month.toString().padLeft(2, '0')}-${day.day.toString().padLeft(2, '0')}';
DateTime parseDayKey(String key) {
final [y, m, d] = key.split('-').map(int.parse).toList();
return DateTime.utc(y, m, d);
}
/// The first day of the week [day] belongs to, for weeks starting on
/// [weekStart] (`DateTime.monday` … `DateTime.sunday`).
DateTime weekStartOf(DateTime day, int weekStart) => addDays(day, -((day.weekday - weekStart) % 7));
List<DateTime> weekDays(DateTime start) => [for (var i = 0; i < 7; i++) addDays(start, i)];
/// ISO calendar week ("KW") of the week starting at [start], taken from the
/// week's middle so weeks not starting on Monday still get a sensible number.
int calendarWeek(DateTime start) {
final middle = addDays(start, 3);
final thursday = addDays(middle, DateTime.thursday - middle.weekday);
final firstThursday = addDays(
DateTime.utc(thursday.year),
(DateTime.thursday - DateTime.utc(thursday.year).weekday) % 7,
);
return 1 + thursday.difference(firstThursday).inDays ~/ 7;
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:intl/intl.dart';
import '../localization/app_localizations.dart';
import 'day.dart';
/// "Heute", "Morgen" or "Sa., 24. Okt."
String dayLabel(AppLocalizations l10n, DateTime day, DateTime today) {
if (day == today) return l10n.jumpToToday;
if (day == addDays(today, 1)) return l10n.tomorrow;
return DateFormat('E, d. MMM', 'de').format(day);
}
/// "19.–25. Okt." within a month, "26. Okt. – 1. Nov." across two.
String dayRange(DateTime first, DateTime last) {
if (first == last) return DateFormat('d. MMM', 'de').format(first);
if (first.month == last.month) return '${first.day}.–${DateFormat('d. MMM', 'de').format(last)}';
return '${DateFormat('d. MMM', 'de').format(first)} – ${DateFormat('d. MMM', 'de').format(last)}';
}
/// "Fr." for the day column of the Week Plan.
String weekdayShort(DateTime day) => DateFormat('E', 'de').format(day);
+27
View File
@@ -0,0 +1,27 @@
import '../recipes/models/recipe.dart';
/// All Ingredient Lines of [recipes] (each at its own Servings), with lines of
/// the same name and Unit added up: "200 g Butter" twice becomes "400 g Butter".
/// Lines in different Units stay apart; a line without an amount is listed once.
/// Remarks are kept only when every added line agrees on them.
List<IngredientLine> ingredientTotals(Iterable<Recipe> recipes) {
final totals = <(String, String?), IngredientLine>{};
for (final line in recipes.expand((r) => r.ingredientLines)) {
final key = (line.name.trim().toLowerCase(), line.unitKey);
final sum = totals[key];
totals[key] = sum == null ? line : _add(sum, line);
}
return totals.values.toList();
}
IngredientLine _add(IngredientLine a, IngredientLine b) {
final amount = a.amount == null && b.amount == null ? null : (a.amount ?? 0) + (b.amount ?? 0);
final ranged = a.amountMax != null || b.amountMax != null;
return IngredientLine(
amount: amount,
amountMax: ranged ? (a.amountMax ?? a.amount ?? 0) + (b.amountMax ?? b.amount ?? 0) : null,
unitKey: a.unitKey,
name: a.name,
remark: a.remark == b.remark ? a.remark : null,
);
}
+14
View File
@@ -0,0 +1,14 @@
/// A time of day the cook plans for, such as Mittag or Abend.
class Meal {
const Meal({required this.id, required this.name, required this.position, this.retired = false});
final String id;
final String name;
final int position;
/// Deleted by the cook but still named by past Plan Entries.
final bool retired;
}
/// The Meals a fresh install starts with.
const defaultMeals = ['Mittag', 'Abend'];
+22
View File
@@ -0,0 +1,22 @@
/// One thing planned for a Meal on a day: a Recipe or a short note.
class PlanEntry {
const PlanEntry({
required this.id,
required this.day,
required this.mealId,
this.recipeId,
this.note,
required this.position,
}) : assert((recipeId == null) != (note == null), 'A Plan Entry is either a Recipe or a note');
final String id;
/// The calendar day, as a UTC midnight (see `day.dart`).
final DateTime day;
final String mealId;
final String? recipeId;
final String? note;
/// Order within its Meal on its day.
final int position;
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import 'day.dart';
import 'models/meal.dart';
import 'models/plan_entry.dart';
/// Meals and Plan Entries of the Week Plan.
class PlanRepository {
PlanRepository(this._db);
final AppDatabase _db;
Stream<List<Meal>> watchMeals() {
final query = _db.select(_db.meals)..orderBy([(m) => OrderingTerm.asc(m.position)]);
return query.watch().map((rows) => rows.map(_toMeal).toList());
}
Future<List<Meal>> allMeals() async {
final query = _db.select(_db.meals)..orderBy([(m) => OrderingTerm.asc(m.position)]);
return (await query.get()).map(_toMeal).toList();
}
/// Adds a Meal at the end of the list. A retired Meal of the same name comes
/// back instead, so its past Plan Entries join the new one.
Future<void> addMeal(String name) => _db.transaction(() async {
final meals = await allMeals();
final position = meals.isEmpty ? 0 : meals.last.position + 1;
for (final meal in meals.where((m) => m.retired)) {
if (meal.name.toLowerCase() == name.toLowerCase()) {
await _updateMeal(
meal.id,
MealsCompanion(name: Value(name), retired: const Value(false), position: Value(position)),
);
return;
}
}
await _db.into(_db.meals).insert(MealsCompanion.insert(id: const Uuid().v4(), name: name, position: position));
});
Future<void> renameMeal(String id, String name) => _updateMeal(id, MealsCompanion(name: Value(name)));
Future<void> reorderMeals(List<Meal> ordered) => _db.batch((b) {
for (final (i, meal) in ordered.indexed) {
b.update(_db.meals, MealsCompanion(position: Value(i)), where: (m) => m.id.equals(meal.id));
}
});
/// Plan Entries for this Meal from [today] on, which deleting it removes.
Future<int> upcomingEntryCount(String mealId, DateTime today) async {
final count = _db.planEntries.id.count();
final query = _db.selectOnly(_db.planEntries)
..addColumns([count])
..where(_db.planEntries.mealId.equals(mealId) & _db.planEntries.day.isBiggerOrEqualValue(dayKey(today)));
return (await query.getSingle()).read(count) ?? 0;
}
/// Removes the Meal and its Plan Entries from [today] on; past entries keep it as a retired Meal.
Future<void> deleteMeal(String mealId, DateTime today) => _db.transaction(() async {
await (_db.delete(
_db.planEntries,
)..where((e) => e.mealId.equals(mealId) & e.day.isBiggerOrEqualValue(dayKey(today)))).go();
final remaining = await (_db.select(_db.planEntries)..where((e) => e.mealId.equals(mealId))).get();
if (remaining.isEmpty) {
await (_db.delete(_db.meals)..where((m) => m.id.equals(mealId))).go();
} else {
await _updateMeal(mealId, const MealsCompanion(retired: Value(true)));
}
});
Stream<List<PlanEntry>> watchEntries() {
final query = _db.select(_db.planEntries)
..orderBy([(e) => OrderingTerm.asc(e.day), (e) => OrderingTerm.asc(e.position)]);
return query.watch().map((rows) => rows.map(_toEntry).toList());
}
Future<List<PlanEntry>> allEntries() async {
final query = _db.select(_db.planEntries)
..orderBy([(e) => OrderingTerm.asc(e.day), (e) => OrderingTerm.asc(e.position)]);
return (await query.get()).map(_toEntry).toList();
}
/// Plans a Recipe ([recipeId]) or a [note] at the end of the Meal on [day].
Future<void> addEntry(DateTime day, String mealId, {String? recipeId, String? note}) =>
addEntries([(day: day, mealId: mealId, recipeId: recipeId, note: note)]);
Future<void> addEntries(List<({DateTime day, String mealId, String? recipeId, String? note})> entries) =>
_db.transaction(() async {
for (final e in entries) {
await _db
.into(_db.planEntries)
.insert(
PlanEntriesCompanion.insert(
id: const Uuid().v4(),
day: dayKey(e.day),
mealId: e.mealId,
recipeId: Value(e.recipeId),
note: Value(e.note),
position: await _nextPosition(e.day, e.mealId),
),
);
}
});
/// "Tauschen": the entry now plans another Recipe.
Future<void> replaceRecipe(String entryId, String recipeId) =>
(_db.update(_db.planEntries)..where((e) => e.id.equals(entryId))).write(
PlanEntriesCompanion(recipeId: Value(recipeId), note: const Value(null)),
);
/// A note's new text.
Future<void> updateNote(String entryId, String note) => (_db.update(
_db.planEntries,
)..where((e) => e.id.equals(entryId) & e.recipeId.isNull())).write(PlanEntriesCompanion(note: Value(note)));
/// "Verschieben": to the end of another Meal or day.
Future<void> moveEntry(String entryId, DateTime day, String mealId) => _db.transaction(() async {
final position = await _nextPosition(day, mealId);
await (_db.update(_db.planEntries)..where((e) => e.id.equals(entryId))).write(
PlanEntriesCompanion(day: Value(dayKey(day)), mealId: Value(mealId), position: Value(position)),
);
});
Future<void> removeEntry(String entryId) => (_db.delete(_db.planEntries)..where((e) => e.id.equals(entryId))).go();
Future<int> _nextPosition(DateTime day, String mealId) async {
final max = _db.planEntries.position.max();
final query = _db.selectOnly(_db.planEntries)
..addColumns([max])
..where(_db.planEntries.day.equals(dayKey(day)) & _db.planEntries.mealId.equals(mealId));
final current = (await query.getSingle()).read(max);
return current == null ? 0 : current + 1;
}
Future<void> _updateMeal(String id, MealsCompanion companion) =>
(_db.update(_db.meals)..where((m) => m.id.equals(id))).write(companion);
static Meal _toMeal(MealRow row) => Meal(id: row.id, name: row.name, position: row.position, retired: row.retired);
static PlanEntry _toEntry(PlanEntryRow row) => PlanEntry(
id: row.id,
day: parseDayKey(row.day),
mealId: row.mealId,
recipeId: row.recipeId,
note: row.note,
position: row.position,
);
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:typed_data';
import '../bundle/bundle_codec.dart';
import '../bundle/bundle_service.dart';
import '../recipes/course_repository.dart';
import '../recipes/models/recipe.dart';
import '../recipes/photo_store.dart';
import '../recipes/recipe_repository.dart';
import '../recipes/unit_repository.dart';
import 'day.dart';
import 'plan_repository.dart';
/// A week of another cook's Week Plan, read from a Recipe Bundle and not yet
/// added here.
class SharedWeek {
const SharedWeek(this.contents);
final BundleContents contents;
BundlePlan get plan => contents.plan!;
DateTime get weekStart => parseDayKey(plan.weekStart);
/// The bundle's Recipes by their id in the bundle.
Map<String, BundleRecipe> get recipes => {for (final r in contents.recipes) r.recipe.id: r};
}
/// What adopting a [SharedWeek] would do.
class SharedWeekPreview {
const SharedWeekPreview({required this.entries, required this.newRecipes, required this.existingEntries});
final int entries;
/// Recipes this collection doesn't have yet (by id or by name).
final int newRecipes;
/// Plan Entries already in that week here, which stay.
final int existingEntries;
}
/// "Wochenplan teilen": one week of the Week Plan, with the Recipes it plans,
/// as a Recipe Bundle for another cook's CookIt – and taking one in.
class PlanShareService {
PlanShareService(this._recipes, this._courses, this._units, this._photos, this._plan, this._bundles);
final RecipeRepository _recipes;
final CourseRepository _courses;
final UnitRepository _units;
final PhotoStore _photos;
final PlanRepository _plan;
final BundleService _bundles;
Future<Uint8List> weekFile(DateTime weekStart) async {
final end = addDays(weekStart, 6);
final entries = [
for (final e in await _plan.allEntries())
if (!e.day.isBefore(weekStart) && !e.day.isAfter(end)) e,
];
final meals = {for (final m in await _plan.allMeals()) m.id: m.name};
final used = {for (final e in entries) e.mealId};
final recipeIds = {for (final e in entries) ?e.recipeId};
final recipes = [
for (final r in await _recipes.allRecipes())
if (recipeIds.contains(r.id)) r,
];
final plan = BundlePlan(
weekStart: dayKey(weekStart),
meals: [
for (final MapEntry(key: id, value: name) in meals.entries)
if (used.contains(id)) name,
],
entries: [
for (final e in entries)
BundlePlanEntry(day: dayKey(e.day), meal: meals[e.mealId] ?? '', recipeId: e.recipeId, note: e.note),
],
);
return _bundles.exportWeek(recipes, plan);
}
Future<SharedWeekPreview> preview(SharedWeek week) async {
final matches = await _matchRecipes(week);
final start = week.weekStart;
final end = addDays(start, 6);
final existing = (await _plan.allEntries()).where((e) => !e.day.isBefore(start) && !e.day.isAfter(end));
return SharedWeekPreview(
entries: week.plan.entries.length,
newRecipes: week.recipes.keys.where((id) => matches[id] == null).length,
existingEntries: existing.length,
);
}
/// Adds the week's entries next to what is planned here: Recipes this
/// collection has (same id or same name) are reused, the others added;
/// Meals are matched by name and created when missing.
Future<void> adopt(SharedWeek week) async {
final matches = await _matchRecipes(week);
final known = {for (final u in await _units.allUnits()) u.key};
for (final unit in week.contents.units) {
if (!known.contains(unit.key)) await _units.addOwn(unit.label, key: unit.key, direct: unit.direct);
}
final ids = <String, String>{};
for (final MapEntry(key: id, value: entry) in week.recipes.entries) {
ids[id] = matches[id] ?? await _addRecipe(entry, week.contents.files);
}
final mealIds = <String, String>{};
for (final name in {...week.plan.meals, for (final e in week.plan.entries) e.meal}) {
mealIds[name] = await _mealNamed(name);
}
await _plan.addEntries([
for (final e in week.plan.entries)
(
day: parseDayKey(e.day),
mealId: mealIds[e.meal]!,
recipeId: e.recipeId == null ? null : ids[e.recipeId],
note: e.recipeId == null || ids[e.recipeId] == null ? e.note ?? '' : null,
),
]);
}
/// The bundle's Recipe ids mapped to the Recipe here that is the same
/// (same id, else same name), or null when there is none.
Future<Map<String, String?>> _matchRecipes(SharedWeek week) async {
final mine = await _recipes.allRecipes();
final byId = {for (final r in mine) r.id};
final byName = {for (final r in mine) _key(r.name): r.id};
return {
for (final MapEntry(key: id, value: entry) in week.recipes.entries)
id: byId.contains(id) ? id : byName[_key(entry.recipe.name)],
};
}
/// Adds a Recipe from the bundle, keeping its id so a later week from the
/// same cook finds it again.
Future<String> _addRecipe(BundleRecipe entry, Map<String, Uint8List> files) async {
final courseName = entry.courseName;
final courseId = courseName == null ? null : (await _courses.findOrCreate(courseName)).id;
final Recipe recipe = entry.recipe.copyWith(
courseId: () => courseId,
favourite: false,
reviewReasons: const [],
photos: [
for (final path in entry.photoPaths)
if (files[path] case final bytes?) await _photos.saveBytes(bytes),
],
);
await _recipes.saveRecipe(recipe);
return recipe.id;
}
Future<String> _mealNamed(String name) async {
bool same(String other) => _key(other) == _key(name);
for (final meal in await _plan.allMeals()) {
if (!meal.retired && same(meal.name)) return meal.id;
}
await _plan.addMeal(name); // brings a retired Meal of that name back
return (await _plan.allMeals()).firstWhere((m) => !m.retired && same(m.name)).id;
}
static String _key(String name) => name.trim().toLowerCase();
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/text_breaks.dart';
import '../day.dart';
import '../day_format.dart';
import '../plan_share_service.dart';
/// A week of another cook's Week Plan, shown before anything is added: the
/// entries as a grid, what adopting would add, and "Übernehmen".
class SharedWeekView extends StatefulWidget {
const SharedWeekView({super.key, required this.week});
static const routeName = '/received-week';
final SharedWeek week;
@override
State<SharedWeekView> createState() => _SharedWeekViewState();
}
class _SharedWeekViewState extends State<SharedWeekView> {
late final Future<SharedWeekPreview> _preview = context.read<PlanShareService>().preview(widget.week);
var _adopting = false;
Future<void> _adopt() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
setState(() => _adopting = true);
await context.read<PlanShareService>().adopt(widget.week);
navigator.pop();
final start = widget.week.weekStart;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.weekAdopted(dayRange(start, addDays(start, 6))))));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final week = widget.week;
final start = week.weekStart;
final recipes = week.recipes;
final meals = week.plan.meals;
return Scaffold(
appBar: AppBar(title: Text(l10n.receivedWeek)),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: _adopting ? null : _adopt,
icon: const Icon(Icons.calendar_month_rounded),
label: Text(l10n.adopt),
),
),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text(
l10n.weekPlanFileName(dayRange(start, addDays(start, 6))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
FutureBuilder<SharedWeekPreview>(
future: _preview,
builder: (context, snapshot) {
final preview = snapshot.data;
if (preview == null) return const SizedBox(height: 24);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Text(
'${l10n.receivedWeekEntries(preview.entries)} · ${l10n.receivedWeekNewRecipes(preview.newRecipes)}',
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
),
if (preview.existingEntries > 0)
Container(
margin: const EdgeInsets.only(top: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: c.honey, borderRadius: BorderRadius.circular(14)),
child: Text(
l10n.receivedWeekExisting(preview.existingEntries),
style: TextStyle(fontWeight: FontWeight.w700, color: c.onHoney),
),
),
],
);
},
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(20)),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 46),
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6, bottom: 6),
child: Text(
meal,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.inkMuted),
),
),
),
],
),
for (final day in weekDays(start))
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 46,
child: Text(
'${weekdayShort(day).replaceAll('.', '')}\n${day.day}.${day.month}.',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 12, color: c.ink),
),
),
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final e in week.plan.entries)
if (e.day == dayKey(day) && e.meal == meal)
_PreviewEntry(
text: recipes[e.recipeId]?.recipe.name ?? e.note ?? '',
note: e.recipeId == null,
),
],
),
),
),
],
),
),
],
),
),
],
),
);
}
}
class _PreviewEntry extends StatelessWidget {
const _PreviewEntry({required this.text, required this.note});
final String text;
final bool note;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: note ? c.noteFill : c.planCell,
borderRadius: BorderRadius.circular(10),
border: note ? Border.all(color: c.noteBorder, width: 1.5) : null,
),
child: Row(
children: [
if (note) ...[Icon(Icons.sticky_note_2_outlined, size: 14, color: c.noteInk), const SizedBox(width: 4)],
Expanded(
child: Text(
withNaturalBreaks(text),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.ink),
),
),
],
),
);
}
}
+395
View File
@@ -0,0 +1,395 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/cookit_colors.dart';
import '../../design/cookit_nav_bar.dart';
import '../../design/herb_pattern.dart';
import '../../localization/app_localizations.dart';
import '../../recipes/recipe_list_provider.dart';
import '../../settings/settings_controller.dart';
import '../day.dart';
import '../day_format.dart';
import '../models/plan_entry.dart';
import '../plan_repository.dart';
import 'widgets/fill_week_sheet.dart';
import 'widgets/plan_actions.dart';
import 'widgets/plan_grid.dart';
import 'widgets/week_share_menu.dart';
import 'widgets/today_banner.dart';
/// The Week Plan: today's banner, then one week as a grid. Swipe (or ‹ ›) for
/// other weeks. While an entry is dragged, the week bar turns into drop zones
/// for the neighbouring weeks and the navigation bar into "remove".
class WeekPlanView extends StatefulWidget {
const WeekPlanView({super.key, this.onDraggingChanged});
/// Lets the home screen hide its navigation bar while an entry is dragged.
final ValueChanged<bool>? onDraggingChanged;
@override
State<WeekPlanView> createState() => _WeekPlanViewState();
}
class _WeekPlanViewState extends State<WeekPlanView> {
/// The page showing the current week; pages before it are past weeks.
static const _origin = 10000;
final _pages = PageController(initialPage: _origin);
var _page = _origin;
var _dragging = false;
@override
void dispose() {
_pages.dispose();
super.dispose();
}
DateTime get _thisWeek => weekStartOf(dayOf(DateTime.now()), context.read<SettingsController>().weekStart);
DateTime _weekOf(int page) => addDays(_thisWeek, 7 * (page - _origin));
void _goTo(int page) =>
_pages.animateToPage(page, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic);
// A drag can outlive the cell it started in (the week flips under it), so it
// ends with the finger, not with the cell's callbacks.
void _setDragging(bool dragging) {
if (_dragging == dragging) return;
setState(() => _dragging = dragging);
widget.onDraggingChanged?.call(dragging);
}
/// Dropped on "Vorige/Nächste Woche": same weekday and Meal in the week now
/// shown, or in the neighbouring one if the week hasn't flipped yet.
Future<void> _dropOnWeek(PlanEntry entry, int direction) async {
final weekStart = context.read<SettingsController>().weekStart;
final entryWeek = weekStartOf(entry.day, weekStart);
var target = _weekOf(_page);
if (target == entryWeek) target = addDays(target, 7 * direction);
final day = addDays(target, (entry.day.weekday - weekStart) % 7);
final page = _origin + target.difference(_thisWeek).inDays ~/ 7;
await context.read<PlanRepository>().moveEntry(entry.id, day, entry.mealId);
_goTo(page);
}
Future<void> _remove(PlanEntry entry) async {
final recipes = context.read<RecipeListProvider>().allRecipes;
final name = recipes.where((r) => r.id == entry.recipeId).firstOrNull?.name ?? entry.note ?? '';
await removeEntryWithUndo(context, entry, name);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
context.watch<SettingsController>();
final today = dayOf(DateTime.now());
final topInset = MediaQuery.paddingOf(context).top;
final bottomInset = MediaQuery.paddingOf(context).bottom;
final shown = _weekOf(_page);
return Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
// No Scaffold of its own: HomeView's is the one the SnackBars appear in.
child: ColoredBox(
color: c.background,
child: Stack(
children: [
const Positioned.fill(child: ParallaxEdgePattern()),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, topInset + 16, 20, 8),
child: Row(
children: [
Expanded(
child: Text(
l10n.weekPlanTitle,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 32,
color: c.ink,
),
),
),
FilledButton.tonalIcon(
onPressed: () => _goTo(_origin),
icon: const Icon(Icons.today_rounded, size: 18),
label: Text(l10n.jumpToToday),
),
],
),
),
const Padding(padding: EdgeInsets.symmetric(horizontal: 16), child: TodayBanner()),
SizedBox(
height: 60,
child: _dragging
? Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Expanded(
child: _WeekDropZone(
label: l10n.previousWeek,
icon: Icons.chevron_left_rounded,
onFlip: () => _goTo(_page - 1),
onDrop: (entry) => _dropOnWeek(entry, -1),
),
),
const SizedBox(width: 8),
Expanded(
child: _WeekDropZone(
label: l10n.nextWeek,
icon: Icons.chevron_right_rounded,
iconAfter: true,
onFlip: () => _goTo(_page + 1),
onDrop: (entry) => _dropOnWeek(entry, 1),
),
),
],
),
)
: _WeekBar(
start: shown,
weeksAway: _page - _origin,
past: addDays(shown, 6).isBefore(today),
onPrevious: () => _goTo(_page - 1),
onNext: () => _goTo(_page + 1),
),
),
Expanded(
child: PageView.builder(
controller: _pages,
onPageChanged: (page) => setState(() => _page = page),
itemBuilder: (context, page) => SingleChildScrollView(
padding: EdgeInsets.fromLTRB(12, 0, 12, 32 + bottomInset + CookItNavBar.height),
child: PlanGrid(weekStart: _weekOf(page), today: today, onDragStarted: () => _setDragging(true)),
),
),
),
],
),
if (_dragging)
Positioned(
left: 16,
right: 16,
bottom: 16 + bottomInset,
height: CookItNavBar.height,
child: DragTarget<PlanEntry>(
onAcceptWithDetails: (d) => _remove(d.data),
builder: (context, candidates, _) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.tiles[2] : const Color(0xFFF4C4AE),
borderRadius: BorderRadius.circular(CookItNavBar.height / 2),
border: Border.all(color: const Color(0xFFC0563A), width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outline_rounded, color: Color(0xFF8A2E17)),
const SizedBox(width: 8),
Text(
l10n.dropToRemove,
style: const TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFF8A2E17),
),
),
],
),
),
),
),
),
],
),
),
);
}
}
class _WeekBar extends StatelessWidget {
const _WeekBar({
required this.start,
required this.weeksAway,
required this.past,
required this.onPrevious,
required this.onNext,
});
final DateTime start;
final int weeksAway;
final bool past;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final title = switch (weeksAway) {
0 => l10n.thisWeek,
1 => l10n.nextWeek,
-1 => l10n.lastWeek,
_ => l10n.calendarWeek(calendarWeek(start)),
};
final round = IconButton.styleFrom(
backgroundColor: c.surface,
foregroundColor: c.green,
fixedSize: const Size(40, 40),
);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 16, 0),
child: Row(
children: [
// ‹ title › take what the buttons leave, the title shortening only if it must.
Expanded(
child: Row(
children: [
IconButton(
tooltip: l10n.previousWeek,
onPressed: onPrevious,
icon: Icon(Icons.chevron_left_rounded, color: c.green),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 17,
color: c.green,
),
),
Text(
dayRange(start, addDays(start, 6)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12, color: c.inkMuted),
),
],
),
),
IconButton(
tooltip: l10n.nextWeek,
onPressed: onNext,
icon: Icon(Icons.chevron_right_rounded, color: c.green),
),
],
),
),
IconButton(
tooltip: l10n.shareMenu,
onPressed: () => showWeekShareMenu(context, start),
style: round,
icon: const Icon(Icons.share_rounded, size: 20),
),
if (!past) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.fillWeek,
onPressed: () => showFillWeekSheet(context, start),
style: round,
icon: const Icon(Icons.casino_outlined, size: 20),
),
],
],
),
);
}
}
/// "Vorige Woche" / "Nächste Woche" while dragging: holding an entry over it
/// flips the week (again every moment it stays there); dropping it there moves it.
class _WeekDropZone extends StatefulWidget {
const _WeekDropZone({
required this.label,
required this.icon,
required this.onFlip,
required this.onDrop,
this.iconAfter = false,
});
final String label;
final IconData icon;
final bool iconAfter;
final VoidCallback onFlip;
final ValueChanged<PlanEntry> onDrop;
@override
State<_WeekDropZone> createState() => _WeekDropZoneState();
}
class _WeekDropZoneState extends State<_WeekDropZone> {
Timer? _flipping;
void _stop() {
_flipping?.cancel();
_flipping = null;
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return DragTarget<PlanEntry>(
onWillAcceptWithDetails: (_) {
_flipping ??= Timer.periodic(const Duration(milliseconds: 800), (_) => widget.onFlip());
return true;
},
onLeave: (_) => _stop(),
onAcceptWithDetails: (d) {
_stop();
widget.onDrop(d.data);
},
builder: (context, candidates, _) {
final icon = Icon(widget.icon, size: 20, color: c.green);
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: candidates.isEmpty ? c.surface : c.navBar,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: c.brand, width: 2),
),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.iconAfter) icon,
Text(
widget.label,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 14, color: c.green),
),
if (widget.iconAfter) icon,
],
),
),
);
},
);
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../plan_repository.dart';
/// "+" on a Meal: search the collection for a Recipe, or keep what was typed as a note.
Future<void> showAddEntrySheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _AddEntrySheet(day: day, meal: meal),
);
class _AddEntrySheet extends StatefulWidget {
const _AddEntrySheet({required this.day, required this.meal});
final DateTime day;
final Meal meal;
@override
State<_AddEntrySheet> createState() => _AddEntrySheetState();
}
class _AddEntrySheetState extends State<_AddEntrySheet> {
final _query = TextEditingController();
/// Only Recipes of this Course; null shows all.
String? _courseId;
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _add({String? recipeId, String? note}) async {
final plan = context.read<PlanRepository>();
Navigator.pop(context);
await plan.addEntry(widget.day, widget.meal.id, recipeId: recipeId, note: note);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final provider = context.watch<RecipeListProvider>();
final text = _query.text.trim();
final recipes = provider.allRecipes
.where((r) => _courseId == null || r.courseId == _courseId)
.where((r) => r.name.toLowerCase().contains(text.toLowerCase()))
.toList();
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addEntryTitle(widget.meal.name, dayLabel(l10n, widget.day, dayOf(DateTime.now()))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
const SizedBox(height: 12),
TextField(
controller: _query,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.done,
onChanged: (_) => setState(() {}),
onSubmitted: (value) {
if (value.trim().isNotEmpty) _add(note: value.trim());
},
decoration: InputDecoration(
hintText: l10n.addEntryHint,
prefixIcon: Icon(Icons.search_rounded, color: c.green),
fillColor: c.background,
),
),
],
),
),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
for (final course in [null, ...provider.courses])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(course == null ? l10n.filterAll : course.label),
selected: _courseId == course?.id,
showCheckmark: false,
onSelected: (_) => setState(() => _courseId = course?.id),
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: ListView(
children: [
if (text.isNotEmpty)
ListTile(
leading: CircleAvatar(
backgroundColor: c.tiles[0],
child: Icon(Icons.sticky_note_2_outlined, color: c.green),
),
title: Text(l10n.addAsNote(text), style: const TextStyle(fontWeight: FontWeight.w800)),
onTap: () => _add(note: text),
),
for (final recipe in recipes)
ListTile(
leading: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 44,
height: 44,
child: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: provider.courseOf(recipe)?.emoji,
cacheWidth: 132,
),
),
),
title: Text(recipe.name),
subtitle: provider.courseOf(recipe) == null ? null : Text(provider.courseOf(recipe)!.name),
onTap: () => _add(recipeId: recipe.id),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../cooking_mode/cooking_mode_view.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/time_format.dart';
import '../../../recipes/views/recipe_detail_view.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../week_plan_provider.dart';
import 'add_entry_sheet.dart';
import 'plan_actions.dart';
/// Tapping a filled cell of the Week Plan grid: its Plan Entries with what can
/// be done to each, and "Weiteres hinzufügen".
Future<void> showCellSheet(BuildContext context, DateTime day, Meal meal) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _CellSheet(day: day, meal: meal, host: context),
);
class _CellSheet extends StatelessWidget {
const _CellSheet({required this.day, required this.meal, required this.host});
final DateTime day;
final Meal meal;
/// The Week Plan's context, which outlives this sheet.
final BuildContext host;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final entries = context.watch<WeekPlanProvider>().entriesFor(day, meal.id);
final recipes = context.watch<RecipeListProvider>();
final byId = {for (final r in recipes.allRecipes) r.id: r};
final navigator = Navigator.of(context);
if (entries.isEmpty) {
// The last entry was removed or moved away.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator.canPop()) navigator.pop();
});
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.addEntryTitle(meal.name, dayLabel(l10n, day, dayOf(DateTime.now()))),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 22, color: c.ink),
),
for (final entry in entries)
Builder(
builder: (context) {
final recipe = byId[entry.recipeId];
final name = recipe?.name ?? entry.note ?? '';
final details = [
if (recipe?.totalMinutes != null) formatMinutes(l10n, recipe!.totalMinutes!),
if (recipe != null && recipes.courseOf(recipe) != null) recipes.courseOf(recipe)!.name,
].join(' · ');
void close() => navigator.pop();
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 56,
height: 56,
child: recipe == null
? ColoredBox(
color: c.noteFill,
child: Icon(Icons.sticky_note_2_outlined, color: c.noteInk),
)
: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: recipes.courseOf(recipe)?.emoji,
cacheWidth: 168,
iconSize: 26,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 18,
color: c.ink,
),
),
if (details.isNotEmpty)
Text(
details,
style: TextStyle(fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 4,
children: [
if (recipe != null) ...[
TextButton.icon(
onPressed: () {
close();
navigator.pushNamed(RecipeDetailView.routeName, arguments: recipe.id);
},
icon: const Icon(Icons.menu_book_rounded),
label: Text(l10n.decisionOpen),
),
TextButton.icon(
onPressed: () {
close();
navigator.pushNamed(CookingModeView.routeName, arguments: recipe.id);
},
icon: const Icon(Icons.soup_kitchen_outlined),
label: Text(l10n.cookStart),
),
TextButton.icon(
onPressed: () => rerollEntry(context, entry, recipe),
icon: const Icon(Icons.casino_outlined),
label: Text(l10n.entryReroll),
),
],
if (recipe == null)
TextButton.icon(
onPressed: () => editNote(context, entry),
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.entryEdit),
),
TextButton.icon(
onPressed: () => moveEntryWithPicker(context, entry),
icon: const Icon(Icons.swap_vert_rounded),
label: Text(l10n.entryMove),
),
TextButton.icon(
// Closes the sheet first: a SnackBar's time only runs once nothing covers it.
onPressed: () {
close();
removeEntryWithUndo(host, entry, name);
},
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.entryRemove),
),
],
),
],
),
);
},
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () {
navigator.pop();
showAddEntrySheet(host, day, meal);
},
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addAnother),
),
],
),
),
);
}
}
@@ -0,0 +1,178 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/decision.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../day.dart';
import '../../plan_repository.dart';
import '../../week_fill.dart';
import '../../week_plan_provider.dart';
/// "Woche füllen": which Meal, which Courses, how much time – then random
/// Recipes for that Meal's empty days from today on.
Future<void> showFillWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _FillWeekSheet(weekStart: weekStart),
);
class _FillWeekSheet extends StatefulWidget {
const _FillWeekSheet({required this.weekStart});
final DateTime weekStart;
@override
State<_FillWeekSheet> createState() => _FillWeekSheetState();
}
class _FillWeekSheetState extends State<_FillWeekSheet> {
late String? _mealId = _defaultMeal();
late Set<String> _courseIds = _defaultCourses();
var _time = TimeLimit.any;
String? _defaultMeal() {
final meals = context.read<WeekPlanProvider>().meals;
for (final meal in meals) {
if (meal.name.toLowerCase() == 'abend') return meal.id;
}
return meals.isEmpty ? null : meals.last.id;
}
Set<String> _defaultCourses() => {
for (final course in context.read<RecipeListProvider>().courses)
if (course.name.toLowerCase() == 'hauptgericht') course.id,
};
Future<void> _fill() async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final plan = context.read<PlanRepository>();
final week = context.read<WeekPlanProvider>();
final recipes = context.read<RecipeListProvider>().allRecipes;
final mealId = _mealId!;
final today = dayOf(DateTime.now());
final weekEnd = addDays(widget.weekStart, 6);
final emptyDays = [
for (final day in weekDays(widget.weekStart))
if (!day.isBefore(today) && week.entriesFor(day, mealId).isEmpty) day,
];
final filled = fillWeek(
emptyDays: emptyDays,
candidates: DecisionChoice(courseIds: _courseIds, time: _time).matching(recipes),
inWeek: week.recipeIdsBetween(widget.weekStart, weekEnd),
recent: week.recipeIdsBetween(addDays(widget.weekStart, -7 * recentWeeks), addDays(widget.weekStart, -1)),
random: Random(),
);
Navigator.pop(context);
await plan.addEntries([
for (final MapEntry(key: day, value: recipe) in filled.entries)
(day: day, mealId: mealId, recipeId: recipe.id, note: null),
]);
final left = emptyDays.length - filled.length;
final message = emptyDays.isEmpty
? l10n.fillNothingEmpty
: [l10n.fillResult(filled.length), if (left > 0) l10n.fillLeftEmpty(left)].join(' ');
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final meals = context.watch<WeekPlanProvider>().meals;
final courses = context.watch<RecipeListProvider>().courses;
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 18, color: c.ink);
final times = {
TimeLimit.upTo30: l10n.decisionUpTo30,
TimeLimit.upTo60: l10n.decisionUpTo60,
TimeLimit.any: l10n.decisionAnyTime,
};
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.fillWeek,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 4),
Text(l10n.fillWeekHint, style: TextStyle(color: c.inkMuted)),
const SizedBox(height: 16),
Text(l10n.fillWhichMeal, style: heading),
const SizedBox(height: 8),
if (meals.isEmpty)
Text(l10n.noMeals, style: TextStyle(color: c.inkMuted))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final meal in meals)
ChoiceChip(
label: Text(meal.name),
selected: _mealId == meal.id,
onSelected: (_) => setState(() => _mealId = meal.id),
),
],
),
const SizedBox(height: 16),
Text(l10n.decisionCourseQuestion, style: heading),
Text(l10n.decisionCourseHint, style: TextStyle(color: c.inkMuted)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final course in courses)
FilterChip(
label: Text(course.name),
selected: _courseIds.contains(course.id),
onSelected: (on) => setState(() {
_courseIds = {..._courseIds};
on ? _courseIds.add(course.id) : _courseIds.remove(course.id);
}),
),
],
),
const SizedBox(height: 16),
Text(l10n.decisionTimeQuestion, style: heading),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final MapEntry(key: limit, value: label) in times.entries)
ChoiceChip(
label: Text(label),
selected: _time == limit,
onSelected: (_) => setState(() => _time = limit),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 52,
child: FilledButton.icon(
onPressed: _mealId == null ? null : _fill,
icon: const Icon(Icons.casino_outlined),
label: Text(l10n.fillAction),
),
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../settings/settings_controller.dart';
import '../../day.dart';
import '../../models/plan_entry.dart';
import '../../plan_repository.dart';
import '../../week_fill.dart';
import '../../week_plan_provider.dart';
import 'plan_slot_sheet.dart';
/// "Neu würfeln": another Recipe of the same Course, by the rules of "Woche füllen".
Future<void> rerollEntry(BuildContext context, PlanEntry entry, Recipe current) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final week = context.read<WeekPlanProvider>();
final recipes = context.read<RecipeListProvider>().allRecipes;
final plan = context.read<PlanRepository>();
final weekStart = weekStartOf(entry.day, context.read<SettingsController>().weekStart);
final replacement = pickReplacement(
candidates: [
for (final r in recipes)
if (current.courseId == null || r.courseId == current.courseId) r,
],
inWeek: week.recipeIdsBetween(weekStart, addDays(weekStart, 6)),
recent: week.recipeIdsBetween(addDays(weekStart, -7 * recentWeeks), addDays(weekStart, -1)),
random: Random(),
);
if (replacement == null) {
messenger.showSnackBar(SnackBar(content: Text(l10n.noOtherRecipe)));
return;
}
await plan.replaceRecipe(entry.id, replacement.id);
}
/// "Verschieben …": asks for a day and Meal.
Future<void> moveEntryWithPicker(BuildContext context, PlanEntry entry) async {
final l10n = AppLocalizations.of(context);
final plan = context.read<PlanRepository>();
final slot = await showPlanSlotSheet(
context,
action: l10n.moveEntryTitle,
initialDay: entry.day,
initialMealId: entry.mealId,
);
if (slot == null) return;
final (day, mealId) = slot;
await plan.moveEntry(entry.id, day, mealId);
}
/// Removes the entry, with "Rückgängig" in the SnackBar.
Future<void> removeEntryWithUndo(BuildContext context, PlanEntry entry, String name) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final plan = context.read<PlanRepository>();
await plan.removeEntry(entry.id);
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.entryRemoved(name)),
duration: const Duration(milliseconds: 1500),
action: SnackBarAction(
label: l10n.undo,
onPressed: () => plan.addEntry(entry.day, entry.mealId, recipeId: entry.recipeId, note: entry.note),
),
),
);
}
/// "Bearbeiten" on a note: a small dialog with its text.
Future<void> editNote(BuildContext context, PlanEntry entry) async {
final plan = context.read<PlanRepository>();
final text = await showDialog<String>(
context: context,
builder: (_) => _NoteDialog(note: entry.note ?? ''),
);
if (text != null && text.isNotEmpty) await plan.updateNote(entry.id, text);
}
class _NoteDialog extends StatefulWidget {
const _NoteDialog({required this.note});
final String note;
@override
State<_NoteDialog> createState() => _NoteDialogState();
}
class _NoteDialogState extends State<_NoteDialog> {
late final _text = TextEditingController(text: widget.note);
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _save() => Navigator.pop(context, _text.text.trim());
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.editNoteTitle),
content: TextField(
controller: _text,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
onSubmitted: (_) => _save(),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(l10n.cancel)),
FilledButton(onPressed: _save, child: Text(l10n.save)),
],
);
}
}
@@ -0,0 +1,390 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/text_breaks.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../models/meal.dart';
import '../../models/plan_entry.dart';
import '../../plan_repository.dart';
import '../../week_plan_provider.dart';
import 'add_entry_sheet.dart';
import 'cell_sheet.dart';
/// One week of the Week Plan as a grid: a row per day, a column per Meal.
/// Entries can be long-pressed and dragged onto another cell (they join what
/// is there). Up to two Meals the cells show the Course's icon; from four on
/// the Meal columns scroll sideways.
class PlanGrid extends StatelessWidget {
const PlanGrid({super.key, required this.weekStart, required this.today, required this.onDragStarted});
final DateTime weekStart;
final DateTime today;
final VoidCallback onDragStarted;
static const _rowHeight = 60.0;
/// Several entries in one cell: one slim line each, and that day's row grows.
static const compactHeight = 30.0;
static const compactGap = 4.0;
static const _headerHeight = 26.0;
static const _dayWidth = 46.0;
static const _scrollingColumnWidth = 112.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final plan = context.watch<WeekPlanProvider>();
final days = weekDays(weekStart);
final meals = plan.mealsDuring(days);
final showIcons = meals.length <= 2;
final scrolls = meals.length >= 4;
if (meals.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Text(
l10n.noMeals,
textAlign: TextAlign.center,
style: TextStyle(color: c.inkMuted),
),
);
}
Color? tintOf(DateTime day) => day == today
? c.tiles[0]
: day.weekday >= DateTime.saturday
? c.weekendTint
: null;
double heightOf(DateTime day) {
final most = meals.fold(0, (n, meal) => max(n, plan.entriesFor(day, meal.id).length));
return most <= 1 ? _rowHeight : most * (compactHeight + compactGap);
}
Widget row(DateTime day, Widget child, {required bool start}) => Opacity(
opacity: day.isBefore(today) ? 0.55 : 1,
child: Container(
height: heightOf(day),
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: tintOf(day),
borderRadius: start
? const BorderRadius.horizontal(left: Radius.circular(16))
: const BorderRadius.horizontal(right: Radius.circular(16)),
),
child: child,
),
);
final mealColumns = Column(
children: [
SizedBox(
height: _headerHeight,
child: Row(
children: [
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Text(
meal.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.inkMuted),
),
),
),
],
),
),
for (final day in days)
row(
day,
start: false,
Row(
children: [
for (final meal in meals)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2.5),
child: _PlanCell(
key: ValueKey('${dayKey(day)}-${meal.id}'),
day: day,
meal: meal,
entries: plan.entriesFor(day, meal.id),
showIcon: showIcons,
onDragStarted: onDragStarted,
),
),
),
],
),
),
],
);
return Container(
padding: const EdgeInsets.fromLTRB(6, 6, 6, 8),
decoration: BoxDecoration(
color: c.surface,
borderRadius: BorderRadius.circular(22),
border: c.cardBorder == null ? null : Border.all(color: c.cardBorder!),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _dayWidth,
child: Column(
children: [
const SizedBox(height: _headerHeight),
for (final day in days) row(day, start: true, _DayLabel(day: day, isToday: day == today)),
],
),
),
Expanded(
child: scrolls
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(width: meals.length * _scrollingColumnWidth, child: mealColumns),
)
: mealColumns,
),
],
),
);
}
}
class _DayLabel extends StatelessWidget {
const _DayLabel({required this.day, required this.isToday});
final DateTime day;
final bool isToday;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
return Center(
child: Container(
width: 40,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: isToday ? BoxDecoration(color: c.sage, borderRadius: BorderRadius.circular(12)) : null,
// Scales down rather than wrapping with large system fonts.
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
weekdayShort(day).replaceAll('.', ''),
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 15, color: c.ink),
),
Text(
'${day.day}.${day.month}.',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: c.inkMuted),
),
],
),
),
),
);
}
}
class _PlanCell extends StatelessWidget {
const _PlanCell({
super.key,
required this.day,
required this.meal,
required this.entries,
required this.showIcon,
required this.onDragStarted,
});
final DateTime day;
final Meal meal;
final List<PlanEntry> entries;
final bool showIcon;
final VoidCallback onDragStarted;
/// One entry of the cell: tap for the cell's sheet, long-press to drag it elsewhere.
Widget _draggable(BuildContext context, PlanEntry entry, {required bool compact}) {
final c = CookItColors.of(context);
final radius = BorderRadius.circular(compact ? 10 : 14);
// Fills its slot: the whole cell, or one slim line of it.
final tile = SizedBox.expand(
child: _EntryTile(entry: entry, showIcon: showIcon, compact: compact),
);
return LayoutBuilder(
builder: (context, constraints) => LongPressDraggable<PlanEntry>(
data: entry,
onDragStarted: onDragStarted,
feedback: Material(
color: Colors.transparent,
child: Transform.rotate(
angle: -0.06,
child: Container(
width: constraints.maxWidth,
height: constraints.maxHeight,
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 18, offset: const Offset(0, 8)),
],
),
child: tile,
),
),
),
childWhenDragging: DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.brand, width: 2),
),
),
child: Material(
color: Colors.transparent,
child: InkWell(borderRadius: radius, onTap: () => showCellSheet(context, day, meal), child: tile),
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final radius = BorderRadius.circular(14);
return DragTarget<PlanEntry>(
onWillAcceptWithDetails: (d) => !meal.retired && !(d.data.day == day && d.data.mealId == meal.id),
onAcceptWithDetails: (d) => context.read<PlanRepository>().moveEntry(d.data.id, day, meal.id),
builder: (context, candidates, _) {
final Widget content;
if (entries.isEmpty) {
content = Tooltip(
message: l10n.addEntry,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: radius,
onTap: meal.retired ? null : () => showAddEntrySheet(context, day, meal),
child: Ink(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.navBar, width: 2),
),
child: meal.retired ? null : Center(child: Icon(Icons.add_rounded, size: 18, color: c.green)),
),
),
),
);
} else if (entries.length == 1) {
content = _draggable(context, entries.single, compact: false);
} else {
content = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, entry) in entries.indexed) ...[
if (i > 0) const SizedBox(height: PlanGrid.compactGap),
SizedBox(height: PlanGrid.compactHeight, child: _draggable(context, entry, compact: true)),
],
],
);
}
if (candidates.isEmpty) return content;
return Stack(
fit: StackFit.expand,
children: [
content,
DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: c.brand, width: 3),
color: c.lime.withValues(alpha: 0.45),
),
child: entries.isEmpty
? null
: Align(
alignment: Alignment.bottomCenter,
child: Text(
l10n.dropAdd,
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w900, color: CookItColors.light.onBrand),
),
),
),
],
);
},
);
}
}
/// One Plan Entry in a cell: a Recipe with its Course's icon, or a note with
/// the note icon on its warm ground – in the same type, since a note is just
/// as settled as a Recipe. [compact] is the slim one-line form for a cell
/// holding several entries.
class _EntryTile extends StatelessWidget {
const _EntryTile({required this.entry, required this.showIcon, required this.compact});
final PlanEntry entry;
final bool showIcon;
final bool compact;
@override
Widget build(BuildContext context) {
final c = CookItColors.of(context);
final recipes = context.watch<RecipeListProvider>();
final Recipe? recipe = entry.recipeId == null
? null
: recipes.allRecipes.where((r) => r.id == entry.recipeId).firstOrNull;
final note = recipe == null;
final emoji = recipe == null ? null : recipes.courseOf(recipe)?.emoji;
final iconSize = compact ? 20.0 : 26.0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: note ? c.noteFill : c.planCell,
borderRadius: BorderRadius.circular(compact ? 10 : 14),
border: note ? Border.all(color: c.noteBorder, width: 1.5) : null,
),
child: Row(
children: [
if (note)
Padding(
padding: const EdgeInsets.only(right: 5),
child: Icon(Icons.sticky_note_2_outlined, size: compact ? 14 : 16, color: c.noteInk),
)
else if (showIcon)
Container(
width: iconSize,
height: iconSize,
margin: const EdgeInsets.only(right: 6),
alignment: Alignment.center,
decoration: BoxDecoration(color: c.surface, borderRadius: BorderRadius.circular(compact ? 6 : 8)),
child: Text(emoji ?? '🍽️', style: TextStyle(fontSize: compact ? 12 : 15)),
),
Expanded(
child: Text(
withNaturalBreaks(recipe?.name ?? entry.note ?? ''),
maxLines: compact ? 1 : (showIcon ? 2 : 3),
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, height: 1.2, fontWeight: FontWeight.w800, color: c.ink),
),
),
],
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../week_plan_provider.dart';
/// Asks for a day and a Meal: for "Einplanen" on a Recipe and for "Verschieben"
/// of a Plan Entry. Returns null when the cook backs out.
Future<(DateTime, String)?> showPlanSlotSheet(
BuildContext context, {
required String action,
DateTime? initialDay,
String? initialMealId,
}) => showModalBottomSheet<(DateTime, String)>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _PlanSlotSheet(action: action, initialDay: initialDay, initialMealId: initialMealId),
);
class _PlanSlotSheet extends StatefulWidget {
const _PlanSlotSheet({required this.action, this.initialDay, this.initialMealId});
final String action;
final DateTime? initialDay;
final String? initialMealId;
@override
State<_PlanSlotSheet> createState() => _PlanSlotSheetState();
}
class _PlanSlotSheetState extends State<_PlanSlotSheet> {
static const _quickDays = 14;
final _today = dayOf(DateTime.now());
late DateTime _day = widget.initialDay ?? _today;
late String? _mealId = widget.initialMealId;
List<DateTime> get _days => [for (var i = 0; i < _quickDays; i++) addDays(_today, i)];
Future<void> _pickOtherDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _day,
firstDate: addDays(_today, -365),
lastDate: addDays(_today, 2 * 365),
);
if (picked != null) setState(() => _day = dayOf(picked));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final meals = context.watch<WeekPlanProvider>().meals;
final mealId = _mealId ?? (meals.isEmpty ? null : meals.last.id);
final heading = TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 20, color: c.ink);
final otherDay = !_days.contains(_day);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.planWhichDay, style: heading),
const SizedBox(height: 10),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final day in _days)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(dayLabel(l10n, day, _today)),
selected: _day == day,
onSelected: (_) => setState(() => _day = day),
),
),
ChoiceChip(
avatar: const Icon(Icons.calendar_month_rounded, size: 18),
label: Text(otherDay ? dayLabel(l10n, _day, _today) : l10n.planOtherDate),
selected: otherDay,
onSelected: (_) => _pickOtherDate(),
),
],
),
),
const SizedBox(height: 20),
Text(l10n.planWhichMeal, style: heading),
const SizedBox(height: 10),
if (meals.isEmpty)
Text(l10n.noMeals, style: TextStyle(color: c.inkMuted))
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final meal in meals)
ChoiceChip(
label: Text(meal.name),
selected: mealId == meal.id,
onSelected: (_) => setState(() => _mealId = meal.id),
),
],
),
const SizedBox(height: 20),
FilledButton(
onPressed: mealId == null ? null : () => Navigator.pop(context, (_day, mealId)),
child: Text(widget.action),
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/ingredients_text.dart';
import '../../../recipes/models/recipe.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/unit_catalog.dart';
import '../../../recipes/views/widgets/share_or_copy_buttons.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../ingredient_totals.dart';
import '../../week_plan_provider.dart';
/// "Zutaten der Woche teilen": the added-up ingredients of the Recipes planned
/// on the chosen days, handed to Android's share menu (e.g. to Bring!).
Future<void> showShareWeekSheet(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => _ShareWeekSheet(weekStart: weekStart),
);
class _ShareWeekSheet extends StatefulWidget {
const _ShareWeekSheet({required this.weekStart});
final DateTime weekStart;
@override
State<_ShareWeekSheet> createState() => _ShareWeekSheetState();
}
class _ShareWeekSheetState extends State<_ShareWeekSheet> {
final _today = dayOf(DateTime.now());
/// Days with a Recipe planned, from today on.
late final Set<DateTime> _days = {
for (final day in weekDays(widget.weekStart))
if (!day.isBefore(_today) && _recipesOn(day).isNotEmpty) day,
};
List<Recipe> _recipesOn(DateTime day) {
final byId = {for (final r in context.read<RecipeListProvider>().allRecipes) r.id: r};
return [
for (final entry in context.read<WeekPlanProvider>().entriesOn(day))
if (byId[entry.recipeId] case final recipe?) recipe,
];
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final units = context.watch<UnitCatalog>();
context.watch<WeekPlanProvider>();
final chosen = weekDays(widget.weekStart).where(_days.contains).toList();
final lines = ingredientTotals(chosen.expand(_recipesOn));
final text = ingredientsAsText([IngredientGroup(lines: lines)], (key) => units.label(l10n, key));
final subject = chosen.isEmpty ? '' : l10n.shareWeekSubject(dayRange(chosen.first, chosen.last));
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.shareWeek,
style: TextStyle(fontFamily: 'Nunito', fontWeight: FontWeight.w900, fontSize: 24, color: c.ink),
),
const SizedBox(height: 4),
Text(l10n.shareWeekHint, style: TextStyle(fontSize: 14, color: c.inkMuted)),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final day in weekDays(widget.weekStart))
FilterChip(
label: Text('${weekdayShort(day)} ${day.day}.'),
selected: _days.contains(day),
onSelected: _recipesOn(day).isEmpty
? null
: (on) => setState(() => on ? _days.add(day) : _days.remove(day)),
),
],
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.35),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: c.background, borderRadius: BorderRadius.circular(16)),
child: SingleChildScrollView(
child: Text(
lines.isEmpty ? l10n.shareWeekEmpty : text,
style: TextStyle(fontSize: 15, height: 1.45, color: lines.isEmpty ? c.inkMuted : c.ink),
),
),
),
),
const SizedBox(height: 16),
ShareOrCopyButtons(text: lines.isEmpty ? null : text, subject: subject),
],
),
),
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../../../cooking_mode/cooking_mode_view.dart';
import '../../../design/cookit_colors.dart';
import '../../../localization/app_localizations.dart';
import '../../../recipes/recipe_list_provider.dart';
import '../../../recipes/views/recipe_detail_view.dart';
import '../../../recipes/views/widgets/recipe_photo.dart';
import '../../day.dart';
import '../../week_plan_provider.dart';
/// Above the Week Plan grid: everything planned for today, one line per entry,
/// with the Recipe's photo and "Kochen". Nothing when today is empty.
class TodayBanner extends StatelessWidget {
const TodayBanner({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final c = CookItColors.of(context);
final plan = context.watch<WeekPlanProvider>();
final recipes = context.watch<RecipeListProvider>();
final now = DateTime.now();
final entries = plan.entriesToday(now);
if (entries.isEmpty) return const SizedBox.shrink();
final byId = {for (final r in recipes.allRecipes) r.id: r};
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
decoration: BoxDecoration(color: c.sage, borderRadius: BorderRadius.circular(22)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.todayBanner(DateFormat('EEEE', 'de').format(dayOf(now))).toUpperCase(),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.7, color: c.ink),
),
for (final entry in entries)
Builder(
builder: (context) {
final recipe = byId[entry.recipeId];
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: recipe == null
? null
: () => Navigator.pushNamed(context, RecipeDetailView.routeName, arguments: recipe.id),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 44,
height: 44,
child: recipe == null
? ColoredBox(
color: c.noteFill,
child: Icon(Icons.sticky_note_2_outlined, color: c.noteInk, size: 22),
)
: RecipePhoto(
photo: recipe.mainPhoto,
seed: recipe.id,
placeholderEmoji: recipes.courseOf(recipe)?.emoji,
cacheWidth: 132,
iconSize: 22,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
plan.mealById(entry.mealId)?.name ?? '',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: c.ink),
),
Text(
recipe?.name ?? entry.note ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Nunito',
fontWeight: FontWeight.w900,
fontSize: 16,
color: c.ink,
),
),
],
),
),
if (recipe != null)
FilledButton(
onPressed: () =>
Navigator.pushNamed(context, CookingModeView.routeName, arguments: recipe.id),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: CookItColors.light.green,
visualDensity: VisualDensity.compact,
),
child: Text(l10n.cook),
),
],
),
),
);
},
),
],
),
);
}
}
@@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../../localization/app_localizations.dart';
import '../../day.dart';
import '../../day_format.dart';
import '../../plan_share_service.dart';
import 'share_week_sheet.dart';
/// "Teilen" in the week bar: the week as a file for another CookIt, or its
/// added-up ingredients as text.
Future<void> showWeekShareMenu(BuildContext context, DateTime weekStart) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final l10n = AppLocalizations.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.calendar_month_rounded),
title: Text(l10n.shareWeekPlan),
subtitle: Text(l10n.shareWeekPlanHint),
onTap: () {
Navigator.pop(sheetContext);
_shareWeekFile(context, weekStart);
},
),
ListTile(
leading: const Icon(Icons.shopping_basket_outlined),
title: Text(l10n.shareWeek),
subtitle: Text(l10n.shareWeekIngredientsHint),
onTap: () {
Navigator.pop(sheetContext);
showShareWeekSheet(context, weekStart);
},
),
],
),
),
);
},
);
Future<void> _shareWeekFile(BuildContext context, DateTime weekStart) async {
final l10n = AppLocalizations.of(context);
final bytes = await context.read<PlanShareService>().weekFile(weekStart);
final range = dayRange(weekStart, addDays(weekStart, 6)).replaceAll(RegExp(r'\.$'), '');
final file = File('${(await getTemporaryDirectory()).path}/${l10n.weekPlanFileName(range)}.cookit');
await file.writeAsBytes(bytes);
await SharePlus.instance.share(ShareParams(files: [XFile(file.path, mimeType: 'application/zip')]));
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:math';
import '../recipes/models/recipe.dart';
/// How far back "planned recently" reaches, in weeks.
const recentWeeks = 4;
/// "Woche füllen": one Recipe for each of [emptyDays], drawn from [candidates].
/// Never repeats a Recipe already in the week ([inWeek]) or within the fill, and
/// takes Recipes planned recently ([recent]) only once the others run out. Days
/// left over when the candidates run out stay empty.
Map<DateTime, Recipe> fillWeek({
required List<DateTime> emptyDays,
required List<Recipe> candidates,
required Set<String> inWeek,
required Set<String> recent,
required Random random,
}) {
final pool = _preferFresh(candidates.where((r) => !inWeek.contains(r.id)), recent, random);
return {
for (final (i, day) in emptyDays.indexed)
if (i < pool.length) day: pool[i],
};
}
/// "Tauschen": another Recipe for one Plan Entry, by the same rules as [fillWeek].
Recipe? pickReplacement({
required List<Recipe> candidates,
required Set<String> inWeek,
required Set<String> recent,
required Random random,
}) {
final pool = _preferFresh(candidates.where((r) => !inWeek.contains(r.id)), recent, random);
return pool.isEmpty ? null : pool.first;
}
List<Recipe> _preferFresh(Iterable<Recipe> recipes, Set<String> recent, Random random) {
final fresh = recipes.where((r) => !recent.contains(r.id)).toList()..shuffle(random);
final again = recipes.where((r) => recent.contains(r.id)).toList()..shuffle(random);
return [...fresh, ...again];
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'day.dart';
import 'models/meal.dart';
import 'models/plan_entry.dart';
import 'plan_repository.dart';
/// The Week Plan as the screens see it: all Meals and Plan Entries, kept
/// current by the database's watch streams.
class WeekPlanProvider extends ChangeNotifier {
WeekPlanProvider(PlanRepository plan) {
_subscriptions = [
plan.watchMeals().listen((value) {
_meals = value;
notifyListeners();
}),
plan.watchEntries().listen((value) {
_byDay = {};
for (final entry in value) {
(_byDay[entry.day] ??= []).add(entry);
}
notifyListeners();
}),
];
}
late final List<StreamSubscription<Object?>> _subscriptions;
List<Meal> _meals = const [];
Map<DateTime, List<PlanEntry>> _byDay = {};
/// The Meals the cook plans for now, in order.
List<Meal> get meals => _meals.where((m) => !m.retired).toList();
Meal? mealById(String id) {
for (final meal in _meals) {
if (meal.id == id) return meal;
}
return null;
}
List<PlanEntry> entriesOn(DateTime day) => _byDay[day] ?? const [];
/// The Meals shown for [day]: the current ones, plus retired ones its entries still use.
List<Meal> mealsOn(DateTime day) {
final used = {for (final e in entriesOn(day)) e.mealId};
return _meals.where((m) => !m.retired || used.contains(m.id)).toList();
}
/// The grid columns for [days]: the current Meals, plus retired ones their entries still use.
List<Meal> mealsDuring(Iterable<DateTime> days) {
final used = {
for (final day in days)
for (final e in entriesOn(day)) e.mealId,
};
return _meals.where((m) => !m.retired || used.contains(m.id)).toList();
}
List<PlanEntry> entriesFor(DateTime day, String mealId) => entriesOn(day).where((e) => e.mealId == mealId).toList();
/// Recipes planned on any day from [from] up to and including [to].
Set<String> recipeIdsBetween(DateTime from, DateTime to) => {
for (final MapEntry(key: day, value: entries) in _byDay.entries)
if (!day.isBefore(from) && !day.isAfter(to))
for (final e in entries)
if (e.recipeId != null) e.recipeId!,
};
/// Today's entries in Meal order, for the home screen.
List<PlanEntry> entriesToday(DateTime now) {
final today = dayOf(now);
final order = {for (final (i, m) in _meals.indexed) m.id: i};
return [...entriesOn(today)]..sort((a, b) {
final byMeal = (order[a.mealId] ?? 0).compareTo(order[b.mealId] ?? 0);
return byMeal != 0 ? byMeal : a.position.compareTo(b.position);
});
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:intl/intl.dart';
/// Amount and optional upper end of a range, e.g. "2-3" -> (2, 3).
typedef Amount = ({double? amount, double? amountMax});
const _fractionGlyphs = {'½': 0.5, '¼': 0.25, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3, '⅛': 0.125};
/// Reads what the cook typed into an amount field: "2", "1,5", "½", "1 1/2", "2-3".
/// Returns null when the text isn't an amount; an empty text is "no amount".
Amount? parseAmount(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return (amount: null, amountMax: null);
final parts = trimmed.split(RegExp(r'\s*(?:-|–|bis)\s*'));
if (parts.length == 2) {
final low = _single(parts[0]);
final high = _single(parts[1]);
if (low != null && high != null) return (amount: low, amountMax: high);
return null;
}
final single = _single(trimmed);
return single == null ? null : (amount: single, amountMax: null);
}
double? _single(String text) {
final t = text.trim();
if (_fractionGlyphs.containsKey(t)) return _fractionGlyphs[t];
var m = RegExp(r'^(\d+)\s*([½¼¾⅓⅔⅛])$').firstMatch(t);
if (m != null) return int.parse(m[1]!) + _fractionGlyphs[m[2]!]!;
m = RegExp(r'^(?:(\d+)\s+)?(\d+)\s*/\s*(\d+)$').firstMatch(t);
if (m != null && int.parse(m[3]!) != 0) {
return int.parse(m[1] ?? '0') + int.parse(m[2]!) / int.parse(m[3]!);
}
if (RegExp(r'^\d+(?:[.,]\d+)?$').hasMatch(t)) {
return double.parse(t.replaceAll(',', '.'));
}
return null;
}
final _decimal = NumberFormat('#,##0.##', 'de');
/// German display of a number, using fraction glyphs for common fractions: 1.5 -> "1½".
String formatNumber(double value) {
final whole = value.floor();
final fraction = value - whole;
for (final MapEntry(key: glyph, value: v) in _fractionGlyphs.entries) {
if ((fraction - v).abs() < 0.01) return whole == 0 ? glyph : '$whole$glyph';
}
return _decimal.format(value);
}
String formatAmount(double? amount, double? amountMax) {
if (amount == null) return '';
if (amountMax == null) return formatNumber(amount);
return '${formatNumber(amount)}–${formatNumber(amountMax)}';
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../database/database.dart';
import 'models/course.dart';
class CourseRepository {
CourseRepository(this._db);
final AppDatabase _db;
Stream<List<Course>> watchCourses() {
final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]);
return query.watch().map((rows) => rows.map(_toCourse).toList());
}
Future<List<Course>> allCourses() async {
final query = _db.select(_db.courses)..orderBy([(c) => OrderingTerm.asc(c.position)]);
return (await query.get()).map(_toCourse).toList();
}
Future<Course> addCourse(String name, {String? emoji}) async {
final position = (await allCourses()).length;
final course = Course(id: const Uuid().v4(), name: name, emoji: emoji, position: position);
await _db
.into(_db.courses)
.insert(
CoursesCompanion.insert(
id: course.id,
name: course.name,
emoji: Value(course.emoji),
position: course.position,
),
);
return course;
}
/// The Course with this name, created at the end of the list if it doesn't exist yet.
Future<Course> findOrCreate(String name) async {
for (final course in await allCourses()) {
if (course.name.toLowerCase() == name.toLowerCase()) return course;
}
return addCourse(name);
}
Future<void> updateCourse(String id, {required String name, String? emoji}) => (_db.update(
_db.courses,
)..where((c) => c.id.equals(id))).write(CoursesCompanion(name: Value(name), emoji: Value(emoji)));
Future<void> reorder(List<Course> ordered) => _db.batch((b) {
for (final (i, course) in ordered.indexed) {
b.update(_db.courses, CoursesCompanion(position: Value(i)), where: (c) => c.id.equals(course.id));
}
});
/// Recipes of a deleted Course keep existing without one.
Future<void> deleteCourse(String id) => (_db.delete(_db.courses)..where((c) => c.id.equals(id))).go();
Future<int> recipeCount(String courseId) async {
final count = _db.recipes.id.count();
final query = _db.selectOnly(_db.recipes)
..addColumns([count])
..where(_db.recipes.courseId.equals(courseId));
return (await query.getSingle()).read(count) ?? 0;
}
static Course _toCourse(CourseRow row) =>
Course(id: row.id, name: row.name, emoji: row.emoji, position: row.position);
}
+36
View File
@@ -0,0 +1,36 @@
import 'models/recipe.dart';
/// "Wie viel Zeit hast du?" in the decision helper.
enum TimeLimit {
upTo30(30),
upTo60(60),
any(null);
const TimeLimit(this.minutes);
final int? minutes;
}
/// The cook's answers in the decision helper ("Hilf mir entscheiden").
class DecisionChoice {
const DecisionChoice({this.courseIds = const {}, this.time = TimeLimit.any});
/// Courses the cook fancies; empty means any.
final Set<String> courseIds;
final TimeLimit time;
/// Recipes without a recorded time only match when time doesn't matter.
bool matches(Recipe recipe) {
if (courseIds.isNotEmpty && !courseIds.contains(recipe.courseId)) return false;
final limit = time.minutes;
if (limit == null) return true;
final total = recipe.totalMinutes;
return total != null && total <= limit;
}
List<Recipe> matching(Iterable<Recipe> recipes) => recipes.where(matches).toList();
DecisionChoice withoutTimeLimit() => DecisionChoice(courseIds: courseIds);
DecisionChoice withAllCourses() => DecisionChoice(time: time);
}
+19
View File
@@ -0,0 +1,19 @@
import 'amount.dart';
import 'models/recipe.dart';
/// Ingredient Groups as plain text, one Ingredient Line per line (e.g.
/// "250 g Mehl"), so a shopping list app can take each line as one item.
/// Groups are separated by an empty line; their names are left out because
/// they would turn into items of their own.
String ingredientsAsText(List<IngredientGroup> groups, String Function(String unitKey) unitLabel) => [
for (final group in groups)
[
for (final line in group.lines)
[
formatAmount(line.amount, line.amountMax),
if (line.unitKey != null) unitLabel(line.unitKey!),
line.name,
if (line.remark != null) '(${line.remark})',
].where((part) => part.isNotEmpty).join(' '),
].join('\n'),
].where((group) => group.isNotEmpty).join('\n\n');
+29
View File
@@ -0,0 +1,29 @@
/// The single kind of dish a Recipe is, such as Hauptgericht or Dessert.
class Course {
const Course({required this.id, required this.name, this.emoji, required this.position});
final String id;
final String name;
final String? emoji;
final int position;
String get label => emoji == null ? name : '$emoji $name';
Course copyWith({String? name, String? emoji, int? position}) =>
Course(id: id, name: name ?? this.name, emoji: emoji ?? this.emoji, position: position ?? this.position);
}
/// The Courses a fresh install starts with (taken from the original workbook).
const defaultCourses = <(String, String)>[
('Hauptgericht', '🍝'),
('Auflauf', '🥘'),
('Beilage', '🥔'),
('Salat', '🥗'),
('Vorspeise', '🍤'),
('Snacks/ Fingerfood', '🍢'),
('Sandwich', '🥪'),
('Brot', '🥖'),
('Dip', '🥣'),
('Dessert', '🍰'),
('Getränk', '🥤'),
];
+192
View File
@@ -0,0 +1,192 @@
import 'unit.dart';
/// A dish the cook can prepare. See GLOSSARY.md for the vocabulary.
class Recipe {
const Recipe({
required this.id,
required this.name,
this.courseId,
this.tags = const [],
this.servings,
this.prepMinutes,
this.cookMinutes,
this.calories,
this.ingredientGroups = const [],
this.stepSections = const [],
this.photos = const [],
this.sources = const [],
this.notes,
this.favourite = false,
this.reviewReasons = const [],
required this.createdAt,
required this.updatedAt,
});
final String id;
final String name;
final String? courseId;
final List<String> tags;
final double? servings;
final int? prepMinutes;
final int? cookMinutes;
final Calories? calories;
final List<IngredientGroup> ingredientGroups;
final List<StepSection> stepSections;
/// Photo file names in display order; the first is the main Photo.
final List<String> photos;
final List<String> sources;
final String? notes;
final bool favourite;
/// Why the cook should check this Recipe. Empty means no Review Flag.
final List<String> reviewReasons;
final DateTime createdAt;
final DateTime updatedAt;
bool get needsReview => reviewReasons.isNotEmpty;
String? get mainPhoto => photos.isEmpty ? null : photos.first;
int? get totalMinutes {
if (prepMinutes == null && cookMinutes == null) return null;
return (prepMinutes ?? 0) + (cookMinutes ?? 0);
}
Iterable<IngredientLine> get ingredientLines => ingredientGroups.expand((g) => g.lines);
Recipe copyWith({
String? name,
String? Function()? courseId,
List<String>? tags,
double? Function()? servings,
int? Function()? prepMinutes,
int? Function()? cookMinutes,
Calories? Function()? calories,
List<IngredientGroup>? ingredientGroups,
List<StepSection>? stepSections,
List<String>? photos,
List<String>? sources,
String? Function()? notes,
bool? favourite,
List<String>? reviewReasons,
DateTime? updatedAt,
}) {
return Recipe(
id: id,
name: name ?? this.name,
courseId: courseId != null ? courseId() : this.courseId,
tags: tags ?? this.tags,
servings: servings != null ? servings() : this.servings,
prepMinutes: prepMinutes != null ? prepMinutes() : this.prepMinutes,
cookMinutes: cookMinutes != null ? cookMinutes() : this.cookMinutes,
calories: calories != null ? calories() : this.calories,
ingredientGroups: ingredientGroups ?? this.ingredientGroups,
stepSections: stepSections ?? this.stepSections,
photos: photos ?? this.photos,
sources: sources ?? this.sources,
notes: notes != null ? notes() : this.notes,
favourite: favourite ?? this.favourite,
reviewReasons: reviewReasons ?? this.reviewReasons,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
enum CaloriesBasis {
perServing('perServing'),
wholeRecipe('wholeRecipe');
const CaloriesBasis(this.key);
final String key;
static CaloriesBasis fromKey(String key) => values.firstWhere((b) => b.key == key, orElse: () => perServing);
}
class Calories {
const Calories(this.kcal, this.basis);
final int kcal;
final CaloriesBasis basis;
Map<String, dynamic> toJson() => {'kcal': kcal, 'basis': basis.key};
static Calories fromJson(Map<String, dynamic> json) =>
Calories((json['kcal'] as num).round(), CaloriesBasis.fromKey(json['basis'] as String));
}
/// A subset of Ingredient Lines that belong together. [name] null = an unnamed break.
class IngredientGroup {
const IngredientGroup({this.name, required this.lines});
final String? name;
final List<IngredientLine> lines;
Map<String, dynamic> toJson() => {
'name': name,
'lines': [for (final l in lines) l.toJson()],
};
static IngredientGroup fromJson(Map<String, dynamic> json) => IngredientGroup(
name: json['name'] as String?,
lines: [for (final l in json['lines'] as List) IngredientLine.fromJson(l as Map<String, dynamic>)],
);
}
class IngredientLine {
const IngredientLine({this.amount, this.amountMax, this.unitKey, required this.name, this.remark});
final double? amount;
/// Upper end of a range such as "2–3".
final double? amountMax;
/// Key of a built-in [Unit] or of one of the cook's own units.
final String? unitKey;
final String name;
final String? remark;
Map<String, dynamic> toJson() => {
'amount': amount,
'amountMax': amountMax,
'unit': unitKey,
'name': name,
'remark': remark,
};
static IngredientLine fromJson(Map<String, dynamic> json) {
return IngredientLine(
amount: (json['amount'] as num?)?.toDouble(),
amountMax: (json['amountMax'] as num?)?.toDouble(),
unitKey: json['unit'] as String?,
name: json['name'] as String,
remark: json['remark'] as String?,
);
}
/// This line for [factor] times the servings ("Kochmodus" scaling).
IngredientLine scaled(double factor) => factor == 1
? this
: IngredientLine(
amount: amount == null ? null : amount! * factor,
amountMax: amountMax == null ? null : amountMax! * factor,
unitKey: unitKey,
name: name,
remark: remark,
);
}
/// Consecutive Steps under an optional heading such as "Vorteig".
class StepSection {
const StepSection({this.title, required this.steps});
final String? title;
final List<String> steps;
Map<String, dynamic> toJson() => {'title': title, 'steps': steps};
static StepSection fromJson(Map<String, dynamic> json) =>
StepSection(title: json['title'] as String?, steps: [for (final s in json['steps'] as List) s as String]);
}
+48
View File
@@ -0,0 +1,48 @@
/// The built-in measures an Ingredient Line's amount can be given in. The cook
/// can add their own next to these (see `UnitDefinition`).
///
/// [key] is what the database and Recipe Bundles store; labels live in the
/// localization files. [common] units start out offered directly in the picker,
/// the rest under "Weitere Einheiten" — the cook can change that in the settings.
enum Unit {
gram('gram', common: true),
kilogram('kilogram', common: true),
milliliter('milliliter', common: true),
liter('liter', common: true),
tablespoon('tablespoon', common: true),
teaspoon('teaspoon', common: true),
pinch('pinch', common: true),
knifeTip('knifeTip', common: true),
pack('pack', common: true),
piece('piece'),
slice('slice'),
clove('clove'),
sprig('sprig'),
bunch('bunch'),
handful('handful'),
cup('cup'),
can('can'),
jar('jar'),
tub('tub'),
bag('bag'),
carton('carton'),
head('head'),
grain('grain'),
small('small'),
medium('medium'),
large('large'),
some('some');
const Unit(this.key, {this.common = false});
final String key;
final bool common;
static Unit? fromKey(String? key) {
if (key == null) return null;
for (final unit in values) {
if (unit.key == key) return unit;
}
return null;
}
}
@@ -0,0 +1,26 @@
import 'unit.dart';
/// A Unit as the cook's list knows it: a built-in [Unit] or one of their own,
/// shown directly in the picker or under "Weitere Einheiten", in their order.
class UnitDefinition {
const UnitDefinition({
required this.key,
this.label,
required this.builtIn,
required this.direct,
required this.position,
});
/// Prefix of the keys of the cook's own units.
static const ownPrefix = 'own-';
final String key;
/// The name of one of the cook's own units; null for built-in ones (their names are localized).
final String? label;
final bool builtIn;
final bool direct;
final int position;
Unit? get builtInUnit => Unit.fromKey(key);
}
+44
View File
@@ -0,0 +1,44 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Keeps Photo files in the app's private storage. Recipes reference them by file name.
class PhotoStore {
PhotoStore(this.directory);
final Directory directory;
static Future<PhotoStore> open() async {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory(p.join(docs.path, 'photos'));
await dir.create(recursive: true);
return PhotoStore(dir);
}
File file(String name) => File(p.join(directory.path, name));
Future<Uint8List> read(String name) => file(name).readAsBytes();
Future<String> saveBytes(Uint8List bytes) async {
final name = '${const Uuid().v4()}.jpg';
await file(name).writeAsBytes(bytes, flush: true);
return name;
}
/// Copies a picked image (already downscaled by the picker) into the store.
Future<String> importFile(String path) async {
final name = '${const Uuid().v4()}${p.extension(path).toLowerCase()}';
await File(path).copy(file(name).path);
return name;
}
Future<void> delete(Iterable<String> names) async {
for (final name in names) {
final f = file(name);
if (await f.exists()) await f.delete();
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/widgets.dart';
import 'package:uuid/uuid.dart';
import 'amount.dart';
import 'models/recipe.dart';
/// The ingredient list as the edit form shows it: one flat, reorderable list
/// where a [GroupEntry] starts a new Ingredient Group.
sealed class IngredientEntry {
IngredientEntry() : key = const Uuid().v4();
final String key;
void dispose();
}
class GroupEntry extends IngredientEntry {
GroupEntry([String? name]) : name = TextEditingController(text: name);
final TextEditingController name;
@override
void dispose() => name.dispose();
}
class LineEntry extends IngredientEntry {
LineEntry([IngredientLine? line])
: amount = TextEditingController(text: formatAmount(line?.amount, line?.amountMax)),
unitKey = line?.unitKey,
name = TextEditingController(text: line?.name),
remark = TextEditingController(text: line?.remark),
showRemark = line?.remark != null;
final TextEditingController amount;
String? unitKey;
final TextEditingController name;
final TextEditingController remark;
/// The remark field is only shown for lines that have one, or once the cook asks for it.
bool showRemark;
@override
void dispose() {
amount.dispose();
name.dispose();
remark.dispose();
}
}
/// The steps as the edit form shows them; a [SectionEntry] starts a Step Section.
sealed class StepEntry {
StepEntry() : key = const Uuid().v4();
final String key;
TextEditingController get text;
void dispose() => text.dispose();
}
class SectionEntry extends StepEntry {
SectionEntry([String? title]) : text = TextEditingController(text: title);
@override
final TextEditingController text;
}
class StepTextEntry extends StepEntry {
StepTextEntry([String? step]) : text = TextEditingController(text: step);
@override
final TextEditingController text;
}
List<IngredientEntry> ingredientEntriesFrom(List<IngredientGroup> groups) => [
for (final (i, group) in groups.indexed) ...[
if (i > 0 || group.name != null) GroupEntry(group.name),
for (final line in group.lines) LineEntry(line),
],
];
/// Builds the Ingredient Groups back from the form. Lines without a name are left out,
/// as are groups that end up empty. Amounts must already be validated.
List<IngredientGroup> ingredientGroupsFrom(List<IngredientEntry> entries) {
final groups = <({String? name, List<IngredientLine> lines})>[(name: null, lines: [])];
for (final entry in entries) {
switch (entry) {
case GroupEntry(:final name):
groups.add((name: _orNull(name.text), lines: []));
case LineEntry():
final name = _orNull(entry.name.text);
if (name == null) continue;
final amount = parseAmount(entry.amount.text) ?? (amount: null, amountMax: null);
groups.last.lines.add(
IngredientLine(
amount: amount.amount,
amountMax: amount.amountMax,
unitKey: entry.unitKey,
name: name,
remark: _orNull(entry.remark.text),
),
);
}
}
return [
for (final g in groups)
if (g.lines.isNotEmpty) IngredientGroup(name: g.name, lines: g.lines),
];
}
List<StepEntry> stepEntriesFrom(List<StepSection> sections) => [
for (final section in sections) ...[
if (section.title != null) SectionEntry(section.title),
for (final step in section.steps) StepTextEntry(step),
],
];
List<StepSection> stepSectionsFrom(List<StepEntry> entries) {
final sections = <({String? title, List<String> steps})>[(title: null, steps: [])];
for (final entry in entries) {
final text = _orNull(entry.text.text);
switch (entry) {
case SectionEntry():
sections.add((title: text, steps: []));
case StepTextEntry():
if (text != null) sections.last.steps.add(text);
}
}
return [
for (final s in sections)
if (s.steps.isNotEmpty) StepSection(title: s.title, steps: s.steps),
];
}
/// "vegan, schnell, Vegan" -> ["vegan", "schnell"]
List<String> tagsFrom(String text) {
final seen = <String>{};
return [
for (final tag in text.split(',').map((t) => t.trim()))
if (tag.isNotEmpty && seen.add(tag.toLowerCase())) tag,
];
}
List<String> linesFrom(String text) => [
for (final line in text.split('\n').map((l) => l.trim()))
if (line.isNotEmpty) line,
];
String? _orNull(String text) {
final trimmed = text.trim();
return trimmed.isEmpty ? null : trimmed;
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more