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

+38
View File
@@ -0,0 +1,38 @@
import 'package:cookit/src/planning/day.dart';
import 'package:cookit/src/planning/day_format.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
setUpAll(() => initializeDateFormatting('de'));
final friday = DateTime.utc(2026, 10, 23);
test('weekStartOf honours the configured first weekday', () {
expect(weekStartOf(friday, DateTime.monday), DateTime.utc(2026, 10, 19));
expect(weekStartOf(friday, DateTime.tuesday), DateTime.utc(2026, 10, 20));
expect(weekStartOf(friday, DateTime.friday), friday);
expect(weekStartOf(friday, DateTime.saturday), DateTime.utc(2026, 10, 17));
});
test('days survive daylight saving time and storage', () {
// Summer time ends in Germany on 25 Oct 2026.
expect(addDays(DateTime.utc(2026, 10, 24), 2), DateTime.utc(2026, 10, 26));
expect(weekDays(DateTime.utc(2026, 10, 26)).last, DateTime.utc(2026, 11, 1));
expect(dayKey(friday), '2026-10-23');
expect(parseDayKey('2026-10-23'), friday);
expect(dayOf(DateTime(2026, 10, 23, 23, 59)), friday);
});
test('calendarWeek is the ISO week, also across the turn of the year', () {
expect(calendarWeek(DateTime.utc(2026, 10, 19)), 43);
expect(calendarWeek(DateTime.utc(2026, 10, 20)), 43); // a Tuesday-start week
expect(calendarWeek(DateTime.utc(2026, 12, 28)), 53);
expect(calendarWeek(DateTime.utc(2027, 1, 4)), 1);
});
test('dayRange', () {
expect(dayRange(DateTime.utc(2026, 10, 19), DateTime.utc(2026, 10, 25)), '19.–25. Okt.');
expect(dayRange(DateTime.utc(2026, 10, 26), DateTime.utc(2026, 11, 1)), '26. Okt. – 1. Nov.');
});
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:cookit/src/planning/ingredient_totals.dart';
import 'package:cookit/src/recipes/models/recipe.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
Recipe withLines(String id, List<IngredientLine> lines) => recipe(
id: id,
ingredientGroups: [IngredientGroup(lines: lines)],
);
test('lines with the same name and Unit are added up, others stay apart', () {
final totals = ingredientTotals([
withLines('a', const [
IngredientLine(amount: 200, unitKey: 'gram', name: 'Butter'),
IngredientLine(amount: 2, name: 'Eier'),
IngredientLine(name: 'Salz'),
]),
withLines('b', const [
IngredientLine(amount: 200, unitKey: 'gram', name: 'butter '),
IngredientLine(amount: 1, unitKey: 'tablespoon', name: 'Butter'),
IngredientLine(amount: 1, amountMax: 2, name: 'Eier'),
IngredientLine(name: 'Salz'),
]),
]);
expect(
[for (final l in totals) (l.amount, l.amountMax, l.unitKey, l.name)],
[
(400.0, null, 'gram', 'Butter'),
(3.0, 4.0, null, 'Eier'),
(null, null, null, 'Salz'),
(1.0, null, 'tablespoon', 'Butter'),
],
);
});
test('a remark survives only when every line agrees', () {
final totals = ingredientTotals([
withLines('a', const [
IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'),
IngredientLine(amount: 1, name: 'Ei', remark: 'groß'),
]),
withLines('b', const [
IngredientLine(amount: 1, name: 'Zitrone', remark: 'bio'),
IngredientLine(amount: 1, name: 'Ei'),
]),
]);
expect([for (final l in totals) l.remark], ['bio', null]);
});
test('the same Recipe planned twice counts twice', () {
final r = withLines('a', const [IngredientLine(amount: 100, unitKey: 'gram', name: 'Mehl')]);
expect(ingredientTotals([r, r]).single.amount, 200);
});
}
+126
View File
@@ -0,0 +1,126 @@
import 'dart:io';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/planning/models/meal.dart';
import 'package:cookit/src/planning/plan_repository.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
void main() {
late AppDatabase db;
late PhotoStore photos;
late PlanRepository plan;
late RecipeRepository recipes;
final yesterday = DateTime.utc(2026, 10, 22);
final today = DateTime.utc(2026, 10, 23);
final tomorrow = DateTime.utc(2026, 10, 24);
setUp(() async {
db = testDatabase();
photos = await tempPhotoStore();
plan = PlanRepository(db);
recipes = RecipeRepository(db, photos);
});
tearDown(() async {
await db.close();
await photos.directory.delete(recursive: true);
});
Future<Meal> meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name);
test('a fresh database plans Mittag and Abend', () async {
expect((await plan.allMeals()).map((m) => m.name), defaultMeals);
});
test('entries are added in order, moved to the end of another Meal and removed', () async {
await recipes.saveRecipe(recipe());
final abend = await meal('Abend');
final mittag = await meal('Mittag');
await plan.addEntry(today, abend.id, recipeId: 'r1');
await plan.addEntry(today, abend.id, note: 'Salat');
await plan.addEntry(tomorrow, mittag.id, note: 'Reste');
var entries = await plan.allEntries();
expect(
[for (final e in entries) (e.day, e.recipeId ?? e.note, e.position)],
[(today, 'r1', 0), (today, 'Salat', 1), (tomorrow, 'Reste', 0)],
);
await plan.moveEntry(entries.first.id, tomorrow, mittag.id);
await plan.removeEntry(entries[1].id);
entries = await plan.allEntries();
expect(
[for (final e in entries) (e.day, e.mealId, e.recipeId ?? e.note, e.position)],
[(tomorrow, mittag.id, 'Reste', 0), (tomorrow, mittag.id, 'r1', 1)],
);
});
test('deleting a Meal removes its upcoming entries and keeps it retired for the past', () async {
final abend = await meal('Abend');
await plan.addEntry(yesterday, abend.id, note: 'Pizza');
await plan.addEntry(today, abend.id, note: 'Suppe');
await plan.addEntry(tomorrow, abend.id, note: 'Curry');
expect(await plan.upcomingEntryCount(abend.id, today), 2);
await plan.deleteMeal(abend.id, today);
expect((await plan.allEntries()).map((e) => e.note), ['Pizza']);
expect((await meal('Abend')).retired, isTrue);
// Added again under the same name, the old Meal comes back with its history.
await plan.addMeal('abend');
final back = await meal('abend');
expect(back.id, abend.id);
expect(back.retired, isFalse);
});
test('deleting an unused Meal removes it entirely', () async {
final mittag = await meal('Mittag');
await plan.deleteMeal(mittag.id, today);
expect((await plan.allMeals()).map((m) => m.name), ['Abend']);
});
test('a deleted Recipe stays in the plan as a note with its name', () async {
await recipes.saveRecipe(recipe());
final abend = await meal('Abend');
await plan.addEntry(yesterday, abend.id, recipeId: 'r1');
await recipes.deleteRecipe(recipe());
final entry = (await plan.allEntries()).single;
expect(entry.recipeId, isNull);
expect(entry.note, 'Focaccia');
});
test('"Tauschen" plans another Recipe in the same entry', () async {
await recipes.saveRecipe(recipe());
await recipes.saveRecipe(recipe(id: 'r2', name: 'Pizza'));
final abend = await meal('Abend');
await plan.addEntry(today, abend.id, recipeId: 'r1');
await plan.replaceRecipe((await plan.allEntries()).single.id, 'r2');
expect((await plan.allEntries()).single.recipeId, 'r2');
});
test('an existing version 2 database gets the Week Plan tables on upgrade', () async {
await db.close();
final dir = await Directory.systemTemp.createTemp('cookit_db_');
final file = File('${dir.path}/cookit.sqlite');
var fileDb = testDatabase(file: file);
await RecipeRepository(fileDb, photos).saveRecipe(recipe());
await fileDb.customStatement('DROP TABLE plan_entries');
await fileDb.customStatement('DROP TABLE meals');
await fileDb.customStatement('PRAGMA user_version = 2');
await fileDb.close();
fileDb = testDatabase(file: file);
expect((await PlanRepository(fileDb).allMeals()).map((m) => m.name), defaultMeals);
expect((await RecipeRepository(fileDb, photos).getRecipe('r1'))!.name, 'Focaccia');
await fileDb.close();
await dir.delete(recursive: true);
db = testDatabase(); // for tearDown
});
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:cookit/src/bundle/bundle_service.dart';
import 'package:cookit/src/database/database.dart';
import 'package:cookit/src/planning/day.dart';
import 'package:cookit/src/planning/plan_repository.dart';
import 'package:cookit/src/planning/plan_share_service.dart';
import 'package:cookit/src/recipes/course_repository.dart';
import 'package:cookit/src/recipes/photo_store.dart';
import 'package:cookit/src/recipes/recipe_repository.dart';
import 'package:cookit/src/recipes/unit_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
import '../support/test_database.dart';
/// One cook's collection and Week Plan.
class Phone {
Phone._(this.db, this.photos)
: recipes = RecipeRepository(db, photos),
courses = CourseRepository(db),
units = UnitRepository(db),
plan = PlanRepository(db) {
bundles = BundleService(recipes, courses, units, photos);
shares = PlanShareService(recipes, courses, units, photos, plan, bundles);
}
static Future<Phone> create() async {
final phone = Phone._(testDatabase(), await tempPhotoStore());
addTearDown(() async {
await phone.db.close();
await phone.photos.directory.delete(recursive: true);
});
return phone;
}
final AppDatabase db;
final PhotoStore photos;
final RecipeRepository recipes;
final CourseRepository courses;
final UnitRepository units;
final PlanRepository plan;
late final BundleService bundles;
late final PlanShareService shares;
Future<String> meal(String name) async => (await plan.allMeals()).firstWhere((m) => m.name == name).id;
}
void main() {
final monday = DateTime.utc(2026, 10, 19);
test('a shared week reuses known Recipes, adds new ones and its Meals, and keeps what is planned', () async {
final mine = await Phone.create();
final theirs = await Phone.create();
// Mine: three Recipes planned across the week, plus a note and a Meal they don't have.
await mine.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia'));
await mine.recipes.saveRecipe(recipe(id: 'mine-pizza', name: 'Pizza'));
await mine.recipes.saveRecipe(recipe(id: 'new', name: 'Gulasch'));
await mine.plan.addMeal('Frühstück');
await mine.plan.addEntry(monday, await mine.meal('Abend'), recipeId: 'shared');
await mine.plan.addEntry(addDays(monday, 1), await mine.meal('Abend'), recipeId: 'mine-pizza');
await mine.plan.addEntry(addDays(monday, 2), await mine.meal('Mittag'), recipeId: 'new');
await mine.plan.addEntry(addDays(monday, 3), await mine.meal('Frühstück'), note: 'Bäcker');
await mine.plan.addEntry(addDays(monday, 8), await mine.meal('Abend'), recipeId: 'new'); // next week: not sent
// Theirs: the same Focaccia (same id), their own Pizza (same name), and a plan of their own.
await theirs.recipes.saveRecipe(recipe(id: 'shared', name: 'Focaccia'));
await theirs.recipes.saveRecipe(recipe(id: 'their-pizza', name: 'pizza '));
await theirs.plan.addEntry(monday, await theirs.meal('Mittag'), note: 'Reste');
final week = SharedWeek(await theirs.bundles.read(await mine.shares.weekFile(monday)));
final preview = await theirs.shares.preview(week);
expect((preview.entries, preview.newRecipes, preview.existingEntries), (4, 1, 1));
await theirs.shares.adopt(week);
final recipes = await theirs.recipes.allRecipes();
expect(recipes.map((r) => r.id), unorderedEquals(['shared', 'their-pizza', 'new']));
final meals = {for (final m in await theirs.plan.allMeals()) m.id: m.name};
final entries = await theirs.plan.allEntries();
expect(
[for (final e in entries) (e.day, meals[e.mealId], e.recipeId ?? e.note)],
unorderedEquals([
(monday, 'Mittag', 'Reste'),
(monday, 'Abend', 'shared'),
(addDays(monday, 1), 'Abend', 'their-pizza'),
(addDays(monday, 2), 'Mittag', 'new'),
(addDays(monday, 3), 'Frühstück', 'Bäcker'),
]),
);
});
test('only a bundle shared from the Week Plan carries a week', () async {
final mine = await Phone.create();
await mine.recipes.saveRecipe(recipe());
final contents = await mine.bundles.read(await mine.bundles.exportBundle());
expect(contents.plan, isNull);
final week = await mine.bundles.read(await mine.shares.weekFile(monday));
expect(week.plan, isNotNull);
});
}
+65
View File
@@ -0,0 +1,65 @@
import 'dart:math';
import 'package:cookit/src/planning/week_fill.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/fixtures.dart';
void main() {
final days = [for (var d = 19; d <= 25; d++) DateTime.utc(2026, 10, d)];
final recipes = [for (var i = 0; i < 5; i++) recipe(id: 'r$i', name: 'R$i')];
test('fills each empty day with a different Recipe, never one already in the week', () {
final filled = fillWeek(
emptyDays: days.take(3).toList(),
candidates: recipes,
inWeek: {'r0'},
recent: const {},
random: Random(1),
);
expect(filled.keys, days.take(3));
final ids = filled.values.map((r) => r.id).toList();
expect(ids.toSet(), hasLength(3));
expect(ids, isNot(contains('r0')));
});
test('recently planned Recipes come only after the others', () {
for (var seed = 0; seed < 20; seed++) {
final filled = fillWeek(
emptyDays: days.take(4).toList(),
candidates: recipes,
inWeek: const {},
recent: {'r0', 'r1'},
random: Random(seed),
);
final ids = filled.values.map((r) => r.id).toList();
expect(ids.take(3).toSet(), {'r2', 'r3', 'r4'});
expect(ids.last, anyOf('r0', 'r1'));
}
});
test('days stay empty when the Recipes run out', () {
final filled = fillWeek(
emptyDays: days,
candidates: recipes.take(2).toList(),
inWeek: const {},
recent: const {},
random: Random(2),
);
expect(filled, hasLength(2));
});
test('pickReplacement skips the week and returns null when nothing is left', () {
final pick = pickReplacement(
candidates: recipes,
inWeek: {'r0', 'r1', 'r2', 'r3'},
recent: const {},
random: Random(),
);
expect(pick?.id, 'r4');
expect(
pickReplacement(candidates: recipes, inWeek: {for (final r in recipes) r.id}, recent: const {}, random: Random()),
isNull,
);
});
}