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
2 changes: 1 addition & 1 deletion 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/` | 1121 tests across 44 files |
| Run all tests | `cd menu_management && flutter test test/` | 1120 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
2 changes: 1 addition & 1 deletion adr/0014-multi-trip-shopping-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The copy no longer prints the planner's raw amounts. For each ingredient it take

ADR 0015 supersedes this UI: the OFF mode (flat list, ignore shelf life) was dropped, and the toggle was renamed `_useFreezerStrategy`. Both modes now produce sectioned, freshness-aware output in the detailed export. OFF is the original ON behavior (multi-trip, no freezing). ON is the new freezer-aware mode where freezable items ride trip 0 with a `(freeze on arrival)` suffix and only non-freezable perishables can force later trips.

One floating Export button replaced the floating copy button. It opens `showExportOptionsDialog`, which offers one row per format: Simplified (one line per ingredient), Detailed (one section per trip, with the packs and the links), and Checklist (the same sections and packs as Detailed, but one line per pack and no link). Checklist exists because a checklist app such as Google Keep turns each pasted line into one item: the name line, the indent and the link line of the Detailed format each become an item that the reader cannot tick off in a shop. Checklist repeats the ingredient name on every line and drops the links, so each line stands alone. Detailed and Checklist share the trip sections (`_buildTripSections`) and the pack mix (`shoppingPackSelection`), so the two can never split one menu differently. The on-screen list is still not sectioned. The toggle drives the trip plan, and every export reads that plan, so the toggle changes all of them. It also drives a short status banner. The detailed export writes one section per trip. The simplified export ignores the trips and the packs, but it keeps the `(freeze on arrival)` note of the same plan, because the one-trip plan only works when the user freezes those items on the day of the trip. With the toggle off the plan freezes nothing, so the simplified export writes no note.
One floating Export button replaced the floating copy button. It opens `showExportOptionsDialog`, which offers one row per format: Simplified (one section per trip, one line per ingredient with the amount), Detailed (one section per trip, with the packs and the links), and Checklist (the same sections and packs as Detailed, but one line per pack and no link). Checklist exists because a checklist app such as Google Keep turns each pasted line into one item: the name line, the indent and the link line of the Detailed format each become an item that the reader cannot tick off in a shop. Checklist repeats the ingredient name on every line and drops the links, so each line stands alone. All three text formats share the trip sections (`_buildTripSections`), so they can never split one menu differently. Simplified also writes the sections because its reader shops without the app and must still know when to buy what. Detailed and Checklist also share the pack mix (`shoppingPackSelection`). The on-screen list is still not sectioned. The toggle drives the trip plan, and every export reads that plan, so the toggle changes all of them. It also drives a short status banner. Each text export writes one section per trip and the `(freeze on arrival)` note on each line of a trip that the plan freezes. With the toggle off the plan freezes nothing, so no export writes the note.

## Consequences

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 @@ -43,7 +43,7 @@ The previous shopping page toggle (`_ensureFreshness`) had two states: off = sin
- **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".

The detailed export (and the on-screen banner) reflect the active mode, and the detailed export is sectioned by trip in both modes. When `freezeOnArrival = true` for an aggregated `TripItem`, the corresponding line gets a `(freeze on arrival)` suffix. The simplified export writes no trip section, but it keeps that same suffix: without it the one-trip plan cannot be followed safely, because the plan assumes the user freezes those items on the day of the trip. `computeFreezeOnArrivalIngredientIds` in `shopping_copy_text.dart` names the ingredients to freeze, and both formats read it from the same trip plan. It holds one flag per ingredient: one trip that freezes an ingredient marks every line of that ingredient in the simplified text, because the reader of that text buys every batch on day one.
The text exports (and the on-screen banner) reflect the active mode, and each text export is sectioned by trip in both modes. When `freezeOnArrival = true` for an aggregated `TripItem`, the corresponding line gets a `(freeze on arrival)` suffix. The simplified export keeps that suffix too: without it the one-trip plan cannot be followed safely, because the plan assumes the user freezes those items on the day of the trip.

### Planner internals

Expand Down
95 changes: 39 additions & 56 deletions menu_management/lib/shopping/shopping_copy_text.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// The shopping side gives each copy format its own builder, while the menu side switches formats
// with the MenuCopyFormat enum. That difference is deliberate. The two menu formats write the same
// lines with one extra piece of text, so one function with a flag keeps them in step. The shopping
// formats write different lines: the simplified one needs neither the trip plan nor the packs, and
// the detailed and the checklist ones write a different shape from the same input. One function
// with a flag would take parameters that some of its callers must leave empty.
// formats write a different line shape for each ingredient, so each format passes its own line
// builder ([IngredientLinesBuilder]).
//
// The detailed and the checklist formats read the same input, so they share the parts that must
// never drift apart: the trip sections ([_buildTripSections]) and the pack mix
// ([shoppingPackSelection]). Only the per-line shape differs.
// The three text formats read the same input, so they share the trip sections
// ([_buildTripSections]) that must never drift apart. The detailed and the checklist formats also
// share the pack mix ([shoppingPackSelection]). Only the per-line shape differs.
import "package:menu_management/flutter_essentials/library.dart";
import "package:menu_management/ingredients/models/ingredient.dart";
import "package:menu_management/ingredients/models/product.dart";
Expand All @@ -19,8 +18,9 @@ import "package:menu_management/shopping/waste_optimizer.dart";

/// Writes the copied text of one ingredient, for one trip's worth of [remaining].
///
/// [buildIngredientCopyLines] and [buildIngredientChecklistLines] both match this shape, so the
/// shared builders below take either of them and write the same sections around it.
/// [buildIngredientCopyLines], [buildIngredientChecklistLines] and [buildIngredientSimplifiedLine]
/// all match this shape, so the shared builders below take any of them and write the same sections
/// around it.
typedef IngredientLinesBuilder = String Function({required Ingredient ingredient, required List<Quantity> remaining, bool freezeOnArrival});

/// Sorts the ingredients of the copied list by name, ignoring upper and lower case.
Expand All @@ -32,34 +32,40 @@ List<Ingredient> sortIngredientsForCopy(List<Ingredient> ingredients) {
return sorted;
}

/// Builds the simplified shopping list: one line per ingredient, with the amount and nothing else.
/// Builds the simplified shopping list: one section per shop trip, with one line per ingredient.
///
/// Pure: takes the ingredients and what the user must still buy of each one, keyed by ingredient
/// id, and returns the text. The reader of this text shops without the app, so the text holds no
/// trip section, no pack line, and no store link. Use [buildMultiTripCopyText] for those.
///
/// An ingredient that the user already owns writes no line.
///
/// [freezeOnArrivalIngredientIds] names the ingredients that the user must freeze on the day of
/// the trip (ADR 0015). Each of them keeps the same "(freeze on arrival)" suffix that the detailed
/// text writes. Without the suffix the one-trip plan cannot be followed safely, because the plan
/// assumes the freezer. Build the set with [computeFreezeOnArrivalIngredientIds].
/// Pure: takes the same input as [buildMultiTripCopyText] and writes the same trip sections, so the
/// reader knows when to buy what. Each line holds the ingredient and the amount of that trip, and
/// nothing else. The reader of this text shops without the app, so the text holds no pack line and
/// no store link. Use [buildMultiTripCopyText] for those.
///
/// Precondition: every amount is already a whole number of its unit, as in [buildIngredientCopyLines].
/// An ingredient that the user already owns writes no line. A line of a trip that the plan freezes
/// keeps the same "(freeze on arrival)" suffix that the detailed text writes (ADR 0015).
String buildSimplifiedShoppingCopyText({
required List<Ingredient> ingredients,
required Map<String, List<Quantity>> remainingByIngredientId,
Set<String> freezeOnArrivalIngredientIds = const {},
required List<ShoppingTrip> trips,
required String Function(ShoppingTrip trip) tripLabel,
}) {
StringBuffer buffer = StringBuffer();
for (Ingredient ingredient in sortIngredientsForCopy(ingredients)) {
List<Quantity> remaining = remainingForCopy(ingredient: ingredient, remainingByIngredientId: remainingByIngredientId);
assertWholeShoppingAmounts(ingredient: ingredient, remaining: remaining);
if (!remaining.any((Quantity quantity) => quantity.amount > 0)) continue;
String freezeSuffix = freezeOnArrivalIngredientIds.contains(ingredient.id) ? freezeOnArrivalSuffix : "";
buffer.writeln("${ingredient.name}: ${shoppingAmountsText(remaining)}$freezeSuffix");
}
return buffer.toString().trimRight();
return _buildTripSections(
ingredients: ingredients,
remainingByIngredientId: remainingByIngredientId,
trips: trips,
tripLabel: tripLabel,
buildLines: buildIngredientSimplifiedLine,
);
}

/// Builds the simplified text for one ingredient (one trip's worth of [remaining]), for example
/// "Rice: 400 grams".
///
/// Pure, and it holds the same precondition as [buildIngredientCopyLines]. It returns an empty
/// text when nothing is needed.
String buildIngredientSimplifiedLine({required Ingredient ingredient, required List<Quantity> remaining, bool freezeOnArrival = false}) {
assertWholeShoppingAmounts(ingredient: ingredient, remaining: remaining);
if (!remaining.any((Quantity quantity) => quantity.amount > 0)) return "";
String freezeSuffix = freezeOnArrival ? freezeOnArrivalSuffix : "";
return "${ingredient.name}: ${shoppingAmountsText(remaining)}$freezeSuffix\n";
}

/// The note that tells the reader to freeze an item on the day of the trip (ADR 0015).
Expand All @@ -77,31 +83,6 @@ void assertWholeShoppingAmounts({required Ingredient ingredient, required List<Q
);
}

/// Returns the ids of the ingredients that the user must freeze on the day of the trip.
///
/// Pure: takes the same input as the copy builders plus the planned trips. It reads the same
/// per-ingredient split as [buildMultiTripCopyText].
///
/// The detailed text marks each trip line on its own, so one ingredient can carry the note on one
/// trip and not on another. This set holds one flag per ingredient, and one marked trip marks the
/// whole ingredient. The two formats therefore read differently for such an ingredient, and that
/// is on purpose: the simplified text holds no trip, so its reader buys every batch on day one.
/// The batch of a later trip then also has to wait, and only the freezer keeps it.
Set<String> computeFreezeOnArrivalIngredientIds({
required List<Ingredient> ingredients,
required Map<String, List<Quantity>> remainingByIngredientId,
required List<ShoppingTrip> trips,
}) {
Set<String> frozen = {};
if (trips.isEmpty) return frozen;
for (Ingredient ingredient in ingredients) {
List<Quantity> remaining = remainingForCopy(ingredient: ingredient, remainingByIngredientId: remainingByIngredientId);
List<TripAllocation> allocations = distributeRemainingAcrossTrips(ingredient: ingredient, pageRemaining: remaining, trips: trips);
if (allocations.any((TripAllocation allocation) => allocation.freezeOnArrival)) frozen.add(ingredient.id);
}
return frozen;
}

/// Writes the amounts of one ingredient, for example "500 grams + 2 pieces".
///
/// Every copy format and the shopping PDF call this, so one ingredient reads the same way in each
Expand Down Expand Up @@ -309,7 +290,9 @@ String buildIngredientChecklistLines({required Ingredient ingredient, required L
String freezeSuffix = freezeOnArrival ? freezeOnArrivalSuffix : "";

List<({Product product, int packs})>? selection = shoppingPackSelection(ingredient: ingredient, remaining: remaining);
if (selection == null || selection.isEmpty) return "${ingredient.name}: ${shoppingAmountsText(remaining)}$freezeSuffix\n";
if (selection == null || selection.isEmpty) {
return buildIngredientSimplifiedLine(ingredient: ingredient, remaining: remaining, freezeOnArrival: freezeOnArrival);
}

StringBuffer buffer = StringBuffer();
for (({Product product, int packs}) line in selection) {
Expand Down
18 changes: 7 additions & 11 deletions menu_management/lib/shopping/shopping_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ class _ShoppingPageState extends State<ShoppingPage> {
options: [
ClipboardExportOption(
label: "Simplified",
description: "One line per ingredient, with the amount to buy.",
description: "One section per shop trip, one line per ingredient with the amount to buy.",
buildText: _buildSimplifiedCopyText,
confirmation: "Copied the simplified shopping list to the clipboard.",
),
Expand Down Expand Up @@ -345,20 +345,16 @@ class _ShoppingPageState extends State<ShoppingPage> {
);
}

/// The simplified text: the ingredient and the amount, with no trip section and no pack line.
///
/// It keeps the "(freeze on arrival)" note of the detailed text. In one-trip mode the plan only
/// works if the user freezes those items on the day of the trip, so the note is not a detail.
/// The simplified text: the same trip sections as the detailed text, with the ingredient and the
/// amount of each trip, and no pack line.
String _buildSimplifiedCopyText() {
List<ShoppingTrip> trips = _planTrips();
({List<Ingredient> ingredients, Map<String, List<Quantity>> remainingByIngredientId}) input = _copyInput();
return buildSimplifiedShoppingCopyText(
ingredients: input.ingredients,
remainingByIngredientId: input.remainingByIngredientId,
freezeOnArrivalIngredientIds: computeFreezeOnArrivalIngredientIds(
ingredients: input.ingredients,
remainingByIngredientId: input.remainingByIngredientId,
trips: _planTrips(),
),
trips: trips,
tripLabel: (ShoppingTrip trip) => _tripLabel(trip: trip, trips: trips),
);
}

Expand Down Expand Up @@ -405,7 +401,7 @@ class _ShoppingPageState extends State<ShoppingPage> {
if (trips.isEmpty) return "$prefix: nothing to plan.";
String tripCountText = "${trips.length} ${trips.length == 1 ? "trip" : "trips"}";
String weeksText = trips.map((ShoppingTrip t) => _tripLabel(trip: t, trips: trips)).join(", ");
return "$prefix: the detailed export splits into $tripCountText ($weeksText).";
return "$prefix: the exports split into $tripCountText ($weeksText).";
}

/// Collects the ingredients of the list and what the user must still buy of each one.
Expand Down
Loading
Loading