Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it;
| Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | |
| Build release | `cd menu_management && flutter build windows` | |
| Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop |
| Run all tests | `cd menu_management && flutter test test/` | 1073 tests across 42 files |
| Run all tests | `cd menu_management && flutter test test/` | 1121 tests across 44 files |
| Run single test | `cd menu_management && flutter test test/<file>.dart` | |
| List devices | `flutter devices` | |
| Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` |
Expand Down Expand Up @@ -93,7 +93,7 @@ UI (Widgets) --> State (Providers) --> Data (Freezed Models)
| `ingredients/` | `IngredientsProvider` | `Ingredient`, `Product` | CRUD for food items; optional store product link per ingredient |
| `recipes/` | `RecipesProvider` | `Recipe`, `Instruction`, `IngredientUsage`, `Quantity`, `Result` | Recipe management with multi-step instructions, inputs/outputs |
| `menu/` | `MenuProvider` | `MultiWeekMenu`, `Menu`, `Meal`, `MealTime`, `Cooking`, `MenuConfiguration` | Multi-week menus (each week = 21 meal slots), generation algorithm |
| `shopping/` | (derived) | `ShoppingIngredient` | Aggregated shopping list from generated menu |
| `shopping/` | (derived) | `ShoppingIngredient`, `ShoppingProgress` | Aggregated shopping list from generated menu; saved owned stock |
| `theme/` | - | `DynamicTheme`, `ThemeCustom` | Material 3 theming |

### Menu Generation Algorithm
Expand All @@ -106,6 +106,7 @@ Core logic in `menu_generator.dart`. Fully parameterized: receives `List<Recipe>

- **`.tsr` files**: JSON with top-level `"Ingredients"` and `"Recipes"` arrays. On save, `ref_name` fields are injected into `IngredientUsage` entries for human readability.
- **`.tsm` files**: Menus store `recipeId` (UUID) + `ref_name` per meal, not full Recipe objects. On load, each `recipeId` is validated; missing recipes are skipped with a warning. A menu may also carry `startDate`, the real date of menu day 0; a file without it keeps the Saturday-first, date-less behavior. Use `menu/menu_dates.dart` to turn a day offset into a date or a label.
- **Shopping progress**: a `.tsm` file can carry `shoppingProgress`, the owned stock and the trip switch of `ShoppingPage`. It keys product counts by product `link` plus `unit`, not index; `shopping/shopping_progress.dart` converts at the page boundary. See ADR 0003.
- **PDF export**: each PDF is two files. `<feature>_pdf_document.dart` decides what the PDF says, and `<feature>_pdf.dart` renders it and exposes its composed strings as public pure functions. The split keeps the content testable with no PDF to decode. The two PDFs are `menu/menu_pdf.dart` and `shopping/shopping_pdf.dart`. `Persistency.saveBytes` writes any export that is not JSON, and `Persistency.supportsFileSaving()` says if the device has a save dialog.
- Data is **not** automatically saved -- users must manually save via the save button
- On startup, dialogs ask whether to load last session, bundled defaults, or skip (for both recipes and menus)
Expand Down Expand Up @@ -226,6 +227,7 @@ ADRs capture **why** decisions were made, not just what was built. This includes
- Platform target is desktop-first. Mobile platforms have limited save/load support.
- CI installs the latest stable Flutter and pins no version, on purpose. When `flutter analyze` or a test compile fails on code that `main` already merged with a green check, your local SDK is too old. Run `flutter upgrade`. Never change the code to fit an old SDK.
- To add days to a calendar date, use the `DateTime(year, month, day + n)` constructor, never `add(Duration(days: n))`. A `Duration` counts hours, so it drifts by one hour at each daylight-saving change and can land on the wrong calendar day.
- A `Product.link` is not unique inside one ingredient: the grams product and the pieces product of one store item share it. To identify a product in saved data, use the link plus the `unit`, never the link alone.

## Git Workflow

Expand Down
3 changes: 3 additions & 0 deletions adr/0003-tsr-file-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Persist data as JSON-based files via `FilePicker`, using two distinct formats:

- **`.tsm` files** -- Store generated menus (single-week or multi-week). Each meal's `Cooking` object stores a `recipeId` (UUID string) referencing a recipe in the recipe book, plus a `ref_name` field for human readability (ignored by the app on load). Saved through `Persistency.saveMenu()`, loaded through `Persistency.loadMultiWeekMenu()`. The loader detects whether the JSON contains a `"weeks"` key (multi-week format) or just `"meals"` (old single-week format) and handles both. On load, each `recipeId` is validated against the loaded recipe book; meals referencing missing recipes have their cooking set to null with a warning logged.

- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link` plus its `unit` (`ProductCountKey`), not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link and the unit do not. The link alone is not unique: the grams product and the pieces product of one store item share it. When two products share link and unit, the first of them gets the count. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. The code writes this JSON by hand, because json_serializable cannot write the record types of the model. The lenient parse lives in the model (`ShoppingProgress.fromJsonLenient`), not in `Persistency` like `startDate`, so every `MultiWeekMenu.fromJson` call gets it. It drops each bad value with a warning. On open, the page also drops a count that matches no product, a header amount whose unit the unit dropdown no longer offers, and a header amount of an ingredient that now shows one input per product. The first change that the page reports also drops a saved entry of a deleted ingredient and a saved count that matches no product, for the ingredients of other menus too, so a stale entry does not stay in the file. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu.

- **Exports that are not JSON** (for example the menu PDF) -- `Persistency.saveBytes()` asks the user where to save through the same `FilePicker` save dialog, then writes raw bytes with `saveBytesToPath()`. `saveBytesToPath()` writes the picked path as it came back and adds no extension: the save dialog asks about the name that the user typed, so a changed name could overwrite a file that the user never saw. `Persistency.supportsFileSaving()` reports whether the device has a save dialog at all; `FileExportOption` asks it before it builds any byte and warns the user instead of failing silently on iOS/Android. The `.tsm` and `.tsr` saves do not ask it yet, so they still fail without a word on mobile. An export writes no `last_session.json` entry, because the app has nothing that reads such a file back. `Persistency.defaultMenuFileName()` takes an `extension` parameter so a `.tsm` save and a PDF export of the same menu propose the same file name, differing only in extension.

On startup, sequential dialogs ask the user whether to load recipes (last saved, bundled defaults, or skip) and then whether to load a menu (same options). The app tracks the last saved/loaded `.tsr` and `.tsm` paths in `%APPDATA%/MenuManagement/last_session.json`.
Expand All @@ -21,6 +23,7 @@ On startup, sequential dialogs ask the user whether to load recipes (last saved,
- No database dependency; fully portable data files.
- Save/load is unavailable on iOS/Android due to `FilePicker` limitations.
- Users manage their own file storage locations.
- A product whose `link` changes loses its saved owned count, because the link is the key of that count.
- Menu configurations (the 21-slot grid in `MenuProvider`) are not persisted. They reset to defaults on each app launch.
- The release-mode startup load requires user interaction (file picker), so there is no silent background restore.
- A byte-based export (the PDF) reuses the save dialog but skips the last-session bookkeeping, so it cannot silently grow into a format the app tries to load back; a future loadable binary format needs its own decision.
1 change: 1 addition & 0 deletions adr/0008-multi-week-menus.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Key design choices:
- **Optional start date**: `MultiWeekMenu.startDate` holds the real calendar date of menu day 0. It is optional. The planning math never reads it: every calculation stays in absolute day offsets (`weekIndex * 7 + weekDay.value`). The date only translates an offset into a real date for the user. The functions in `lib/menu/menu_dates.dart` do that translation and are the one home for it.
- **Day order comes from the start date**: the `WeekDay` enum value is the day offset inside the week, so the grid always renders offset 0 to 6 in that order. The start date changes only the name and the date of each column. A menu that starts on a Wednesday reads Wednesday to Tuesday. No meal moves, because no meal is re-keyed.
- **The start date survives regeneration**: the start date is user configuration, not generator output. `MenuGenerator` knows nothing about dates, so `MenuPage` re-applies the date to the regenerated menu.
- **Optional shopping progress**: `MultiWeekMenu.shoppingProgress` holds what the user typed on the shopping page (ADR 0003 has the file format). It is user input, like the start date, so the regenerate button of `MenuPage` and the generate button of `MenuConfigurationPage` both keep it. `ShoppingPage` hands each change back to `MenuPage` through a callback, and `MenuPage` stores it through its menu setter.
- **Date-less menus keep the old wording**: a `.tsm` file without `startDate` loads with a null date. Then the day names come from the `WeekDay` enum, which starts at Saturday, and no date is shown. The shopping trip label falls back to "Week N".
- **UI navigation**: Week switching uses left/right chevrons in the app bar. Add/remove week uses +/- circle buttons. When only one week exists, the navigation arrows are hidden.

Expand Down
2 changes: 1 addition & 1 deletion adr/0015-freezable-products-and-freezer-aware-trips.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Red trumps blue: a meal with one impossible ingredient and one freeze-required i

### Shopping list: replacement of the freshness toggle

The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (internal flag `_useFreezerStrategy`). The two states are:
The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (page flag `_useFreezerStrategy`). The menu file keeps it in `ShoppingProgress.useFreezerStrategy` (ADR 0003), so the page restores it. The two states are:

- **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks weekly trips so every event is within sealed shelf life (ADR 0014 says when the set is the smallest). Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking".
- **On (One-trip mode)**: every event whose matching product is freezable (any-match across same-unit variants) is treated as non-perishable for trip assignment AND pinned to trip 0. Such an event is also tagged `freezeOnArrival = true` when its sealed shelf life would not have covered the gap from trip 0 to the cooking day (i.e. the user actually has to freeze it). Non-freezable perishables continue to flow through the original interval-cover algorithm and may still force later trips when their shelf life is exceeded. Tooltip: "shop once and freeze items that would otherwise expire".
Expand Down
1 change: 1 addition & 0 deletions adr/0016-reference-guarded-deletion.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Option 2 was chosen because `MenuProvider` is already the app-lifetime singleton
- All three delete flows are now safe: no dangling ingredient IDs in `IngredientUsage`, no dangling `recipeId` in `Cooking`, no dangling result IDs in `Instruction.inputs`.
- Undo is symmetric with the cascade: the snackbar's "Undo" action restores not just the deleted entity but also every recipe/menu it had modified, by closing over the pre-cascade objects (`referencingRecipes`, `menu`) in the callback.
- `MenuProvider.multiWeekMenu` is a best-effort mirror, not a guaranteed-fresh source of truth. It is set only when `MenuPage` is open or was opened at least once this session. Every current generate/load path pushes a new `MenuPage`, which replaces the mirror, but nothing ever resets it to `null` -- it outlives the page it came from, and it is not touched when a `.tsr` recipe file is loaded that removes recipes the mirrored menu still references. `MenuConfigurationPage` also reads the mirror on purpose: it borrows the start date of the active menu to name its day columns, so the grid can show the dates of a menu that the user already closed, and it falls back to the Saturday-first `WeekDay` order before any menu exists. `RecipesPage._deleteSelectedRecipe` tolerates this because `findReferencingMeals` is a plain string comparison with no assumption that every `recipeId` it finds still resolves to a loaded `Recipe`; worst case, a stale mirrored menu under-reports or over-reports references for a menu the user is no longer looking at, but it cannot crash.
- The delete cascade does not touch `MultiWeekMenu.shoppingProgress`. A deleted ingredient or product leaves its saved owned stock in the menu on purpose. When the shopping page opens, it logs a warning for each saved product count that matches no product. At the first change that the page reports, `ShoppingProgress.fromPageState` drops every saved entry of a deleted ingredient and every saved count that matches no product, so the next save writes a clean file (ADR 0003).
- `MenuProvider` now has two unrelated responsibilities: the fixed 21-slot `MenuConfiguration` grid (ADR 0002) and this one mutable "active menu" reference. A future reviewer of ADR 0002 should be aware the "fixed-grid provider" description is no longer the whole picture.
- The reference-check methods intentionally cascade-delete rather than block the deletion outright. The user is warned and shown exactly what will be cleaned up, but there is no "cannot delete while referenced" mode. This matches the app's existing delete-then-undo pattern (immediate action, reversible) rather than introducing a new blocking-validation pattern.
- `showDeleteConfirmationDialog` is domain-agnostic and reusable; a fourth delete flow needing the same warn-and-list treatment (e.g., deleting a `Product` referenced by shopping calculations) can reuse it without changes.
Expand Down
12 changes: 11 additions & 1 deletion menu_management/lib/menu/models/multi_week_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "package:menu_management/recipes/models/quantity.dart";
import "package:menu_management/recipes/models/recipe.dart";
import "package:menu_management/shopping/ingredient_meal_requirement.dart";
import "package:menu_management/shopping/ingredient_source.dart";
import "package:menu_management/shopping/shopping_progress.dart";

part "multi_week_menu.freezed.dart";
part "multi_week_menu.g.dart";
Expand All @@ -22,7 +23,16 @@ abstract class MultiWeekMenu with _$MultiWeekMenu {
/// keeps the date-less day order of the WeekDay enum, which starts at Saturday.
/// The date never changes the planning math, which stays in absolute day offsets.
/// Use the functions in `menu_dates.dart` to turn a day offset into a date or a label.
const factory MultiWeekMenu({@Default([]) List<Menu> weeks, @JsonKey(includeIfNull: false) DateTime? startDate}) = _MultiWeekMenu;
///
/// [shoppingProgress] is what the user typed on the shopping page. It is optional: the JSON
/// leaves it out when it is null or empty, and a bad value in a file loads as null or loses
/// only that value (see [ShoppingProgress.fromJsonLenient]).
const factory MultiWeekMenu({
@Default([]) List<Menu> weeks,
@JsonKey(includeIfNull: false) DateTime? startDate,
@JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull)
ShoppingProgress? shoppingProgress,
}) = _MultiWeekMenu;

factory MultiWeekMenu.fromJson(Map<String, Object?> json) => _$MultiWeekMenuFromJson(json);

Expand Down
Loading
Loading