From d191869f685fc1e629e3878c2e4baa4f409ba8f0 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 22:57:01 +0200 Subject: [PATCH 1/2] feat: say when to buy what in the simplified shopping export The simplified export wrote one flat list, so its reader bought every batch on day one, even when the plan splits the shopping into several trips. It now writes the same trip sections as the detailed and the checklist exports, with one "Ingredient: amount" line per trip. The freeze note now rides on the line of the trip that freezes the item, as in the detailed export. computeFreezeOnArrivalIngredientIds only existed to mark the flat list, so it is gone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- adr/0014-multi-trip-shopping-planner.md | 2 +- ...ezable-products-and-freezer-aware-trips.md | 2 +- .../lib/shopping/shopping_copy_text.dart | 95 +++++------ .../lib/shopping/shopping_page.dart | 16 +- menu_management/test/shopping_copy_test.dart | 156 ++++++++---------- menu_management/test/shopping_page_test.dart | 23 ++- 7 files changed, 135 insertions(+), 161 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e426fa..67c6b5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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` | diff --git a/adr/0014-multi-trip-shopping-planner.md b/adr/0014-multi-trip-shopping-planner.md index d4701a0..e3bff31 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -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 diff --git a/adr/0015-freezable-products-and-freezer-aware-trips.md b/adr/0015-freezable-products-and-freezer-aware-trips.md index ba0242d..a06864d 100644 --- a/adr/0015-freezable-products-and-freezer-aware-trips.md +++ b/adr/0015-freezable-products-and-freezer-aware-trips.md @@ -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 diff --git a/menu_management/lib/shopping/shopping_copy_text.dart b/menu_management/lib/shopping/shopping_copy_text.dart index d853715..104d178 100644 --- a/menu_management/lib/shopping/shopping_copy_text.dart +++ b/menu_management/lib/shopping/shopping_copy_text.dart @@ -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"; @@ -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 remaining, bool freezeOnArrival}); /// Sorts the ingredients of the copied list by name, ignoring upper and lower case. @@ -32,34 +32,40 @@ List sortIngredientsForCopy(List 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 ingredients, required Map> remainingByIngredientId, - Set freezeOnArrivalIngredientIds = const {}, + required List trips, + required String Function(ShoppingTrip trip) tripLabel, }) { - StringBuffer buffer = StringBuffer(); - for (Ingredient ingredient in sortIngredientsForCopy(ingredients)) { - List 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 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). @@ -77,31 +83,6 @@ void assertWholeShoppingAmounts({required Ingredient ingredient, required List computeFreezeOnArrivalIngredientIds({ - required List ingredients, - required Map> remainingByIngredientId, - required List trips, -}) { - Set frozen = {}; - if (trips.isEmpty) return frozen; - for (Ingredient ingredient in ingredients) { - List remaining = remainingForCopy(ingredient: ingredient, remainingByIngredientId: remainingByIngredientId); - List 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 @@ -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) { diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index bb9bc5a..78efe27 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -291,7 +291,7 @@ class _ShoppingPageState extends State { 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.", ), @@ -345,20 +345,16 @@ class _ShoppingPageState extends State { ); } - /// 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 trips = _planTrips(); ({List ingredients, Map> 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), ); } diff --git a/menu_management/test/shopping_copy_test.dart b/menu_management/test/shopping_copy_test.dart index 60af0d7..1136aa8 100644 --- a/menu_management/test/shopping_copy_test.dart +++ b/menu_management/test/shopping_copy_test.dart @@ -239,9 +239,40 @@ void main() { }); group("buildSimplifiedShoppingCopyText", () { - test("writes one line per ingredient with the amount and nothing else", () { + test("writes one section per trip, with one line per ingredient and the amount of that trip", () { + // The reader must know when to buy what, so each trip gets the same header the detailed list + // writes. Inside a section, the line holds the amount and no pack. Ingredient pizza = Ingredient(id: "pizza", name: "Pizza", products: [_equivProduct("a")]); - Ingredient apple = const Ingredient(id: "apple", name: "Apple"); + const Ingredient apple = Ingredient(id: "apple", name: "Apple"); + + String text = buildSimplifiedShoppingCopyText( + ingredients: [pizza, apple], + remainingByIngredientId: const { + "pizza": [Quantity(amount: 1000, unit: Unit.grams)], + "apple": [Quantity(amount: 3, unit: Unit.pieces)], + }, + trips: const [ + ShoppingTrip( + weekIndex: 0, + items: [ + TripItem(ingredientId: "pizza", amount: 500, unit: Unit.grams), + TripItem(ingredientId: "apple", amount: 3, unit: Unit.pieces), + ], + ), + ShoppingTrip( + weekIndex: 1, + items: [TripItem(ingredientId: "pizza", amount: 500, unit: Unit.grams)], + ), + ], + tripLabel: (ShoppingTrip trip) => trip.weekIndex == 0 ? "now" : "Friday 25 Sep", + ); + + expect(text.split("\n"), const ["now", "---", "Apple: 3 pieces", "Pizza: 500 grams", "", "Friday 25 Sep", "-------------", "Pizza: 500 grams"]); + }); + + test("writes one flat list with no header when the planner planned no trip", () { + Ingredient pizza = Ingredient(id: "pizza", name: "Pizza", products: [_equivProduct("a")]); + const Ingredient apple = Ingredient(id: "apple", name: "Apple"); String text = buildSimplifiedShoppingCopyText( ingredients: [pizza, apple], @@ -249,6 +280,8 @@ void main() { "pizza": [Quantity(amount: 500, unit: Unit.grams)], "apple": [Quantity(amount: 3, unit: Unit.pieces)], }, + trips: const [], + tripLabel: (ShoppingTrip trip) => "unused", ); // The list is sorted by name and holds no pack line: the amount is all it shows. @@ -263,6 +296,8 @@ void main() { remainingByIngredientId: const { "milk": [Quantity(amount: 500, unit: Unit.centiliters), Quantity(amount: 2, unit: Unit.pieces)], }, + trips: const [], + tripLabel: (ShoppingTrip trip) => "unused", ); expect(text.split("\n"), const ["Milk: 500 centiliters + 2 pieces"]); @@ -278,27 +313,51 @@ void main() { "milk": [Quantity(amount: 0, unit: Unit.centiliters)], "rice": [Quantity(amount: 200, unit: Unit.grams)], }, + trips: const [], + tripLabel: (ShoppingTrip trip) => "unused", ); expect(text.split("\n"), const ["Rice: 200 grams"]); }); - test("keeps the freeze-on-arrival warning of the ingredients that the plan freezes", () { + test("writes the freeze note only on the trip that the plan freezes", () { // In one-trip mode the plan only works if the user freezes these items on the trip day. - // A simplified list without the warning cannot be followed safely. + // The note rides on the line of the trip that freezes the item, as in the detailed list. const Ingredient peas = Ingredient(id: "peas", name: "Peas"); const Ingredient rice = Ingredient(id: "rice", name: "Rice"); String text = buildSimplifiedShoppingCopyText( ingredients: const [peas, rice], remainingByIngredientId: const { - "peas": [Quantity(amount: 500, unit: Unit.grams)], + "peas": [Quantity(amount: 800, unit: Unit.grams)], "rice": [Quantity(amount: 200, unit: Unit.grams)], }, - freezeOnArrivalIngredientIds: const {"peas"}, + trips: const [ + ShoppingTrip( + weekIndex: 0, + items: [ + TripItem(ingredientId: "peas", amount: 500, unit: Unit.grams, freezeOnArrival: true), + TripItem(ingredientId: "rice", amount: 200, unit: Unit.grams), + ], + ), + ShoppingTrip( + weekIndex: 1, + items: [TripItem(ingredientId: "peas", amount: 300, unit: Unit.grams)], + ), + ], + tripLabel: (ShoppingTrip trip) => "Week ${trip.weekIndex + 1}", ); - expect(text.split("\n"), const ["Peas: 500 grams (freeze on arrival)", "Rice: 200 grams"]); + expect(text.split("\n"), const [ + "Week 1", + "------", + "Peas: 500 grams (freeze on arrival)", + "Rice: 200 grams", + "", + "Week 2", + "------", + "Peas: 300 grams", + ]); }); test("throws on a fractional amount, the same way the detailed list does", () { @@ -312,89 +371,10 @@ void main() { remainingByIngredientId: const { "rice": [Quantity(amount: 0.4, unit: Unit.grams)], }, - ), - throwsA(isA()), - ); - }); - }); - - group("computeFreezeOnArrivalIngredientIds", () { - test("names every ingredient that the plan freezes, and no other one", () { - // The peas ride trip 0 and must be frozen; the rice is bought on the trip of its own week. - const Ingredient peas = Ingredient( - id: "peas", - name: "Peas", - products: [Product(link: "", quantityPerItem: 500, unit: Unit.grams, shelfLifeDaysClosed: 3, canBeFrozen: true)], - ); - const Ingredient rice = Ingredient(id: "rice", name: "Rice"); - - List trips = const [ - ShoppingTrip( - weekIndex: 0, - items: [ - TripItem(ingredientId: "peas", amount: 500, unit: Unit.grams, freezeOnArrival: true), - TripItem(ingredientId: "rice", amount: 200, unit: Unit.grams), - ], - ), - ]; - - Set frozen = computeFreezeOnArrivalIngredientIds( - ingredients: const [peas, rice], - remainingByIngredientId: const { - "peas": [Quantity(amount: 500, unit: Unit.grams)], - "rice": [Quantity(amount: 200, unit: Unit.grams)], - }, - trips: trips, - ); - - expect(frozen, const {"peas"}); - }); - - test("names an ingredient that one trip freezes and another trip does not", () { - // The peas ride two trips: the batch of trip 0 must wait in the freezer, the batch of the - // later trip does not. The simplified list holds no trip, so its reader buys both batches on - // day one. The later batch then also has to wait, and only the freezer keeps it. So one - // frozen batch marks the whole line. - const Ingredient peas = Ingredient( - id: "peas", - name: "Peas", - products: [Product(link: "", quantityPerItem: 500, unit: Unit.grams, shelfLifeDaysClosed: 3, canBeFrozen: true)], - ); - - List trips = const [ - ShoppingTrip( - weekIndex: 0, - items: [TripItem(ingredientId: "peas", amount: 500, unit: Unit.grams, freezeOnArrival: true)], - ), - ShoppingTrip( - weekIndex: 1, - items: [TripItem(ingredientId: "peas", amount: 300, unit: Unit.grams)], - ), - ]; - - Set frozen = computeFreezeOnArrivalIngredientIds( - ingredients: const [peas], - remainingByIngredientId: const { - "peas": [Quantity(amount: 800, unit: Unit.grams)], - }, - trips: trips, - ); - - expect(frozen, const {"peas"}); - }); - - test("names nothing when the planner planned no trip", () { - const Ingredient rice = Ingredient(id: "rice", name: "Rice"); - - expect( - computeFreezeOnArrivalIngredientIds( - ingredients: const [rice], - remainingByIngredientId: const { - "rice": [Quantity(amount: 200, unit: Unit.grams)], - }, trips: const [], + tripLabel: (ShoppingTrip trip) => "unused", ), - isEmpty, + throwsA(isA()), ); }); }); diff --git a/menu_management/test/shopping_page_test.dart b/menu_management/test/shopping_page_test.dart index 7e5f286..86ad51f 100644 --- a/menu_management/test/shopping_page_test.dart +++ b/menu_management/test/shopping_page_test.dart @@ -383,10 +383,25 @@ void main() { expect(_copiedTexts.single.split("\n"), const ["now", "---", "Rice - 500 grams/pack: 1 pack"]); }); - testWidgets("copies the simplified list, with no trip section and no pack", (WidgetTester tester) async { + testWidgets("copies the simplified list, with the trip section and no pack", (WidgetTester tester) async { await _pumpAndCopy(tester, format: "Simplified"); - expect(_copiedTexts.single.split("\n"), const ["Rice: 400 grams"]); + expect(_copiedTexts.single.split("\n"), const ["now", "---", "Rice: 400 grams"]); + }); + + testWidgets("says in the simplified list when to buy what", (WidgetTester tester) async { + // The milk keeps 3 days, so the plan buys each week on its own trip. The reader of the + // simplified list must see both trips and the amount of each one. + IngredientsProvider.instance.setData([_milk]); + RecipesProvider.instance.setData([_milkRecipe()], ingredients: [_milk]); + await _pumpShoppingPage(tester, _twoWeekMilkMenu(startDate: DateTime(2025, 8, 6))); + + await tester.tap(find.byTooltip("Export shopping list")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Simplified")); + await tester.pumpAndSettle(); + + expect(_copiedTexts.single.split("\n"), const ["now", "---", "Milk: 600 grams", "", "Tuesday 12 Aug", "--------------", "Milk: 600 grams"]); }); testWidgets("keeps the freeze note of the trip plan in the simplified list", (WidgetTester tester) async { @@ -403,7 +418,7 @@ void main() { await tester.tap(find.text("Simplified")); await tester.pumpAndSettle(); - expect(_copiedTexts.single.split("\n"), const ["Milk: 1,200 grams (freeze on arrival)"]); + expect(_copiedTexts.single.split("\n"), const ["now", "---", "Milk: 1,200 grams (freeze on arrival)"]); }); testWidgets("buys the food of a leftover meal of the next week with its cook event", (WidgetTester tester) async { @@ -421,7 +436,7 @@ void main() { await tester.pumpAndSettle(); expect(_copiedTexts.map((String text) => text.split("\n")).toList(), const [ - ["Rice: 800 grams"], + ["now", "---", "Rice: 800 grams"], ["now", "---", "Rice", " 500 grams/pack: 2 packs", " https://example.com/rice"], ]); }); From 0b5402efb135bde995e9ca1573ebeaa4e8fb180b Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 22:57:02 +0200 Subject: [PATCH 2/2] fix: say that every export splits into the trips in the banner The banner said only the detailed export splits into trips. Every text export and the PDF now write one section per trip. Co-Authored-By: Claude Opus 5.5 --- menu_management/lib/shopping/shopping_page.dart | 2 +- menu_management/test/shopping_page_test.dart | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index 78efe27..3b9cda9 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -401,7 +401,7 @@ class _ShoppingPageState extends State { 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. diff --git a/menu_management/test/shopping_page_test.dart b/menu_management/test/shopping_page_test.dart index 86ad51f..864a641 100644 --- a/menu_management/test/shopping_page_test.dart +++ b/menu_management/test/shopping_page_test.dart @@ -325,7 +325,7 @@ void main() { testWidgets("says now for the only trip when the menu has no first day", (WidgetTester tester) async { await _pumpShoppingPage(tester, _menu()); - expect(find.textContaining("Multi-trip mode: the detailed export splits into 1 trip (now)."), findsOneWidget); + expect(find.textContaining("Multi-trip mode: the exports split into 1 trip (now)."), findsOneWidget); }); testWidgets("says now for the only trip when the menu has a first day", (WidgetTester tester) async { @@ -333,7 +333,7 @@ void main() { // The banner must match the product row, which calls that same trip "now". await _pumpShoppingPage(tester, _menu(startDate: DateTime(2025, 8, 6))); - expect(find.textContaining("Multi-trip mode: the detailed export splits into 1 trip (now)."), findsOneWidget); + expect(find.textContaining("Multi-trip mode: the exports split into 1 trip (now)."), findsOneWidget); }); testWidgets("keeps the real date of a later trip", (WidgetTester tester) async { @@ -342,7 +342,7 @@ void main() { // The menu starts on Wednesday 6 Aug 2025. The second trip happens on day 6, 12 Aug. await _pumpShoppingPage(tester, _twoWeekMilkMenu(startDate: DateTime(2025, 8, 6))); - expect(find.textContaining("Multi-trip mode: the detailed export splits into 2 trips (now, Tuesday 12 Aug)."), findsOneWidget); + expect(find.textContaining("Multi-trip mode: the exports split into 2 trips (now, Tuesday 12 Aug)."), findsOneWidget); }); });