From ce04d04c882905667111100a0e249591e7fcc172 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 22:39:18 +0200 Subject: [PATCH 1/3] feat(shopping): enter owned quantity per product Before, the shopping list took one "owned" amount per ingredient in a single unit. A user could not say "I have 3 of pack A and 5 of pack B" when an ingredient has several products of different pack sizes. Now each product row has its own owned count input. The ingredient's global owned amount is summed from each product's count times its pack quantity, converted with the ingredient's existing grams/ml conversions (toGrams / fromGrams). That summed amount feeds the existing remaining-to-buy and multi-trip planning logic unchanged. OwnedStock gains a perProduct shape (a count per product) alongside the existing single amount+unit shape, and a shared amountInUnit resolver so the on-screen "Need" and the copied trip amounts still match exactly. Ingredients with no products keep the single header owned input. Updates ADR 0010 and ADR 0014, which documented owned as one amount per ingredient. Closes #24 Co-Authored-By: Claude Opus 4.8 --- adr/0010-product-entity-store-products.md | 2 +- adr/0014-multi-trip-shopping-planner.md | 9 +- .../lib/shopping/multi_trip_planner.dart | 4 +- .../lib/shopping/owned_amount.dart | 63 +++++++++++++- .../lib/shopping/shopping_ingredient.dart | 18 +++- .../lib/shopping/shopping_page.dart | 56 +++++++++---- .../lib/shopping/shopping_product_row.dart | 83 +++++++++++++++---- .../test/multi_trip_planner_test.dart | 26 ++++++ menu_management/test/owned_amount_test.dart | 82 ++++++++++++++++++ .../test/shopping_ingredient_test.dart | 62 ++++++++++++++ .../test/shopping_product_row_test.dart | 21 +++++ 11 files changed, 381 insertions(+), 45 deletions(-) diff --git a/adr/0010-product-entity-store-products.md b/adr/0010-product-entity-store-products.md index 21e3a61..1c82202 100644 --- a/adr/0010-product-entity-store-products.md +++ b/adr/0010-product-entity-store-products.md @@ -41,7 +41,7 @@ Product data is edited via a `ProductEditor` dialog accessible from the ingredie - `Product` data is persisted inside the existing `.tsr` format with no format version bump; old `.tsr` files without product data load correctly because `products` defaults to an empty list. - Pack counts are rounded up (ceiling) so the shopper always buys enough. - The shopping list currently uses only the first product for display. Future work (issue #3) will use multiple products to recommend optimal pack-size combinations per cooking event. -- The shopping list does not track owned packs -- it continues to track owned quantities in the ingredient's native unit. The pack display is presentation-only. +- The shopping list tracks owned quantity per product for ingredients that have products (a count of each product the user owns), and in the ingredient's native unit for ingredients without products. The global owned amount is summed from the per-product counts times each pack quantity via the ingredient's conversions (see ADR 0014 and issue #24). - Splitting shelf life into `shelfLifeDaysOpened` and `shelfLifeDaysClosed` lets the two waste/expiry features share data without coupling: the optimizer never reads the closed value and the menu warning never reads the opened value. - The single-shopping-trip assumption is intentionally strict for multi-week menus: a fresh-meat product with `shelfLifeDaysClosed = 2` will warn for any meal beyond Sunday of week 1. If this proves too noisy in practice, the assumption can be relaxed (e.g., one shopping trip per week) by adjusting the way `absoluteDayIndex` is computed before being passed to `mayBeExpiredOnDay`. The model rule itself stays unchanged. When `canBeFrozen` is true on a variant the warning still fires on the same day; only the severity rendered changes (see ADR 0015). - Old `.tsr` files keep loading because of the `shelfLifeDays` -> `shelfLifeDaysOpened` migration; new saves never write the legacy key. diff --git a/adr/0014-multi-trip-shopping-planner.md b/adr/0014-multi-trip-shopping-planner.md index 2a85a41..779bdc4 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -34,7 +34,12 @@ Shelf life is read from the same-unit product variants of the ingredient. ADR 00 ### Owned amounts -`planShoppingTrips` accepts an optional `ownedAmounts: Map`, where `OwnedStock` (in `owned_amount.dart`) is the user's stock as one amount plus one selected unit (or null for "packs"). For each cooking event the planner converts that stock into the event's unit via the shared `ownedAmountInUnit` (in `owned_amount.dart`), then consumes it against the matching-unit events in chronological order before trips are computed. `ownedAmountInUnit` handles same-unit, cross-unit (grams <-> pieces via `gramsPerPiece`, weight <-> volume via `density`), and "packs" mode (the product whose unit matches the target unit). The on-screen shopping list (`_ownedInUnit` in `shopping_page.dart`) calls the same function, so the copied trip amounts always equal the on-screen "Need" amounts. When no conversion path exists (for example owned pieces with no `gramsPerPiece`), nothing is subtracted. +`planShoppingTrips` accepts an optional `ownedAmounts: Map`, where `OwnedStock` (in `owned_amount.dart`) has two shapes (see ADR 0011 and issue #24): + +- **Single-form** (`OwnedStock(amount, unit)`): one amount plus one selected unit (or null for "packs"). Used for ingredients with no products. +- **Per-product** (`OwnedStock.perProduct(countsByProductIndex)`): one owned count per product of the ingredient. The global owned amount is summed from each product's count times its pack quantity. Used for ingredients that have products, so the user can say "I have 3 of product A and 5 of product B". + +Both shapes resolve to an amount in a target unit through `OwnedStock.amountInUnit(ingredient, targetUnit)`. Single-form delegates to the shared `ownedAmountInUnit`; per-product sums each product's contribution via `productOwnedAmountInUnit` (count times pack quantity, converted with the ingredient's `toGrams`/`fromGrams`). For each cooking event the planner resolves the stock into the event's unit via `amountInUnit`, then consumes it against the matching-unit events in chronological order before trips are computed. `ownedAmountInUnit` handles same-unit, cross-unit (grams <-> pieces via `gramsPerPiece`, weight <-> volume via `density`), and "packs" mode (the product whose unit matches the target unit). The on-screen shopping list (`_ownedInUnit` in `shopping_page.dart`) calls the same `amountInUnit`, so the copied trip amounts always equal the on-screen "Need" amounts. When no conversion path exists (for example owned pieces with no `gramsPerPiece`), nothing is subtracted. Earlier this planner did no cross-unit conversion: it took owned as `Map>` and subtracted only when the owned unit exactly matched the event unit, and the shopping page converted "packs" using the ingredient's first product. That diverged from the on-screen list (which converted correctly), so the copied list could list a higher amount to buy than the page showed. The shared `ownedAmountInUnit` removed the divergence. @@ -51,6 +56,6 @@ ADR 0015 supersedes this UI: the OFF mode (flat list, ignore shelf life) was dro - 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. -- Owned amounts are still tracked at the ingredient level (one number, one selected unit), not per-trip. The planner subtracts owned 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. +- 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 `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. diff --git a/menu_management/lib/shopping/multi_trip_planner.dart b/menu_management/lib/shopping/multi_trip_planner.dart index 4ae1772..c493e33 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -174,9 +174,7 @@ List<_PlanEvent> _buildPlanEvents({ double remainingNeed = quantity.amount; double ownedRemaining = ownedRemainingByUnit.putIfAbsent( quantity.unit, - () => (owned == null || ingredient == null) - ? 0 - : ownedAmountInUnit(ingredient: ingredient, ownedAmount: owned.amount, ownedUnit: owned.unit, targetUnit: quantity.unit), + () => (owned == null || ingredient == null) ? 0 : owned.amountInUnit(ingredient: ingredient, targetUnit: quantity.unit), ); if (ownedRemaining > 0 && remainingNeed > 0) { double consumed = min(ownedRemaining, remainingNeed); diff --git a/menu_management/lib/shopping/owned_amount.dart b/menu_management/lib/shopping/owned_amount.dart index 001e6de..63f1112 100644 --- a/menu_management/lib/shopping/owned_amount.dart +++ b/menu_management/lib/shopping/owned_amount.dart @@ -5,15 +5,72 @@ import "package:menu_management/recipes/models/quantity.dart"; /// A user's owned stock of one ingredient, as entered on the shopping page. /// -/// [unit] is the unit the user picked in the "owned" dropdown. It is null when -/// the user picked "packs" (product-relative), matching [Unit]? null everywhere. +/// Two shapes exist: +/// - Single-form ([OwnedStock.new]): one [amount] plus one selected [unit]. Used for ingredients +/// with no products, where the user types a single number in the desired unit. [unit] is null +/// when the user picked "packs" (product-relative), matching [Unit]? null everywhere. +/// - Per-product ([OwnedStock.perProduct]): one owned count per product of the ingredient +/// ([countsByProductIndex] maps a product's index in [Ingredient.products] to how many of that +/// product the user owns). The global owned amount is summed from each product's count times its +/// pack quantity via the ingredient's conversions. Used for ingredients that have products. +/// +/// Both shapes resolve to an amount in a target unit through [amountInUnit], so the on-screen list +/// and the multi-trip planner always subtract the same amount. class OwnedStock { - const OwnedStock({required this.amount, required this.unit}); + const OwnedStock({required this.amount, required this.unit}) : countsByProductIndex = null; + + const OwnedStock.perProduct({required Map this.countsByProductIndex}) : amount = 0, unit = null; final double amount; /// null means "packs". final Unit? unit; + + /// Per-product owned counts (product index in [Ingredient.products] -> owned count). + /// null for single-form stock. + final Map? countsByProductIndex; + + /// Whether the user owns anything at all. Lets callers skip empty stock. + bool get hasStock { + final Map? counts = countsByProductIndex; + if (counts == null) return amount > 0; + return counts.values.any((double count) => count > 0); + } + + /// The owned amount expressed in [targetUnit] for [ingredient], using the shared converters. + /// + /// Single-form stock delegates to [ownedAmountInUnit]. Per-product stock sums each owned + /// product's contribution via [productOwnedAmountInUnit]. + double amountInUnit({required Ingredient ingredient, required Unit targetUnit}) { + final Map? counts = countsByProductIndex; + if (counts == null) { + return ownedAmountInUnit(ingredient: ingredient, ownedAmount: amount, ownedUnit: unit, targetUnit: targetUnit); + } + double total = 0; + for (MapEntry entry in counts.entries) { + int index = entry.key; + if (index < 0 || index >= ingredient.products.length) continue; + total += productOwnedAmountInUnit(ingredient: ingredient, product: ingredient.products[index], count: entry.value, targetUnit: targetUnit); + } + return total; + } +} + +/// Converts an owned [count] of a single [product] of [ingredient] into [targetUnit]. +/// +/// The count is a number of packs of that product. It is first turned into an amount in the +/// product's own unit (`count * totalQuantityPerPack`), then converted to [targetUnit] via the +/// ingredient's conversions (grams bridge through `density` for volume, `gramsPerPiece` for +/// pieces). Returns 0 when the count is non-positive or no conversion path exists. +double productOwnedAmountInUnit({required Ingredient ingredient, required Product product, required double count, required Unit targetUnit}) { + if (count <= 0) return 0; + double amountInProductUnit = count * product.totalQuantityPerPack; + if (product.unit == targetUnit) return amountInProductUnit; + + double? grams = ingredient.toGrams(Quantity(amount: amountInProductUnit, unit: product.unit)); + if (grams == null) return 0; + if (targetUnit == Unit.grams) return grams; + return ingredient.fromGrams(grams, targetUnit) ?? 0; } /// Converts a user's owned amount into [targetUnit] for an ingredient. diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 0588a83..99365c0 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -58,6 +58,8 @@ class ShoppingIngredient extends StatefulWidget { required this.ownedAmount, required this.ownedUnit, required this.onOwnedChanged, + required this.ownedProductCounts, + required this.onProductOwnedChanged, required this.sources, required this.plannedTrips, }); @@ -66,9 +68,17 @@ class ShoppingIngredient extends StatefulWidget { final List quantitiesDesired; final List calculatedRemainingQuantities; final List productRecommendations; + + /// Single owned input, used only when the ingredient has no products. final double ownedAmount; final OwnedUnit ownedUnit; final void Function(double amount, OwnedUnit unit) onOwnedChanged; + + /// Owned count per product (product index in [Ingredient.products] -> count), used when the + /// ingredient has products. Each product row shows its own owned input. + final Map ownedProductCounts; + final void Function(int productIndex, double count) onProductOwnedChanged; + final List sources; /// Planned shopping trips for the whole menu. When 2+ trips buy this ingredient, @@ -265,8 +275,9 @@ class _ShoppingIngredientState extends State { ), ), - // Owned quantity input with unit dropdown - if (availableUnits.isNotEmpty) ...[ + // Owned quantity input with unit dropdown. + // Only for ingredients with no products; products use per-product owned inputs in each row. + if (widget.ingredient.products.isEmpty && availableUnits.isNotEmpty) ...[ SizedBox( width: 120, child: TextField( @@ -358,6 +369,7 @@ class _ShoppingIngredientState extends State { .toList(); List rows = []; for (int i = 0; i < matchingProducts.length; i++) { + int productIndex = matchingProducts[i].key; Product product = matchingProducts[i].value; ProductRecommendation recommendation = widget.productRecommendations.firstWhere( (r) => r.product == product, @@ -387,6 +399,8 @@ class _ShoppingIngredientState extends State { isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste, packsToBuy: _packsToBuyForProduct(product), tripPurchases: _tripPurchasesForProduct(product), + ownedCount: widget.ownedProductCounts[productIndex] ?? 0, + onOwnedCountChanged: (double count) => widget.onProductOwnedChanged(productIndex, count), ), ); } diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index cf54c5a..c2909d9 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -30,12 +30,18 @@ class ShoppingPage extends StatefulWidget { class _ShoppingPageState extends State { late final Map> ingredientsRequired; - /// Owned amount per ingredient (raw number entered by user). + /// Owned amount per ingredient (raw number entered by user). Used only for ingredients with + /// no products, where the user types a single number in the selected unit. late final Map ownedAmounts; - /// Selected unit for owned input per ingredient. + /// Selected unit for owned input per ingredient (no-products ingredients only). late final Map ownedUnits; + /// Owned count per product for ingredients that have products, keyed by ingredient id then by + /// the product's index in [Ingredient.products]. The global owned amount is derived from these + /// counts via [OwnedStock.perProduct]. + late final Map> ownedProductCounts; + /// Cooking event timeline per ingredient (for event-based waste calculation). late final Map> cookingTimeline; @@ -60,6 +66,7 @@ class _ShoppingPageState extends State { ownedAmounts = {}; ownedUnits = {}; + ownedProductCounts = {}; for (MapEntry> entry in ingredientsRequired.entries) { String ingredientId = entry.key; @@ -67,7 +74,18 @@ class _ShoppingPageState extends State { ownedAmounts[ingredientId] = 0; ownedUnits[ingredientId] = defaultOwnedUnit(ingredient: ingredient, desiredQuantities: entry.value); + ownedProductCounts[ingredientId] = {}; + } + } + + /// Builds the owned stock for an ingredient: per-product counts when it has products, + /// otherwise the single amount + selected unit. Both resolve to the same units via + /// [OwnedStock.amountInUnit], so the on-screen list and the planner subtract the same amount. + OwnedStock _ownedStockFor({required String ingredientId, required Ingredient ingredient}) { + if (ingredient.products.isNotEmpty) { + return OwnedStock.perProduct(countsByProductIndex: ownedProductCounts[ingredientId] ?? const {}); } + return OwnedStock(amount: ownedAmounts[ingredientId] ?? 0, unit: ownedUnits[ingredientId]?.unit); } @override @@ -139,6 +157,7 @@ class _ShoppingPageState extends State { productRecommendations: recommendations, ownedAmount: ownedAmounts[ingredientId] ?? 0, ownedUnit: ownedUnits[ingredientId] ?? const OwnedUnit(unit: Unit.grams), + ownedProductCounts: ownedProductCounts[ingredientId] ?? const {}, sources: ingredientSources[ingredientId] ?? [], plannedTrips: plannedTrips, onOwnedChanged: (double amount, OwnedUnit unit) { @@ -147,19 +166,21 @@ class _ShoppingPageState extends State { ownedUnits[ingredientId] = unit; }); }, + onProductOwnedChanged: (int productIndex, double count) { + setState(() { + (ownedProductCounts[ingredientId] ??= {})[productIndex] = count; + }); + }, ); }, ), ); } - /// Converts owned amount to the actual quantity in [targetUnit] based on the selected owned unit. - /// Delegates to the shared [ownedAmountInUnit] so the on-screen list and the trip planner subtract - /// the same amount. + /// Converts the owned stock of an ingredient into [targetUnit]. Delegates to the shared + /// [OwnedStock.amountInUnit] so the on-screen list and the trip planner subtract the same amount. double _ownedInUnit({required String ingredientId, required Ingredient ingredient, required Unit targetUnit}) { - double amount = ownedAmounts[ingredientId] ?? 0; - OwnedUnit selectedUnit = ownedUnits[ingredientId] ?? OwnedUnit(unit: targetUnit); - return ownedAmountInUnit(ingredient: ingredient, ownedAmount: amount, ownedUnit: selectedUnit.unit, targetUnit: targetUnit); + return _ownedStockFor(ingredientId: ingredientId, ingredient: ingredient).amountInUnit(ingredient: ingredient, targetUnit: targetUnit); } List _remainingAmounts({required String ingredientId, required Ingredient ingredient}) { @@ -265,18 +286,19 @@ class _ShoppingPageState extends State { List _planTrips() { List allIngredients = IngredientsProvider.instance.ingredients; + Map ingredientsById = {for (Ingredient ingredient in allIngredients) ingredient.id: ingredient}; - // Pass the user's owned stock as-is (one amount + one selected unit, or "packs"). - // The planner converts it into each event's unit via the shared ownedAmountInUnit, - // so it subtracts exactly what the on-screen list subtracts. + // Build each ingredient's owned stock (per-product counts, or a single amount + unit for + // no-products ingredients). The planner resolves it into each event's unit via the shared + // OwnedStock.amountInUnit, so it subtracts exactly what the on-screen list subtracts. Map ownedStockPerIngredient = {}; - for (MapEntry entry in ownedAmounts.entries) { - String ingredientId = entry.key; - double amount = entry.value; - if (amount <= 0) continue; + for (String ingredientId in ingredientsRequired.keys) { + Ingredient? ingredient = ingredientsById[ingredientId]; + if (ingredient == null) continue; - OwnedUnit selectedUnit = ownedUnits[ingredientId] ?? const OwnedUnit(unit: Unit.grams); - ownedStockPerIngredient[ingredientId] = OwnedStock(amount: amount, unit: selectedUnit.unit); + OwnedStock stock = _ownedStockFor(ingredientId: ingredientId, ingredient: ingredient); + if (!stock.hasStock) continue; + ownedStockPerIngredient[ingredientId] = stock; } return planShoppingTrips( diff --git a/menu_management/lib/shopping/shopping_product_row.dart b/menu_management/lib/shopping/shopping_product_row.dart index 7b3ff54..6339b27 100644 --- a/menu_management/lib/shopping/shopping_product_row.dart +++ b/menu_management/lib/shopping/shopping_product_row.dart @@ -15,7 +15,7 @@ class ProductTripPurchase { final bool isFirstTrip; // true for the plan's earliest trip -> labeled "now" } -class ShoppingProductRow extends StatelessWidget { +class ShoppingProductRow extends StatefulWidget { const ShoppingProductRow({ super.key, required this.product, @@ -23,6 +23,8 @@ class ShoppingProductRow extends StatelessWidget { required this.isBestOption, required this.packsToBuy, this.tripPurchases = const [], + this.ownedCount = 0, + this.onOwnedCountChanged, }); final Product product; @@ -37,8 +39,36 @@ class ShoppingProductRow extends StatelessWidget { /// trip (e.g. "Buy 6 packs now" + "3 packs week 2"); otherwise it shows a single total. final List tripPurchases; + /// How many of this product the user already owns. Seeds the owned input. + final double ownedCount; + + /// Called with the new owned count when the user edits the owned input. + /// When null, the owned input is hidden (the row is display-only). + final ValueChanged? onOwnedCountChanged; + + @override + State createState() => _ShoppingProductRowState(); +} + +class _ShoppingProductRowState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.ownedCount > 0 ? _formatCount(widget.ownedCount) : ""); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String _formatCount(double value) => value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1); + /// Singular/plural unit word: pieces for single-item packs, packs otherwise. - String _packWord(int count) => product.itemsPerPack == 1 ? (count == 1 ? "piece" : "pieces") : (count == 1 ? "pack" : "packs"); + String _packWord(int count) => widget.product.itemsPerPack == 1 ? (count == 1 ? "piece" : "pieces") : (count == 1 ? "pack" : "packs"); String _tripPurchaseLabel(ProductTripPurchase purchase, {required bool isFirstLine}) { String prefix = isFirstLine ? "Buy" : "+"; @@ -47,9 +77,9 @@ class ShoppingProductRow extends StatelessWidget { } String _wasteBreakdown() { - String unit = product.unit.name; - double over = recommendation.overBuyWaste; - double expiry = recommendation.expiryWaste; + String unit = widget.product.unit.name; + double over = widget.recommendation.overBuyWaste; + double expiry = widget.recommendation.expiryWaste; List parts = []; if (over > 0) parts.add("${over.toFormattedAmount()} $unit surplus from buying whole packs"); @@ -58,8 +88,8 @@ class ShoppingProductRow extends StatelessWidget { } Widget _buildChip(BuildContext context) { - double totalWaste = recommendation.totalWaste; - String unit = product.unit.name; + double totalWaste = widget.recommendation.totalWaste; + String unit = widget.product.unit.name; // No waste: green chip if (totalWaste == 0) { @@ -78,7 +108,7 @@ class ShoppingProductRow extends StatelessWidget { String wasteLabel = "${totalWaste.toFormattedAmount()} $unit waste"; // Best option (or tied for best): blue/teal chip with waste amount - if (isBestOption) { + if (widget.isBestOption) { return Tooltip( message: "Least total waste among available options.\n${_wasteBreakdown()}", child: Chip( @@ -109,13 +139,13 @@ class ShoppingProductRow extends StatelessWidget { @override Widget build(BuildContext context) { - String? packLabel = product.packLabel(); - String totalLabel = "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack"; - bool covered = packsToBuy <= 0; + String? packLabel = widget.product.packLabel(); + String totalLabel = "${widget.product.totalQuantityPerPack.toFormattedAmount()} ${widget.product.unit.name}/pack"; + bool covered = widget.packsToBuy <= 0; return FilledCard( outlined: true, - borderColor: isBestOption && recommendation.totalWaste > 0 ? ThemeCustom.colorScheme(context).tertiary : null, + borderColor: widget.isBestOption && widget.recommendation.totalWaste > 0 ? ThemeCustom.colorScheme(context).tertiary : null, color: ThemeCustom.colorScheme(context).secondaryContainer.withValues(alpha: covered ? 0.3 : 1), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), @@ -141,6 +171,25 @@ class ShoppingProductRow extends StatelessWidget { ), const SizedBox(width: 8), + // Per-product owned count input (how many of this product the user already has) + if (widget.onOwnedCountChanged != null) ...[ + SizedBox( + width: 90, + child: TextField( + controller: _controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: "Owned", border: OutlineInputBorder(), isDense: true), + onChanged: (String value) { + double? parsed = double.tryParse(value); + if (value.isNullOrEmpty) parsed = 0; + if (parsed == null) return; + widget.onOwnedCountChanged!(parsed); + }, + ), + ), + const SizedBox(width: 8), + ], + // Waste status chip (shown for all products) Padding(padding: const EdgeInsets.only(right: 8), child: _buildChip(context)), @@ -148,7 +197,7 @@ class ShoppingProductRow extends StatelessWidget { IconButton( icon: const Icon(Icons.open_in_new_rounded, size: 20), tooltip: "Open product page", - onPressed: product.link.isEmpty ? null : () => Process.run("start", [product.link], runInShell: true), + onPressed: widget.product.link.isEmpty ? null : () => Process.run("start", [widget.product.link], runInShell: true), ), const Spacer(), @@ -165,20 +214,20 @@ class ShoppingProductRow extends StatelessWidget { Text("Covered", style: TextStyle(color: Theme.of(context).hintColor)), ], ) - : tripPurchases.length >= 2 + : widget.tripPurchases.length >= 2 ? Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - for (int i = 0; i < tripPurchases.length; i++) + for (int i = 0; i < widget.tripPurchases.length; i++) Text( - _tripPurchaseLabel(tripPurchases[i], isFirstLine: i == 0), + _tripPurchaseLabel(widget.tripPurchases[i], isFirstLine: i == 0), style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.right, ), ], ) : Text( - "Buy $packsToBuy ${_packWord(packsToBuy)}", + "Buy ${widget.packsToBuy} ${_packWord(widget.packsToBuy)}", style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.right, ), diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 312c194..81aa741 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -281,6 +281,32 @@ void main() { expect(trips.first.items.first.unit, Unit.grams); }); + test("per-product owned stock reduces the trip amount by the summed global owned amount", () { + // Recipe needs 1000 g. Two grams products: 500 g/pack and 250 g/pack. + // Owning 1 of the 500 g pack + 1 of the 250 g pack -> 750 g owned -> 250 g left to buy. + Ingredient item = _ingredient( + id: "i1", + products: [ + Product(link: "https://example.com/a", unit: Unit.grams, quantityPerItem: 500), + Product(link: "https://example.com/b", unit: Unit.grams, quantityPerItem: 250), + ], + ); + Map> timeline = { + "i1": [_event(day: 0, amount: 1000, unit: Unit.grams)], + }; + + List trips = planShoppingTrips( + cookingTimeline: timeline, + ingredients: [item], + ownedAmounts: { + "i1": OwnedStock.perProduct(countsByProductIndex: {0: 1, 1: 1}), + }, + ); + + expect(trips.first.items.first.amount, 250); + expect(trips.first.items.first.unit, Unit.grams); + }); + test("event whose shelf life cannot be satisfied by any prior trip falls back to closest trip", () { // Shelf life 1 day, event on day 10. No trip satisfies fresh constraint: // Trip 0 (-1): 11 days → expired. Trip 1 (6): 4 days → expired. Trip 2 (13): purchase after use. diff --git a/menu_management/test/owned_amount_test.dart b/menu_management/test/owned_amount_test.dart index 5d5100b..c892160 100644 --- a/menu_management/test/owned_amount_test.dart +++ b/menu_management/test/owned_amount_test.dart @@ -61,4 +61,86 @@ void main() { expect(ownedAmountInUnit(ingredient: banana, ownedAmount: 3, ownedUnit: Unit.pieces, targetUnit: Unit.grams), 0); }); }); + + group("productOwnedAmountInUnit", () { + test("count of a product times its pack quantity, when the product unit is the target unit", () { + // Product: 6 items x 125 g = 750 g per pack. Owning 2 packs -> 1500 g. + Product product = _product(unit: Unit.grams, quantityPerItem: 125, itemsPerPack: 6); + Ingredient ingredient = Ingredient(id: "i1", name: "Item", products: [product]); + expect(productOwnedAmountInUnit(ingredient: ingredient, product: product, count: 2, targetUnit: Unit.grams), 1500); + }); + + test("converts a piece product's owned count into grams via gramsPerPiece", () { + // 3 pieces owned, gramsPerPiece 120 -> 360 g. + Product product = _product(unit: Unit.pieces, quantityPerItem: 1); + Ingredient banana = Ingredient(id: "banana", name: "Banana", gramsPerPiece: 120, products: [product]); + expect(productOwnedAmountInUnit(ingredient: banana, product: product, count: 3, targetUnit: Unit.grams), 360); + }); + + test("returns the count in pieces directly when target is pieces (no gramsPerPiece needed)", () { + Product product = _product(unit: Unit.pieces, quantityPerItem: 1); + Ingredient eggs = Ingredient(id: "eggs", name: "Eggs", products: [product]); + expect(productOwnedAmountInUnit(ingredient: eggs, product: product, count: 4, targetUnit: Unit.pieces), 4); + }); + + test("returns 0 when a cross-unit conversion has no bridge", () { + // Pieces product, target grams, no gramsPerPiece -> cannot convert. + Product product = _product(unit: Unit.pieces, quantityPerItem: 1); + Ingredient eggs = Ingredient(id: "eggs", name: "Eggs", products: [product]); + expect(productOwnedAmountInUnit(ingredient: eggs, product: product, count: 4, targetUnit: Unit.grams), 0); + }); + + test("returns 0 for a non-positive count", () { + Product product = _product(unit: Unit.grams, quantityPerItem: 100); + Ingredient ingredient = Ingredient(id: "i1", name: "Item", products: [product]); + expect(productOwnedAmountInUnit(ingredient: ingredient, product: product, count: 0, targetUnit: Unit.grams), 0); + }); + }); + + group("OwnedStock.amountInUnit", () { + test("single-form stock delegates to ownedAmountInUnit", () { + Ingredient flour = const Ingredient(id: "flour", name: "Flour"); + const OwnedStock stock = OwnedStock(amount: 250, unit: Unit.grams); + expect(stock.amountInUnit(ingredient: flour, targetUnit: Unit.grams), 250); + }); + + test("per-product stock sums two products of different pack sizes into grams", () { + // Product A: 500 g per pack. Product B: 250 g per pack. + // Owning 3 of A + 5 of B -> 1500 + 1250 = 2750 g. + Product a = _product(unit: Unit.grams, quantityPerItem: 500); + Product b = _product(unit: Unit.grams, quantityPerItem: 250); + Ingredient ingredient = Ingredient(id: "i1", name: "Item", products: [a, b]); + OwnedStock stock = OwnedStock.perProduct(countsByProductIndex: {0: 3, 1: 5}); + expect(stock.amountInUnit(ingredient: ingredient, targetUnit: Unit.grams), 2750); + }); + + test("per-product stock mixes a weight product and a piece product via gramsPerPiece", () { + // Product 0: 200 g per pack (weight). Product 1: pieces, gramsPerPiece 50. + // Owning 1 weight pack (200 g) + 2 pieces (100 g) -> 300 g. + Product weight = _product(unit: Unit.grams, quantityPerItem: 200); + Product pieces = _product(unit: Unit.pieces, quantityPerItem: 1); + Ingredient ingredient = Ingredient(id: "i1", name: "Item", gramsPerPiece: 50, products: [weight, pieces]); + OwnedStock stock = OwnedStock.perProduct(countsByProductIndex: {0: 1, 1: 2}); + expect(stock.amountInUnit(ingredient: ingredient, targetUnit: Unit.grams), 300); + }); + + test("per-product stock reports the global owned amount in a target unit via fromGrams", () { + // 4 pieces owned, gramsPerPiece 120 -> 480 g -> back to 4 pieces. + Product pieces = _product(unit: Unit.pieces, quantityPerItem: 1); + Ingredient banana = Ingredient(id: "banana", name: "Banana", gramsPerPiece: 120, products: [pieces]); + OwnedStock stock = OwnedStock.perProduct(countsByProductIndex: {0: 4}); + expect(stock.amountInUnit(ingredient: banana, targetUnit: Unit.grams), 480); + expect(stock.amountInUnit(ingredient: banana, targetUnit: Unit.pieces), 4); + }); + + test("hasStock is false when nothing is owned and true when some product is owned", () { + Product a = _product(unit: Unit.grams, quantityPerItem: 500); + expect(OwnedStock.perProduct(countsByProductIndex: const {0: 0}).hasStock, isFalse); + expect(OwnedStock.perProduct(countsByProductIndex: const {0: 2}).hasStock, isTrue); + expect(const OwnedStock(amount: 0, unit: Unit.grams).hasStock, isFalse); + expect(const OwnedStock(amount: 5, unit: Unit.grams).hasStock, isTrue); + // Reference the product so the analyzer does not flag it as unused. + expect(a.totalQuantityPerPack, 500); + }); + }); } diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index b64e7d4..8e834aa 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -30,6 +30,8 @@ Future _pumpIngredient(WidgetTester tester, {required double remainingGram ownedAmount: 0, ownedUnit: const OwnedUnit(), onOwnedChanged: (double amount, OwnedUnit unit) {}, + ownedProductCounts: const {}, + onProductOwnedChanged: (int productIndex, double count) {}, sources: const [], plannedTrips: plannedTrips, ), @@ -38,6 +40,33 @@ Future _pumpIngredient(WidgetTester tester, {required double remainingGram ); } +Future _pumpForOwnedInputs( + WidgetTester tester, { + required Ingredient ingredient, + required List quantitiesDesired, + void Function(int productIndex, double count)? onProductOwnedChanged, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ShoppingIngredient( + ingredient: ingredient, + quantitiesDesired: quantitiesDesired, + calculatedRemainingQuantities: const [Quantity(amount: 100, unit: Unit.grams)], + productRecommendations: const [], + ownedAmount: 0, + ownedUnit: const OwnedUnit(unit: Unit.grams), + onOwnedChanged: (double amount, OwnedUnit unit) {}, + ownedProductCounts: const {}, + onProductOwnedChanged: onProductOwnedChanged ?? (int productIndex, double count) {}, + sources: const [], + plannedTrips: const [], + ), + ), + ), + ); +} + void main() { group("ShoppingIngredient per-trip buy split", () { testWidgets("renders the split lines when 2+ trips buy the product", (WidgetTester tester) async { @@ -88,6 +117,39 @@ void main() { expect(find.textContaining("now"), findsNothing); }); + testWidgets("an ingredient with products shows a per-product owned input, not the header input", (WidgetTester tester) async { + double? reportedIndex; + double? reportedCount; + await _pumpForOwnedInputs( + tester, + ingredient: _ingredient(), + quantitiesDesired: const [Quantity(amount: 900, unit: Unit.grams)], + onProductOwnedChanged: (int productIndex, double count) { + reportedIndex = productIndex.toDouble(); + reportedCount = count; + }, + ); + + // The per-product owned field lives in the product row. + Finder ownedField = find.widgetWithText(TextField, "Owned"); + expect(ownedField, findsOneWidget); + + await tester.enterText(ownedField, "3"); + expect(reportedIndex, 0); + expect(reportedCount, 3); + }); + + testWidgets("an ingredient with no products keeps the single header owned input", (WidgetTester tester) async { + await _pumpForOwnedInputs( + tester, + ingredient: const Ingredient(id: "spice", name: "Spice"), + quantitiesDesired: const [Quantity(amount: 5, unit: Unit.grams)], + ); + + // Header input is present; there are no product rows to host a per-product input. + expect(find.widgetWithText(TextField, "Owned"), findsOneWidget); + }); + testWidgets("skips trips whose rounded amount yields 0 packs, avoiding a false split", (WidgetTester tester) async { // Only week 0 has a real amount; the other two round to 0 packs and are skipped. await _pumpIngredient( diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index 04e87c5..5f3c4e5 100644 --- a/menu_management/test/shopping_product_row_test.dart +++ b/menu_management/test/shopping_product_row_test.dart @@ -15,6 +15,8 @@ Future _pumpRow( required Product product, required int packsToBuy, List tripPurchases = const [], + double ownedCount = 0, + ValueChanged? onOwnedCountChanged, }) async { await tester.pumpWidget( MaterialApp( @@ -25,6 +27,8 @@ Future _pumpRow( isBestOption: true, packsToBuy: packsToBuy, tripPurchases: tripPurchases, + ownedCount: ownedCount, + onOwnedCountChanged: onOwnedCountChanged, ), ), ), @@ -66,6 +70,23 @@ void main() { expect(find.text("Covered"), findsOneWidget); }); + testWidgets("shows a per-product owned input and reports typed counts", (WidgetTester tester) async { + double? reported; + await _pumpRow(tester, product: _packProduct(), packsToBuy: 9, onOwnedCountChanged: (double value) => reported = value); + + Finder ownedField = find.widgetWithText(TextField, "Owned"); + expect(ownedField, findsOneWidget); + + await tester.enterText(ownedField, "2"); + expect(reported, 2); + }); + + testWidgets("hides the owned input when no owned callback is provided", (WidgetTester tester) async { + await _pumpRow(tester, product: _packProduct(), packsToBuy: 9); + + expect(find.widgetWithText(TextField, "Owned"), findsNothing); + }); + testWidgets("uses 'piece' wording for single-item packs", (WidgetTester tester) async { const Product piecesProduct = Product(link: "", quantityPerItem: 1, itemsPerPack: 1, unit: Unit.pieces); await _pumpRow( From 8eb654ef2ba0f9eb525ab7b1d9c17a828aa0c9f7 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:08:53 +0200 Subject: [PATCH 2/3] fix(shopping): keep an owned input when no product unit matches a recipe An ingredient that has products but where no product's unit matches any recipe unit (for example a pieces-only product used by a grams recipe, bridged via gramsPerPiece) rendered no product rows and also hid the single header owned input. The user could then enter no owned amount at all, so the shopping list always asked to buy the full amount. The header owned input was gated only on "the ingredient has no products". Gate both inputs on a new shared check, usesPerProductOwnedInputs: per-product inputs stay the primary path when at least one product unit matches a recipe unit; otherwise the header input shows as a fallback. ShoppingPage's owned-stock resolver uses the same check, so the fallback amount flows through the shared resolver and the remaining-to-buy stays correct. Also fix the fallback's default unit: packs cannot convert when no product matches the target unit, so defaultOwnedUnit now falls through to a concrete unit (pieces, then the first recipe unit, then grams). Co-Authored-By: Claude Opus 4.8 --- .../lib/shopping/shopping_ingredient.dart | 30 +++++- .../lib/shopping/shopping_page.dart | 12 ++- .../test/shopping_ingredient_test.dart | 101 ++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 99365c0..403326c 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -34,13 +34,18 @@ class OwnedUnit { /// practical unit for counting items at home. Falls back to pieces or the first /// desired unit when no products are configured. OwnedUnit defaultOwnedUnit({required Ingredient? ingredient, required List desiredQuantities}) { - if (ingredient != null && ingredient.products.isNotEmpty) { + // Packs is only a useful default when a product row will actually render (its unit matches a + // recipe unit). Otherwise the header owned input is the fallback, and packs cannot convert, so + // fall through to a concrete unit the shared resolver can convert. + if (ingredient != null && usesPerProductOwnedInputs(ingredient: ingredient, desiredQuantities: desiredQuantities)) { bool allSinglePiece = ingredient.products.every((Product p) => p.unit == Unit.pieces && p.totalQuantityPerPack == 1.0); if (allSinglePiece) return const OwnedUnit(unit: Unit.pieces); return const OwnedUnit(); // packs } - bool hasPieces = desiredQuantities.any((q) => q.unit == Unit.pieces); + // Prefer pieces (from a product or a recipe) so the user can count whole items, then the first + // recipe unit, then grams. + bool hasPieces = (ingredient?.products.any((Product p) => p.unit == Unit.pieces) ?? false) || desiredQuantities.any((q) => q.unit == Unit.pieces); if (hasPieces) return const OwnedUnit(unit: Unit.pieces); if (desiredQuantities.isNotEmpty) return OwnedUnit(unit: desiredQuantities.first.unit); @@ -48,6 +53,19 @@ OwnedUnit defaultOwnedUnit({required Ingredient? ingredient, required List desiredQuantities}) { + return ingredient.products.any((Product product) => desiredQuantities.any((Quantity q) => q.unit == product.unit)); +} + class ShoppingIngredient extends StatefulWidget { const ShoppingIngredient({ super.key, @@ -276,8 +294,10 @@ class _ShoppingIngredientState extends State { ), // Owned quantity input with unit dropdown. - // Only for ingredients with no products; products use per-product owned inputs in each row. - if (widget.ingredient.products.isEmpty && availableUnits.isNotEmpty) ...[ + // Shown as the fallback whenever no per-product owned inputs will render (no products, + // or no product unit matches a recipe unit); otherwise each product row hosts its own input. + if (!usesPerProductOwnedInputs(ingredient: widget.ingredient, desiredQuantities: widget.quantitiesDesired) && + availableUnits.isNotEmpty) ...[ SizedBox( width: 120, child: TextField( @@ -360,7 +380,7 @@ class _ShoppingIngredientState extends State { const SizedBox(height: 8), // Product rows (only for products whose unit matches a required quantity) - if (widget.ingredient.products.isNotEmpty) + if (usesPerProductOwnedInputs(ingredient: widget.ingredient, desiredQuantities: widget.quantitiesDesired)) ...() { List> matchingProducts = widget.ingredient.products .asMap() diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index c2909d9..b647b60 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -78,11 +78,15 @@ class _ShoppingPageState extends State { } } - /// Builds the owned stock for an ingredient: per-product counts when it has products, - /// otherwise the single amount + selected unit. Both resolve to the same units via - /// [OwnedStock.amountInUnit], so the on-screen list and the planner subtract the same amount. + /// Builds the owned stock for an ingredient: per-product counts when per-product rows render + /// (see [usesPerProductOwnedInputs]), otherwise the single header amount + selected unit. This + /// mirrors which input the UI shows, so the header fallback (products present but no product unit + /// matches a recipe unit) is read from the single amount, not the empty per-product counts. Both + /// resolve to the same units via [OwnedStock.amountInUnit], so the on-screen list and the planner + /// subtract the same amount. OwnedStock _ownedStockFor({required String ingredientId, required Ingredient ingredient}) { - if (ingredient.products.isNotEmpty) { + List desired = ingredientsRequired[ingredientId] ?? const []; + if (usesPerProductOwnedInputs(ingredient: ingredient, desiredQuantities: desired)) { return OwnedStock.perProduct(countsByProductIndex: ownedProductCounts[ingredientId] ?? const {}); } return OwnedStock(amount: ownedAmounts[ingredientId] ?? 0, unit: ownedUnits[ingredientId]?.unit); diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index 8e834aa..71da1ef 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -1,3 +1,5 @@ +import "dart:math"; + import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; import "package:menu_management/ingredients/models/ingredient.dart"; @@ -5,6 +7,7 @@ import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/shopping/multi_trip_planner.dart"; +import "package:menu_management/shopping/owned_amount.dart"; import "package:menu_management/shopping/shopping_ingredient.dart"; // 100 grams per pack (2 items x 50 grams), so itemsPerPack > 1 keeps the "pack(s)" wording. @@ -67,6 +70,69 @@ Future _pumpForOwnedInputs( ); } +/// Stateful test harness that mirrors how `ShoppingPage` wires owned stock: it picks the owned +/// stock shape with [usesPerProductOwnedInputs] and recomputes the on-screen remaining ("Need") +/// through the shared [OwnedStock] resolver, so a change in the owned input flows to the "Need" text. +class _OwnedHarness extends StatefulWidget { + const _OwnedHarness({required this.ingredient, required this.desired}); + + final Ingredient ingredient; + final List desired; + + @override + State<_OwnedHarness> createState() => _OwnedHarnessState(); +} + +class _OwnedHarnessState extends State<_OwnedHarness> { + double ownedAmount = 0; + late OwnedUnit ownedUnit = defaultOwnedUnit(ingredient: widget.ingredient, desiredQuantities: widget.desired); + final Map ownedProductCounts = {}; + + OwnedStock get _stock => usesPerProductOwnedInputs(ingredient: widget.ingredient, desiredQuantities: widget.desired) + ? OwnedStock.perProduct(countsByProductIndex: ownedProductCounts) + : OwnedStock(amount: ownedAmount, unit: ownedUnit.unit); + + List get _remaining => widget.desired + .map( + (Quantity q) => Quantity( + amount: max(0.0, q.amount - _stock.amountInUnit(ingredient: widget.ingredient, targetUnit: q.unit)).roundToDouble(), + unit: q.unit, + ), + ) + .toList(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + // The flutter_test placeholder font renders every glyph as a fixed-width box, which is wider + // than the real font and overflows the fixed-width unit dropdown. Shrink the text scale so the + // layout has room; this test verifies owned-input logic, not pixel-exact widths. + builder: (BuildContext context, Widget? child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(0.7)), + child: child!, + ), + home: Scaffold( + body: ShoppingIngredient( + ingredient: widget.ingredient, + quantitiesDesired: widget.desired, + calculatedRemainingQuantities: _remaining, + productRecommendations: const [], + ownedAmount: ownedAmount, + ownedUnit: ownedUnit, + onOwnedChanged: (double amount, OwnedUnit unit) => setState(() { + ownedAmount = amount; + ownedUnit = unit; + }), + ownedProductCounts: ownedProductCounts, + onProductOwnedChanged: (int index, double count) => setState(() => ownedProductCounts[index] = count), + sources: const [], + plannedTrips: const [], + ), + ), + ); + } +} + void main() { group("ShoppingIngredient per-trip buy split", () { testWidgets("renders the split lines when 2+ trips buy the product", (WidgetTester tester) async { @@ -150,6 +216,41 @@ void main() { expect(find.widgetWithText(TextField, "Owned"), findsOneWidget); }); + testWidgets("shows the header owned input as a fallback when no product unit matches a recipe unit, and entering it reduces Need", ( + WidgetTester tester, + ) async { + // Egg-like ingredient: the only product is sold in pieces (6 per pack) with gramsPerPiece, + // but the recipe needs grams. No product row matches the grams unit, so the per-product + // inputs never render. The header owned input must appear so the user can still enter stock. + // Wide desktop-like surface so the header row (owned input + unit dropdown) has room to lay out. + await tester.binding.setSurfaceSize(const Size(1200, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + Ingredient egg = Ingredient( + id: "egg", + name: "Egg", + gramsPerPiece: 60, + products: [const Product(link: "", quantityPerItem: 1, itemsPerPack: 6, unit: Unit.pieces)], + ); + await tester.pumpWidget( + _OwnedHarness( + ingredient: egg, + desired: const [Quantity(amount: 300, unit: Unit.grams)], + ), + ); + + // The header owned input is present even though the product unit (pieces) does not match grams. + expect(find.widgetWithText(TextField, "Owned"), findsOneWidget); + expect(find.text("Need: 300 grams"), findsOneWidget); + + // Default owned unit is pieces (from the pieces product). Owning 2 eggs = 120 g via gramsPerPiece, + // so the on-screen Need drops from 300 g to 180 g. + await tester.enterText(find.widgetWithText(TextField, "Owned"), "2"); + await tester.pump(); + expect(find.text("Need: 180 grams"), findsOneWidget); + expect(find.text("Need: 300 grams"), findsNothing); + }); + testWidgets("skips trips whose rounded amount yields 0 packs, avoiding a false split", (WidgetTester tester) async { // Only week 0 has a real amount; the other two round to 0 packs and are skipped. await _pumpIngredient( From 0d53b0a3bf801bac69f82e58ec326183e82c2a69 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:09:12 +0200 Subject: [PATCH 3/3] fix(shopping): keep the per-product owned field in sync with its value ShoppingProductRow seeded its text controller once in initState and never updated it, and the rows were built in a loop with no Key. If a product's owned count were ever reset from outside (for example clearing all owned stock), the field would keep showing stale text, and without a Key Flutter could reuse the wrong row's state when the product list changes. Add didUpdateWidget so the field re-seeds when ownedCount changes from the parent, and give each row a ValueKey of the product's true index so its state stays bound to that product. Co-Authored-By: Claude Opus 4.8 --- .../lib/shopping/shopping_ingredient.dart | 1 + .../lib/shopping/shopping_product_row.dart | 15 ++++++++- .../test/shopping_product_row_test.dart | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 403326c..9d710f5 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -414,6 +414,7 @@ class _ShoppingIngredientState extends State { } rows.add( ShoppingProductRow( + key: ValueKey(productIndex), product: product, recommendation: recommendation, isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste, diff --git a/menu_management/lib/shopping/shopping_product_row.dart b/menu_management/lib/shopping/shopping_product_row.dart index 6339b27..b1b8dea 100644 --- a/menu_management/lib/shopping/shopping_product_row.dart +++ b/menu_management/lib/shopping/shopping_product_row.dart @@ -56,7 +56,18 @@ class _ShoppingProductRowState extends State { @override void initState() { super.initState(); - _controller = TextEditingController(text: widget.ownedCount > 0 ? _formatCount(widget.ownedCount) : ""); + _controller = TextEditingController(text: _textForCount(widget.ownedCount)); + } + + @override + void didUpdateWidget(ShoppingProductRow oldWidget) { + super.didUpdateWidget(oldWidget); + // Re-seed the field when the owned count is changed from outside (e.g. reset by the parent), + // so the shown text never goes stale against the widget's value. + if (widget.ownedCount != oldWidget.ownedCount) { + String newText = _textForCount(widget.ownedCount); + if (_controller.text != newText) _controller.text = newText; + } } @override @@ -65,6 +76,8 @@ class _ShoppingProductRowState extends State { super.dispose(); } + String _textForCount(double count) => count > 0 ? _formatCount(count) : ""; + String _formatCount(double value) => value.toStringAsFixed(value == value.roundToDouble() ? 0 : 1); /// Singular/plural unit word: pieces for single-item packs, packs otherwise. diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index 5f3c4e5..2f3043a 100644 --- a/menu_management/test/shopping_product_row_test.dart +++ b/menu_management/test/shopping_product_row_test.dart @@ -87,6 +87,39 @@ void main() { expect(find.widgetWithText(TextField, "Owned"), findsNothing); }); + testWidgets("re-seeds the owned field when ownedCount changes from the parent", (WidgetTester tester) async { + double owned = 0; + late StateSetter setOuter; + Product product = _packProduct(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + setOuter = setState; + return ShoppingProductRow( + product: product, + recommendation: _recommendation(product), + isBestOption: true, + packsToBuy: 9, + ownedCount: owned, + onOwnedCountChanged: (double value) {}, + ); + }, + ), + ), + ), + ); + + Finder ownedField = find.widgetWithText(TextField, "Owned"); + expect(tester.widget(ownedField).controller!.text, ""); + + // The parent resets the owned count to 3; the field must reflect it without recreating the widget. + setOuter(() => owned = 3); + await tester.pump(); + expect(tester.widget(ownedField).controller!.text, "3"); + }); + testWidgets("uses 'piece' wording for single-item packs", (WidgetTester tester) async { const Product piecesProduct = Product(link: "", quantityPerItem: 1, itemsPerPack: 1, unit: Unit.pieces); await _pumpRow(