diff --git a/adr/0010-product-entity-store-products.md b/adr/0010-product-entity-store-products.md index 3abf846..1875edd 100644 --- a/adr/0010-product-entity-store-products.md +++ b/adr/0010-product-entity-store-products.md @@ -42,7 +42,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, except for the opt-in under-buy case above (issue #29) where the optimizer recommends one pack less and warns. - 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 be8024e..3d107ed 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -34,7 +34,14 @@ 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"). Both the planner and the on-screen list draw the stock down through the shared `OwnedStockConsumer` (in `owned_amount.dart`): the stock is turned into a single grams pool (via `ownedAmountInUnit`) and consumed across the ingredient's needs, one need at a time, in chronological order. `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). A need whose unit has no grams path, or an owned stock with no grams path at all (for example owned pieces with no `gramsPerPiece`), falls back to a per-unit subtraction. Because both callers use the same consumer, the copied trip amounts always equal the on-screen "Need" amounts. +`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`). `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). + +Both the planner and the on-screen list then draw the stock down through the shared `OwnedStockConsumer` (in `owned_amount.dart`). The consumer takes the whole `OwnedStock`, so both shapes feed the same single pool: it reads the stock's grams total via `OwnedStock.amountInUnit(ingredient, grams)` (for per-product, the summed global grams from every owned product) into one grams pool, then consumes that pool across the ingredient's needs, one need at a time, in chronological order. A need whose unit has no grams path, or an owned stock with no grams path at all (for example owned pieces with no `gramsPerPiece`), falls back to a per-unit subtraction that likewise reads from `OwnedStock.amountInUnit`. Because both callers use the same consumer over the same `OwnedStock`, the copied trip amounts always equal the on-screen "Need" amounts. The on-screen list builds the same stock via `_ownedStockFor` in `shopping_page.dart` and runs it through `computeRemainingQuantities`, which is the same `OwnedStockConsumer`, so per-product owned is subtracted once, across units, exactly as the planner does. When no conversion path exists, 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. Moving both to `ownedAmountInUnit` removed that divergence. @@ -60,6 +67,6 @@ ADR 0015 supersedes this UI: the OFF mode (flat list, ignore shelf life) was dro - 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. -- 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 1135dc2..725ac77 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -168,9 +168,9 @@ List<_PlanEvent> _buildPlanEvents({ // and unit. This is the same single-grams-pool subtraction the on-screen list runs (see // computeRemainingQuantities), so the planner and the page never disagree on how much is still // needed. Consuming full owned stock per unit here would over-subtract and drop the ingredient. - OwnedStockConsumer? consumer = (owned == null || ingredient == null) - ? null - : OwnedStockConsumer(ingredient: ingredient, ownedAmount: owned.amount, ownedUnit: owned.unit); + // The owned stock carries its shape (single-form or per-product, see issue #24); the consumer + // reads its grams total from OwnedStock.amountInUnit, so per-product counts feed the same pool. + OwnedStockConsumer? consumer = (owned == null || ingredient == null) ? null : OwnedStockConsumer(ingredient: ingredient, owned: owned); for (CookingEvent event in events) { for (Quantity quantity in event.quantities) { diff --git a/menu_management/lib/shopping/owned_amount.dart b/menu_management/lib/shopping/owned_amount.dart index 4043f9a..a9637f2 100644 --- a/menu_management/lib/shopping/owned_amount.dart +++ b/menu_management/lib/shopping/owned_amount.dart @@ -7,15 +7,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. @@ -51,29 +108,30 @@ double ownedAmountInUnit({required Ingredient ingredient, required double ownedA /// Draws down a user's owned stock across an ingredient's needs, one need at a time. /// -/// The stock is turned into a single shared grams pool (via [ownedAmountInUnit]) and consumed across -/// every need in the order [consumeRemaining] is called. This makes a single owned stock get +/// The stock is turned into a single shared grams pool (via [OwnedStock.amountInUnit]) and consumed +/// across every need in the order [consumeRemaining] is called. This makes a single owned stock get /// subtracted only once, even when the ingredient is needed in more than one unit at the same time. /// It is the single source of truth for owned-stock subtraction, shared by the on-screen shopping /// list ([computeRemainingQuantities]) and the multi-trip planner (`multi_trip_planner.dart`), so the /// two never disagree on how much is still needed. /// +/// The consumer takes an [OwnedStock], so both stock shapes flow through the same single pool: +/// single-form (one amount + unit) and per-product (one count per product, summed into a global +/// grams amount, see issue #24). Whichever shape the user entered, [OwnedStock.amountInUnit] gives +/// its grams total for the pool and its per-unit total for the fallback below. +/// /// Create one consumer per (ingredient, owned stock). A need whose unit cannot be related to grams /// (no density and no gramsPerPiece), or an owned stock with no grams path at all (for example owned /// pieces with no gramsPerPiece), falls back to a per-unit subtraction that is likewise consumed only /// once per unit across calls. class OwnedStockConsumer { - OwnedStockConsumer({required Ingredient ingredient, required double ownedAmount, required Unit? ownedUnit}) + OwnedStockConsumer({required Ingredient ingredient, required OwnedStock owned}) : _ingredient = ingredient, - _ownedAmount = ownedAmount, - _ownedUnit = ownedUnit, - _gramsPool = ownedAmount <= 0 - ? 0 - : ownedAmountInUnit(ingredient: ingredient, ownedAmount: ownedAmount, ownedUnit: ownedUnit, targetUnit: Unit.grams); + _owned = owned, + _gramsPool = owned.hasStock ? owned.amountInUnit(ingredient: ingredient, targetUnit: Unit.grams) : 0; final Ingredient _ingredient; - final double _ownedAmount; - final Unit? _ownedUnit; + final OwnedStock _owned; /// Remaining shared grams pool. Drawn down by each need; reaching 0 just means the stock is used up, /// not that there is no grams path (that is fixed at construction, see [_ownedHasGramsPath]). @@ -91,7 +149,7 @@ class OwnedStockConsumer { /// Returns how much of [need] still has to be bought after applying the owned stock. /// The result is raw (not rounded); callers that display whole units round it themselves. double consumeRemaining(Quantity need) { - if (_ownedAmount <= 0) return need.amount; + if (!_owned.hasStock) return need.amount; // No grams conversion path from the owned stock: subtract per unit directly. if (!_ownedHasGramsPath) return _consumeFallback(need); @@ -108,10 +166,7 @@ class OwnedStockConsumer { } double _consumeFallback(Quantity need) { - double owned = _fallbackOwnedByUnit.putIfAbsent( - need.unit, - () => ownedAmountInUnit(ingredient: _ingredient, ownedAmount: _ownedAmount, ownedUnit: _ownedUnit, targetUnit: need.unit), - ); + double owned = _fallbackOwnedByUnit.putIfAbsent(need.unit, () => _owned.amountInUnit(ingredient: _ingredient, targetUnit: need.unit)); double consumed = min(owned, need.amount); _fallbackOwnedByUnit[need.unit] = owned - consumed; return need.amount - consumed; @@ -125,13 +180,9 @@ class OwnedStockConsumer { /// /// Delegates to [OwnedStockConsumer] so the stock is consumed a single time across all units. This /// prevents the old bug where a single stock was fully converted into every unit and subtracted from -/// each, over-subtracting when an ingredient is needed in more than one unit at once. -List computeRemainingQuantities({ - required Ingredient ingredient, - required List requiredQuantities, - required double ownedAmount, - required Unit? ownedUnit, -}) { - OwnedStockConsumer consumer = OwnedStockConsumer(ingredient: ingredient, ownedAmount: ownedAmount, ownedUnit: ownedUnit); +/// each, over-subtracting when an ingredient is needed in more than one unit at once. [owned] may be +/// either stock shape (single-form or per-product); both resolve through the same single pool. +List computeRemainingQuantities({required Ingredient ingredient, required List requiredQuantities, required OwnedStock owned}) { + OwnedStockConsumer consumer = OwnedStockConsumer(ingredient: ingredient, owned: owned); return requiredQuantities.map((Quantity q) => Quantity(amount: max(0, consumer.consumeRemaining(q)).roundToDouble(), unit: q.unit)).toList(); } diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 52ed132..bcbf14f 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, @@ -58,6 +76,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 +86,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, @@ -263,26 +291,38 @@ class _ShoppingIngredientState extends State { /// solo cover ([_packsToBuyForProduct], computed from the still-needed amount so it reflects /// owned stock) split one-of-each via [distributeEquivalentPacks]. 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 + /// combined members of an equivalence group. The product's index in [Ingredient.products] keys the + /// [ownedProductCounts] map, the [onProductOwnedChanged] callback, and the row's [ValueKey] so the + /// stateful owned field stays bound to the right product across rebuilds. A row renders here only + /// when its product's unit matches a recipe unit, which is exactly when [usesPerProductOwnedInputs] + /// is true, so the header fallback owned input never shows at the same time as these inputs. List _buildProductRows(BuildContext context, double? bestWaste) { - List matchingProducts = widget.ingredient.products.where((Product p) => widget.quantitiesDesired.any((q) => q.unit == p.unit)).toList(); + List> matchingProducts = widget.ingredient.products + .asMap() + .entries + .where((MapEntry entry) => widget.quantitiesDesired.any((q) => q.unit == entry.value.unit)) + .toList(); // Group by equivalence, preserving first-appearance order (Dart maps keep insertion order). - Map> groups = {}; - for (Product product in matchingProducts) { - groups.putIfAbsent(productEquivalenceKey(product), () => []).add(product); + Map>> groups = {}; + for (MapEntry entry in matchingProducts) { + groups.putIfAbsent(productEquivalenceKey(entry.value), () => >[]).add(entry); } List rows = []; bool isFirstRow = true; - for (List group in groups.values) { + for (List> group in groups.values) { bool isCombinedGroup = group.length >= 2; List cycledShares = isCombinedGroup - ? distributeEquivalentPacks(totalPacks: _packsToBuyForProduct(group.first), groupSize: group.length) + ? distributeEquivalentPacks(totalPacks: _packsToBuyForProduct(group.first.value), groupSize: group.length) : const []; bool isFirstVisibleInGroup = true; for (int memberIndex = 0; memberIndex < group.length; memberIndex++) { - Product product = group[memberIndex]; + int productIndex = group[memberIndex].key; + Product product = group[memberIndex].value; int packsToBuy = isCombinedGroup ? cycledShares[memberIndex] : _packsToBuyForProduct(product); // In a combined group a member cycled to 0 packs is fully covered by its equivalents. // Skip it (matching the copied list) so no "... and Covered" row and no dangling "and" @@ -304,6 +344,7 @@ class _ShoppingIngredientState extends State { rows.add( ShoppingProductRow( + key: ValueKey(productIndex), product: product, recommendation: recommendation, isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste, @@ -311,6 +352,8 @@ class _ShoppingIngredientState extends State { // 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), + ownedCount: widget.ownedProductCounts[productIndex] ?? 0, + onOwnedCountChanged: (double count) => widget.onProductOwnedChanged(productIndex, count), ), ); } @@ -378,8 +421,11 @@ class _ShoppingIngredientState extends State { ), ), - // Owned quantity input with unit dropdown - if (availableUnits.isNotEmpty) ...[ + // Owned quantity input with unit dropdown. + // 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( diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index d84d2e3..c00ff9e 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -29,12 +29,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; @@ -59,6 +65,7 @@ class _ShoppingPageState extends State { ownedAmounts = {}; ownedUnits = {}; + ownedProductCounts = {}; for (MapEntry> entry in ingredientsRequired.entries) { String ingredientId = entry.key; @@ -66,9 +73,24 @@ 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 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}) { + 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); + } + @override Widget build(BuildContext context) { List plannedTrips = _planTrips(); @@ -138,6 +160,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) { @@ -146,6 +169,11 @@ class _ShoppingPageState extends State { ownedUnits[ingredientId] = unit; }); }, + onProductOwnedChanged: (int productIndex, double count) { + setState(() { + (ownedProductCounts[ingredientId] ??= {})[productIndex] = count; + }); + }, ); }, ), @@ -155,14 +183,13 @@ class _ShoppingPageState extends State { /// The on-screen "remaining to buy" for one ingredient: the normalized required amounts with the /// user's owned stock subtracted once and each line rounded to a whole unit. Delegates to the /// shared [computeRemainingQuantities] so the header, the single list, and the per-trip copy all - /// start from the same numbers. + /// start from the same numbers. The owned stock comes from [_ownedStockFor], so per-product owned + /// counts (issue #24) feed the same single pool as the single-form amount. List _remainingAmounts({required String ingredientId, required Ingredient ingredient}) { - OwnedUnit selectedUnit = ownedUnits[ingredientId] ?? const OwnedUnit(unit: Unit.grams); return computeRemainingQuantities( ingredient: ingredient, requiredQuantities: ingredientsRequired[ingredientId]!, - ownedAmount: ownedAmounts[ingredientId] ?? 0, - ownedUnit: selectedUnit.unit, + owned: _ownedStockFor(ingredientId: ingredientId, ingredient: ingredient), ); } @@ -246,18 +273,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 7cfc272..593bb50 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,49 @@ 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: _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 + void dispose() { + _controller.dispose(); + 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. - 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"); /// Whether to actually buy one pack less and show the under-buy warning. /// @@ -48,7 +91,8 @@ class ShoppingProductRow extends StatelessWidget { /// `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. - bool get _appliesUnderBuy => recommendation.underBuy && tripPurchases.length < 2 && packsToBuy == recommendation.packsNeeded + 1; + 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" : "+"; @@ -57,9 +101,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"); @@ -68,8 +112,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; // Under-buy: one pack less than the recipes calculate. Amber warning chip. // Label stays compact (like the waste chips); the full wording lives in the tooltip. @@ -79,10 +123,10 @@ class ShoppingProductRow extends StatelessWidget { return Tooltip( message: "Buying less than the recipes calculate.\n" - "Dropped one mostly-empty pack; recipes will be about ${recommendation.shortfall.toFormattedAmount()} $unit short.", + "Dropped one mostly-empty pack; recipes will be about ${widget.recommendation.shortfall.toFormattedAmount()} $unit short.", child: Chip( avatar: Icon(Icons.warning_amber_rounded, size: 16, color: amber.onPrimaryContainer), - label: Text("${recommendation.shortfall.toFormattedAmount()} $unit short"), + label: Text("${widget.recommendation.shortfall.toFormattedAmount()} $unit short"), backgroundColor: amber.primaryContainer, labelStyle: TextStyle(color: amber.onPrimaryContainer, fontSize: 12), visualDensity: VisualDensity.compact, @@ -108,7 +152,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( @@ -139,16 +183,16 @@ 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; // When the under-buy recommendation applies, buy one pack less on the single-total line // (the warning chip explains why). See [_appliesUnderBuy] for when it applies. - int effectivePacksToBuy = _appliesUnderBuy ? packsToBuy - 1 : packsToBuy; + int effectivePacksToBuy = _appliesUnderBuy ? widget.packsToBuy - 1 : widget.packsToBuy; 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), @@ -174,6 +218,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)), @@ -181,7 +244,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(), @@ -198,13 +261,13 @@ 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, ), diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 15d7dc6..2441c93 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -334,6 +334,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 015f3aa..de06b7b 100644 --- a/menu_management/test/owned_amount_test.dart +++ b/menu_management/test/owned_amount_test.dart @@ -63,14 +63,95 @@ void main() { }); }); + 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); + }); + }); + group("computeRemainingQuantities", () { test("subtracts owned once for a single-unit need (matches the old per-unit result)", () { Ingredient flour = const Ingredient(id: "flour", name: "Flour"); List remaining = computeRemainingQuantities( ingredient: flour, requiredQuantities: const [Quantity(amount: 600, unit: Unit.grams)], - ownedAmount: 250, - ownedUnit: Unit.grams, + owned: const OwnedStock(amount: 250, unit: Unit.grams), ); expect(remaining.length, 1); @@ -83,8 +164,7 @@ void main() { List remaining = computeRemainingQuantities( ingredient: flour, requiredQuantities: const [Quantity(amount: 100.6, unit: Unit.grams)], - ownedAmount: 0, - ownedUnit: Unit.grams, + owned: const OwnedStock(amount: 0, unit: Unit.grams), ); expect(remaining.first.amount, 101); @@ -110,8 +190,38 @@ void main() { Quantity(amount: 4, unit: Unit.pieces), Quantity(amount: 50, unit: Unit.grams), ], - ownedAmount: 100, - ownedUnit: Unit.grams, + owned: const OwnedStock(amount: 100, unit: Unit.grams), + ); + + double pieces = remaining.firstWhere((q) => q.unit == Unit.pieces).amount; + double grams = remaining.firstWhere((q) => q.unit == Unit.grams).amount; + expect(pieces, 0); + expect(grams, 50); + }); + }); + + group("computeRemainingQuantities with per-product owned", () { + test("subtracts per-product owned counts once across two units via the single pool", () { + // Garlic needed as 4 pieces AND 50 g (gramsPerPiece 25 keeps both lines). The user owns + // 4 pieces via the pieces product (index 1) -> 100 g global. That covers the pieces line + // and is used up, leaving the 50 g line untouched. This proves per-product owned (issue #24) + // flows through the same single grams pool as the single-form stock above. + Ingredient garlic = Ingredient( + id: "garlic", + name: "Ajo", + gramsPerPiece: 25, + products: [ + _product(unit: Unit.grams, quantityPerItem: 150), + _product(unit: Unit.pieces, quantityPerItem: 1), + ], + ); + List remaining = computeRemainingQuantities( + ingredient: garlic, + requiredQuantities: const [ + Quantity(amount: 4, unit: Unit.pieces), + Quantity(amount: 50, unit: Unit.grams), + ], + owned: OwnedStock.perProduct(countsByProductIndex: const {1: 4}), ); double pieces = remaining.firstWhere((q) => q.unit == Unit.pieces).amount; diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index ed37177..5329d5a 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"; import "package:menu_management/shopping/waste_optimizer.dart"; @@ -22,25 +25,39 @@ ProductRecommendation _rec(Product product, int packsNeeded) => /// Pumps a [ShoppingIngredient] with the given products, remaining amount, and recommendations, /// capturing the value passed to `onOwnedChanged` (used by the auto-fill test). +/// +/// [quantitiesDesired] defaults to a grams recipe that matches the grams products, so per-product +/// owned inputs render. The auto-fill tests override it with a unit the products do not use, so the +/// header owned input (which hosts the auto-fill button) renders instead (see [usesPerProductOwnedInputs]). Future _pumpProducts( WidgetTester tester, { required List products, required double remainingGrams, List recommendations = const [], OwnedUnit ownedUnit = const OwnedUnit(), + List quantitiesDesired = const [Quantity(amount: 1500, unit: Unit.grams)], void Function(double amount, OwnedUnit unit)? onOwnedChanged, }) async { await tester.pumpWidget( MaterialApp( + // The flutter_test placeholder font renders every glyph as a fixed-width box wider than the real + // font, which overflows the fixed-width unit dropdown when the header input renders. Shrink the + // text scale so the layout has room; these tests verify 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: Ingredient(id: "pizza", name: "Pizza", products: products), - quantitiesDesired: const [Quantity(amount: 1500, unit: Unit.grams)], + quantitiesDesired: quantitiesDesired, calculatedRemainingQuantities: [Quantity(amount: remainingGrams, unit: Unit.grams)], productRecommendations: recommendations, ownedAmount: 0, ownedUnit: ownedUnit, onOwnedChanged: onOwnedChanged ?? (double amount, OwnedUnit unit) {}, + ownedProductCounts: const {}, + onProductOwnedChanged: (int productIndex, double count) {}, sources: const [], plannedTrips: const [], ), @@ -68,6 +85,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, ), @@ -76,6 +95,96 @@ 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 [], + ), + ), + ), + ); +} + +/// 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 { @@ -126,6 +235,74 @@ 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("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( @@ -170,6 +347,12 @@ void main() { }); testWidgets("auto-fill uses the full amount needed, not a single cycled share", (WidgetTester tester) async { + // The auto-fill button lives on the header owned input, which renders only when no product + // unit matches a recipe unit (otherwise per-product inputs replace it). The recipe here needs + // pieces while the products are sold in grams, so the header fallback (and its auto-fill) shows. + await tester.binding.setSurfaceSize(const Size(1400, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + double? captured; // Recommendations carry the cycled share (1 each). Auto-fill must sum them (3), not use 1. List products = [_equivProduct("a"), _equivProduct("b"), _equivProduct("c")]; @@ -177,6 +360,7 @@ void main() { tester, products: products, remainingGrams: 1500, + quantitiesDesired: const [Quantity(amount: 3, unit: Unit.pieces)], recommendations: [_rec(products[0], 1), _rec(products[1], 1), _rec(products[2], 1)], onOwnedChanged: (double amount, OwnedUnit unit) => captured = amount, ); @@ -188,6 +372,11 @@ void main() { }); testWidgets("auto-fill counts one pack for two equivalents that need only one (no over-fill)", (WidgetTester tester) async { + // Header fallback (pieces recipe, grams products) so the auto-fill button renders. See the + // test above for why the header input, not per-product inputs, shows here. + await tester.binding.setSurfaceSize(const Size(1400, 600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + double? captured; // Two equivalent 500 g variants, need 500 g -> 1 pack total. Recommendations come from // rankProducts, which cycles the single pack to [1, 0]. Auto-fill must sum to 1, not 2. @@ -202,6 +391,7 @@ void main() { tester, products: products, remainingGrams: 500, + quantitiesDesired: const [Quantity(amount: 1, unit: Unit.pieces)], recommendations: recommendations, onOwnedChanged: (double amount, OwnedUnit unit) => captured = amount, ); diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index d2e32d9..98aa17a 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, ProductRecommendation? recommendation, }) async { await tester.pumpWidget( @@ -26,6 +28,8 @@ Future _pumpRow( isBestOption: true, packsToBuy: packsToBuy, tripPurchases: tripPurchases, + ownedCount: ownedCount, + onOwnedCountChanged: onOwnedCountChanged, ), ), ), @@ -67,6 +71,56 @@ 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("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( diff --git a/menu_management/test/trip_amount_distributor_test.dart b/menu_management/test/trip_amount_distributor_test.dart index 220a565..cb77ade 100644 --- a/menu_management/test/trip_amount_distributor_test.dart +++ b/menu_management/test/trip_amount_distributor_test.dart @@ -170,8 +170,7 @@ void main() { List pageRemaining = computeRemainingQuantities( ingredient: garlic, requiredQuantities: required, - ownedAmount: 100, - ownedUnit: Unit.grams, + owned: const OwnedStock(amount: 100, unit: Unit.grams), ); // The page shows a positive remaining (200 g of need minus 100 g owned = 100 g still to buy).