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/` | 1053 tests across 42 files |
| Run all tests | `cd menu_management && flutter test test/` | 1066 tests across 42 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
4 changes: 2 additions & 2 deletions adr/0014-multi-trip-shopping-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ One floating Export button replaced the floating copy button. It opens `showExpo
- Trips are weekly-only by construction. If the user has a real-world cadence like "I shop on Wednesday and Saturday", the planner cannot match it. Adding configurable trip days would mean exposing trip schedules in the UI; deferred until requested.
- The planner originally used the first-matching-unit product for shelf life. ADR 0015 promoted this to an any-match: the longest sealed shelf life among same-unit variants drives trip planning, and any freezable variant makes the ingredient freezable for the freezer-aware mode. The shopping page's pack-display code (`products.first`) is unaffected; only the planner's shelf-life and freezable lookups changed.
- Non-perishables defaulting to trip 0 means a menu of only non-perishables produces a single trip 0, matching the prior single-trip behavior exactly.
- The copied per-trip amounts always equal the on-screen list: same unit, and per-trip lines that sum to the on-screen total with no rounding drift. The planner picks the trips; `distributeRemainingAcrossTrips` splits the page's number across them. If the planner and page ever disagree on which weeks buy an ingredient, the page total still wins because the copy is derived from it.
- The copied per-trip amounts use the unit of the on-screen list, and they sum to the on-screen total with no rounding drift. The trip that a row names can differ from the copied section in one case (see the trip label below). The planner picks the trips; `distributeRemainingAcrossTrips` splits the page's number across them. If the planner and page ever disagree on which weeks buy an ingredient, the page total still wins because the copy is derived from it.
- Owned amounts are tracked per product for ingredients that have products (one count per product), and as one number plus one selected unit for ingredients without products (see ADR 0011 and issue #24). They are not tracked per-trip. The planner subtracts the summed global owned amount from earliest events first, which usually means owned reduces what is bought on the earliest trip. There is no way today for the user to say "I have 100g of X but I want to use it on trip 2". If this comes up we can add per-event owned overrides.
- The on-screen list does not visually sectionize when the toggle is on. The banner under the AppBar is the only on-screen feedback besides the copy output. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner.
- The on-screen list has no trip sections. Each product row shows the trip feedback of its own product. When 2+ trips buy the product, the row shows one line per trip ("Buy 6 packs now" + "+ 3 packs Week 2"). When one later trip buys it, the row shows the total with the label of that trip ("Buy 3 packs Week 2"). That label names the earliest trip that holds the ingredient, because the largest-remainder split can round a small early need down to 0. In that rounding case the row names an earlier trip than the copied section, on purpose: a buy on the earlier trip is safe, and a buy on the later trip comes too late for a recipe. Issue #75 tracks the split itself. A row of an equivalent-product group gets no per-trip split, because the split of the solo count would contradict the one-of-each share. When the first trip of the plan does not hold the ingredient, each member row shows its own share with the same earliest-trip label, also when 2+ later trips buy the ingredient. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner.
- The `single-shopping-trip` assumption in ADR 0010's menu expiry warning is unchanged: that warning still assumes one purchase the day before menu day 0. Multi-trip mode is a property of the shopping list copy, not of the menu warning. Reconciling them (warning aware of trips) is possible later but not part of this change. The freeze-aware single-trip mode is documented in ADR 0015 and likewise does not change the menu warning's single-trip assumption.
53 changes: 44 additions & 9 deletions menu_management/lib/shopping/shopping_ingredient.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ class ShoppingIngredient extends StatefulWidget {
final List<IngredientSource> sources;

/// Planned shopping trips for the whole menu. When 2+ trips buy this ingredient,
/// each product row shows the per-trip buy split instead of a single total.
/// each product row shows the per-trip buy split instead of a single total. When only a later
/// trip buys it, the single total names that trip.
final List<ShoppingTrip> plannedTrips;

/// First day of the menu. It turns the per-trip buy lines into real shopping dates.
Expand Down Expand Up @@ -267,7 +268,14 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
/// same unit and prints the waste-minimal mix. An ingredient with two products of the same unit
/// can therefore read differently on the page and in the copy.
///
/// Returns an empty list (single-total display) unless 2+ trips actually buy this product.
/// Returns an empty list (plain single-total display) when the plan has fewer than 2 trips, when
/// no trip buys this product, or when the earliest trip of the plan holds this ingredient and the
/// split gives the product to one trip only. One trip alone otherwise gives a one-entry list.
///
/// The one-entry list names the earliest trip that holds a [TripItem] of this ingredient, not
/// the trip that the split picked. The largest-remainder split can round a small early need down
/// to 0 and move the whole buy to a later trip. A recipe before that later trip still needs the
/// product, so the row must name the earlier trip.
List<ProductTripPurchase> _tripPurchasesForProduct(Product product) {
if (widget.plannedTrips.length < 2) return const [];
int firstWeek = widget.plannedTrips.first.weekIndex; // trips are sorted ascending by the planner
Expand All @@ -285,9 +293,25 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
if (packs <= 0) continue;
purchases.add(ProductTripPurchase(weekIndex: allocation.weekIndex, packs: packs, isFirstTrip: allocation.weekIndex == firstWeek));
}
// A row is a "split" only when 2+ trips actually buy this product.
if (purchases.length < 2) return const [];
return purchases;
// No purchase gives the plain total, and 2+ purchases give the per-trip split.
if (purchases.length != 1) return purchases;
int? earliestLaterTripWeek = _earliestLaterTripWeekForIngredient();
// The earliest trip of the plan alone gives the plain total, the same as a one-trip plan.
if (earliestLaterTripWeek == null) return const [];
return [ProductTripPurchase(weekIndex: earliestLaterTripWeek, packs: purchases.single.packs, isFirstTrip: false)];
}

/// Returns the week of the earliest planned trip that holds a [TripItem] of this ingredient.
///
/// Returns null when that trip is the first trip of the plan, or when no trip holds the ingredient.
/// The split weighs each trip by the grams of all its items of this ingredient, whatever their
/// unit, so this method finds the trip by ingredient and not by the unit of a product.
int? _earliestLaterTripWeekForIngredient() {
ShoppingTrip? earliestTrip = widget.plannedTrips.firstWhereOrNull(
(ShoppingTrip trip) => trip.items.any((TripItem item) => item.ingredientId == widget.ingredient.id),
);
if (earliestTrip == null || earliestTrip.weekIndex == widget.plannedTrips.first.weekIndex) return null;
return earliestTrip.weekIndex;
}

/// Returns the indexes inside [group] of the equivalent members whose row renders.
Expand Down Expand Up @@ -414,7 +438,8 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
///
/// For an equivalent group of 2+ members the buy count is the cycled share: the group's
/// solo cover ([_packsToBuyForProduct], computed from the still-needed amount so it reflects
/// owned stock) split one-of-each via [distributeEquivalentPacks]. Non-equivalent products
/// owned stock) split one-of-each via [distributeEquivalentPacks]. A member row gets no per-trip
/// split, but it names the trip when one later trip buys the whole cover. Non-equivalent products
/// (different pack size, shelf life, ...) keep their solo count and the per-trip split.
///
/// Every rendered row also hosts its own "Owned" input (per-product owned counts), including the
Expand Down Expand Up @@ -449,6 +474,10 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {

List<int> visibleMembers = isCombinedGroup ? _visibleMemberIndexes(group: group, cycledShares: cycledShares) : const [];

// When the first trip of the plan does not hold this ingredient, each member row names the
// earliest trip that holds it. Otherwise a plain share would read as "buy now".
int? groupEarliestLaterTripWeek = isCombinedGroup ? _earliestLaterTripWeekForIngredient() : null;

bool isFirstVisibleInGroup = true;
for (int memberIndex = 0; memberIndex < group.length; memberIndex++) {
int productIndex = group[memberIndex].key;
Expand Down Expand Up @@ -479,9 +508,15 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
recommendation: recommendation,
isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste,
packsToBuy: packsToBuy,
// The one-of-each cycle already splits an equivalent group; a per-trip split on top
// would show the wrong (solo) counts, so it is only used for standalone products.
tripPurchases: isCombinedGroup ? const [] : _tripPurchasesForProduct(product),
// The one-of-each cycle already splits an equivalent group. A per-trip split on top
// would show the solo counts, so a member row gets no 2+ split. When the first trip of
// the plan does not hold the ingredient, the member row gets one entry with its own
// cycled share, so the row names the earliest trip that holds it.
tripPurchases: !isCombinedGroup
? _tripPurchasesForProduct(product)
: groupEarliestLaterTripWeek != null
? [ProductTripPurchase(weekIndex: groupEarliestLaterTripWeek, packs: packsToBuy, isFirstTrip: false)]
: const [],
startDate: widget.startDate,
ownedCount: widget.ownedProductCounts[productIndex] ?? 0,
onOwnedCountChanged: (double count) => widget.onProductOwnedChanged(productIndex, count),
Expand Down
23 changes: 16 additions & 7 deletions menu_management/lib/shopping/shopping_product_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class ShoppingProductRow extends StatefulWidget {
final int packsToBuy;

/// Per-trip split of [packsToBuy]. When it has 2+ entries the buy area shows one line per
/// trip (e.g. "Buy 6 packs now" + "3 packs week 2"); otherwise it shows a single total.
/// trip (e.g. "Buy 6 packs now" + "+ 3 packs Week 2"). With 1 entry it shows the single total
/// with that trip's label (e.g. "Buy 3 packs Week 2"). When it is empty it shows the plain total.
final List<ProductTripPurchase> tripPurchases;

/// First day of the menu. When it is set, a later trip line names its real shopping date
Expand Down Expand Up @@ -91,21 +92,25 @@ class _ShoppingProductRowState extends State<ShoppingProductRow> {
/// is actually bought when owned stock did not change the count, which holds exactly when
/// `packsToBuy == recommendation.packsNeeded + 1` (packsNeeded is already the reduced count).
/// Also suppressed in the multi-trip split (2+ purchases), where the per-trip lines render the
/// full round-up, so a "buying less" chip would contradict them.
/// full round-up, so a "buying less" chip would contradict them. A single trip purchase keeps
/// the reduction, because its line shows [packsToBuy] minus one, not the purchase's packs.
bool get _appliesUnderBuy =>
widget.recommendation.underBuy && widget.tripPurchases.length < 2 && widget.packsToBuy == widget.recommendation.packsNeeded + 1;

String _tripPurchaseLabel(ProductTripPurchase purchase, {required bool isFirstLine}) {
String prefix = isFirstLine ? "Buy" : "+";
// shoppingTripLabel owns the whole rule: "now" for the first trip, a real date after it,
// and "Week N" without a start date.
String when = shoppingTripLabel(
return "$prefix ${purchase.packs} ${_packWord(purchase.packs)} ${_tripWhen(purchase)}";
}

/// Names the trip of [purchase]. shoppingTripLabel owns the whole rule: "now" for the first
/// trip, a real date after it, and "Week N" without a start date.
String _tripWhen(ProductTripPurchase purchase) {
return shoppingTripLabel(
startDate: widget.startDate,
weekIndex: purchase.weekIndex,
tripDay: ShoppingTrip.dayForWeek(purchase.weekIndex),
isFirstTrip: purchase.isFirstTrip,
);
return "$prefix ${purchase.packs} ${_packWord(purchase.packs)} $when";
}

String _wasteBreakdown() {
Expand Down Expand Up @@ -283,7 +288,11 @@ class _ShoppingProductRowState extends State<ShoppingProductRow> {
],
)
: Text(
"Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)}",
// A single trip purchase names its trip. The count stays effectivePacksToBuy,
// so the under-buy reduction applies to this line too.
widget.tripPurchases.length == 1
? "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)} ${_tripWhen(widget.tripPurchases.single)}"
: "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)}",
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.right,
),
Expand Down
Loading
Loading