Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 92 additions & 45 deletions menu_management/lib/shopping/shopping_ingredient.dart
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,19 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
double autoValue;

if (selectedUnit.unit == null) {
// Packs: use the recommended product's packs needed
// Packs: use the full packs needed for the recommended product. rankProducts spreads
// equivalent products one-of-each, so a single recommendation.packsNeeded is only a
// cycled share. Sum the whole equivalence group to recover the full amount needed.
ProductRecommendation? bestRec = widget.productRecommendations.firstWhereOrNull((r) => r.isViable) ?? widget.productRecommendations.firstOrNull;
autoValue = bestRec?.packsNeeded.toDouble() ?? 0;
if (bestRec == null) {
autoValue = 0;
} else {
String groupKey = productEquivalenceKey(bestRec.product);
autoValue = widget.productRecommendations
.where((ProductRecommendation r) => productEquivalenceKey(r.product) == groupKey)
.fold<int>(0, (int sum, ProductRecommendation r) => sum + r.packsNeeded)
.toDouble();
}
} else {
// Raw unit: use the desired quantity for that unit
Quantity? desired = widget.quantitiesDesired.firstWhereOrNull((q) => q.unit == selectedUnit.unit);
Expand All @@ -245,6 +255,85 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
widget.onOwnedChanged(autoValue, selectedUnit);
}

/// Builds the product rows, grouping equivalent products (same [productEquivalenceKey],
/// e.g. two pizza flavors of the same size) so they render as a combined "buy one of each"
/// joined by "and" instead of mutually-exclusive "or" alternatives.
///
/// For an equivalent group of 2+ members the buy count is the cycled share: the group's
/// solo cover ([_packsToBuyForProduct], computed from the still-needed amount so it reflects
/// owned stock) split one-of-each via [distributeEquivalentPacks]. Non-equivalent products
/// (different pack size, shelf life, ...) keep their solo count and the per-trip split.
List<Widget> _buildProductRows(BuildContext context, double? bestWaste) {
List<Product> matchingProducts = widget.ingredient.products.where((Product p) => widget.quantitiesDesired.any((q) => q.unit == p.unit)).toList();

// Group by equivalence, preserving first-appearance order (Dart maps keep insertion order).
Map<String, List<Product>> groups = {};
for (Product product in matchingProducts) {
groups.putIfAbsent(productEquivalenceKey(product), () => <Product>[]).add(product);
}

List<Widget> rows = [];
bool isFirstRow = true;
for (List<Product> group in groups.values) {
bool isCombinedGroup = group.length >= 2;
List<int> cycledShares = isCombinedGroup
? distributeEquivalentPacks(totalPacks: _packsToBuyForProduct(group.first), groupSize: group.length)
: const [];

bool isFirstVisibleInGroup = true;
for (int memberIndex = 0; memberIndex < group.length; memberIndex++) {
Product product = group[memberIndex];
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"
// divider appear. If this leaves one visible member, it renders as a normal single row.
if (isCombinedGroup && packsToBuy <= 0) continue;

ProductRecommendation recommendation = widget.productRecommendations.firstWhere(
(r) => r.product == product,
orElse: () => ProductRecommendation(product: product, packsNeeded: 0, overBuyWaste: 0, expiryWaste: 0, isViable: true),
);

if (!isFirstRow) {
// "and" joins visible members of the same equivalence group; "or" separates different options.
bool sameGroupAsPrevious = isCombinedGroup && !isFirstVisibleInGroup;
rows.add(_separatorDivider(context, sameGroupAsPrevious ? "and" : "or"));
}
isFirstRow = false;
isFirstVisibleInGroup = false;

rows.add(
ShoppingProductRow(
product: product,
recommendation: recommendation,
isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste,
packsToBuy: packsToBuy,
// The one-of-each cycle already splits an equivalent group; a per-trip split on top
// would show the wrong (solo) counts, so it is only used for standalone products.
tripPurchases: isCombinedGroup ? const [] : _tripPurchasesForProduct(product),
),
);
}
}
return rows;
}

Widget _separatorDivider(BuildContext context, String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(label, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor)),
),
const Expanded(child: Divider()),
],
),
);
}

/// Snowflake note shown next to the ingredient name when the freezer strategy requires freezing
/// this item on arrival. Uses the same snowflake + blue as the menu page freeze warning, and the
/// same "freeze on arrival" wording as the copied list, so the two stay visually consistent.
Expand Down Expand Up @@ -376,49 +465,7 @@ class _ShoppingIngredientState extends State<ShoppingIngredient> {
const SizedBox(height: 8),

// Product rows (only for products whose unit matches a required quantity)
if (widget.ingredient.products.isNotEmpty)
...() {
List<MapEntry<int, Product>> matchingProducts = widget.ingredient.products
.asMap()
.entries
.where((entry) => widget.quantitiesDesired.any((q) => q.unit == entry.value.unit))
.toList();
List<Widget> rows = [];
for (int i = 0; i < matchingProducts.length; i++) {
Product product = matchingProducts[i].value;
ProductRecommendation recommendation = widget.productRecommendations.firstWhere(
(r) => r.product == product,
orElse: () => ProductRecommendation(product: product, packsNeeded: 0, overBuyWaste: 0, expiryWaste: 0, isViable: true),
);
if (i > 0) {
rows.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text("or", style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor)),
),
const Expanded(child: Divider()),
],
),
),
);
}
rows.add(
ShoppingProductRow(
product: product,
recommendation: recommendation,
isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste,
packsToBuy: _packsToBuyForProduct(product),
tripPurchases: _tripPurchasesForProduct(product),
),
);
}
return rows;
}(),
if (widget.ingredient.products.isNotEmpty) ..._buildProductRows(context, bestWaste),
],
),
),
Expand Down
92 changes: 64 additions & 28 deletions menu_management/lib/shopping/shopping_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -241,34 +241,7 @@ class _ShoppingPageState extends State<ShoppingPage> {
required List<Quantity> remaining,
bool freezeOnArrival = false,
}) {
// Round to whole units to match the on-screen / single-list display semantics.
// Sub-1-unit residuals (e.g., 0.3 teaspoons of a spice) drop out instead of rendering as "0 teaspoons".
List<Quantity> rounded = remaining.map((Quantity q) => Quantity(amount: q.amount.roundToDouble(), unit: q.unit)).toList();
if (!rounded.any((q) => q.amount > 0)) return;

String freezeSuffix = freezeOnArrival ? " (freeze on arrival)" : "";

if (ingredient.products.isNotEmpty) {
Quantity? primaryRemaining = rounded.firstWhereOrNull((q) => q.amount > 0 && ingredient.products.any((p) => p.unit == q.unit));
if (primaryRemaining == null) {
// No matching product unit -> fall back to raw amount line.
String amounts = rounded.where((q) => q.amount > 0).map((q) => "${q.amount.toFormattedAmount()} ${q.unit.name}").join(" + ");
buffer.writeln("${ingredient.name}: $amounts$freezeSuffix");
return;
}
buffer.writeln("${ingredient.name}$freezeSuffix");
for (Product product in ingredient.products) {
if (product.unit != primaryRemaining.unit) continue;
int packs = product.packsNeeded(primaryRemaining.amount);
if (packs <= 0) continue;
String label = product.packLabel() ?? "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack";
String packWord = packs == 1 ? "pack" : "packs";
buffer.writeln(" $label: $packs $packWord");
}
} else {
String amounts = rounded.where((q) => q.amount > 0).map((q) => "${q.amount.toFormattedAmount()} ${q.unit.name}").join(" + ");
buffer.writeln("${ingredient.name}: $amounts$freezeSuffix");
}
buffer.write(buildIngredientCopyLines(ingredient: ingredient, remaining: remaining, freezeOnArrival: freezeOnArrival));
}

List<ShoppingTrip> _planTrips() {
Expand All @@ -295,3 +268,66 @@ class _ShoppingPageState extends State<ShoppingPage> {
);
}
}

/// Builds the copied shopping-list text for one ingredient (one trip's worth of [remaining]).
///
/// Pure: takes the ingredient and its still-needed quantities, returns the lines as text
/// (empty when nothing is needed). Amounts are rounded to whole units so sub-1-unit residuals
/// drop out instead of rendering as "0 teaspoons".
///
/// Equivalent products (same [productEquivalenceKey], e.g. two pizza flavors of the same size)
/// share the packs one-of-each via [distributeEquivalentPacks], so each shows its cycled share
/// instead of every variant showing the full solo count. A variant that ends up with 0 packs is
/// skipped. Non-equivalent products each keep their full solo count.
String buildIngredientCopyLines({required Ingredient ingredient, required List<Quantity> remaining, bool freezeOnArrival = false}) {
StringBuffer buffer = StringBuffer();

List<Quantity> rounded = remaining.map((Quantity q) => Quantity(amount: q.amount.roundToDouble(), unit: q.unit)).toList();
if (!rounded.any((q) => q.amount > 0)) return "";

String freezeSuffix = freezeOnArrival ? " (freeze on arrival)" : "";

if (ingredient.products.isEmpty) {
String amounts = rounded.where((q) => q.amount > 0).map((q) => "${q.amount.toFormattedAmount()} ${q.unit.name}").join(" + ");
buffer.writeln("${ingredient.name}: $amounts$freezeSuffix");
return buffer.toString();
}

Quantity? primaryRemaining = rounded.firstWhereOrNull((q) => q.amount > 0 && ingredient.products.any((p) => p.unit == q.unit));
if (primaryRemaining == null) {
// No matching product unit -> fall back to raw amount line.
String amounts = rounded.where((q) => q.amount > 0).map((q) => "${q.amount.toFormattedAmount()} ${q.unit.name}").join(" + ");
buffer.writeln("${ingredient.name}: $amounts$freezeSuffix");
return buffer.toString();
}

buffer.writeln("${ingredient.name}$freezeSuffix");

// Products matching the primary unit, in configured order.
List<Product> matching = ingredient.products.where((Product p) => p.unit == primaryRemaining.unit).toList();

// Per-equivalence-group cycled shares: the group's solo cover split one-of-each.
Map<String, List<int>> sharesByKey = {};
Map<String, int> cursorByKey = {};
Map<String, List<Product>> groups = {};
for (Product product in matching) {
groups.putIfAbsent(productEquivalenceKey(product), () => <Product>[]).add(product);
}
for (MapEntry<String, List<Product>> group in groups.entries) {
int total = group.value.first.packsNeeded(primaryRemaining.amount);
sharesByKey[group.key] = distributeEquivalentPacks(totalPacks: total, groupSize: group.value.length);
}

for (Product product in matching) {
String key = productEquivalenceKey(product);
int cursor = cursorByKey[key] ?? 0;
cursorByKey[key] = cursor + 1;
int packs = sharesByKey[key]![cursor];
if (packs <= 0) continue;
String label = product.packLabel() ?? "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack";
String packWord = packs == 1 ? "pack" : "packs";
buffer.writeln(" $label: $packs $packWord");
}

return buffer.toString();
}
102 changes: 99 additions & 3 deletions menu_management/lib/shopping/waste_optimizer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ class ProductRecommendation {
final double shortfall;

double get totalWaste => overBuyWaste + expiryWaste;

/// Returns a copy with a different [packsNeeded]; used to spread packs across
/// equivalent products (all other fields, including waste, stay the same).
ProductRecommendation copyWithPacksNeeded(int newPacksNeeded) {
return ProductRecommendation(
product: product,
packsNeeded: newPacksNeeded,
overBuyWaste: overBuyWaste,
expiryWaste: expiryWaste,
isViable: isViable,
);
}
}

/// Ranks products by total waste (over-buy + expiry) for a given required amount.
Expand All @@ -58,7 +70,16 @@ class ProductRecommendation {
/// [ingredient] provides unit conversion functions.
/// [products] is the list of available products to compare.
///
/// Returns recommendations sorted by total waste (lowest first).
/// Returns recommendations sorted by total waste (lowest first). Ties keep the
/// input order so the ordering (and the cycle below) is deterministic.
///
/// Equivalent products are cycled: when two or more products share every
/// buying/consumption characteristic (see [productEquivalenceKey]) and one or more
/// packs are needed, the packs are spread one-of-each across them instead of loading
/// all packs onto the single top-ranked product. This gives variety (e.g. one of
/// each pizza flavor) without changing total cost, since equivalent products have
/// identical pack size and therefore identical waste. When only one pack is needed
/// the first product gets it and the rest get 0, so the group's total stays exact.
List<ProductRecommendation> rankProducts({
required double totalNeeded,
required List<CookingEvent> events,
Expand All @@ -71,8 +92,83 @@ List<ProductRecommendation> rankProducts({
return _simulateProduct(product: product, totalNeeded: totalNeeded, events: events, ingredient: ingredient);
}).toList();

recommendations.sort((ProductRecommendation a, ProductRecommendation b) => a.totalWaste.compareTo(b.totalWaste));
return recommendations;
// Stable sort: lowest waste first, ties broken by original input order so the
// cycle distribution below is deterministic.
List<int> order = List<int>.generate(recommendations.length, (int i) => i);
order.sort((int a, int b) {
int byWaste = recommendations[a].totalWaste.compareTo(recommendations[b].totalWaste);
if (byWaste != 0) return byWaste;
return a.compareTo(b);
});
recommendations = order.map((int i) => recommendations[i]).toList();

return _cycleEquivalentProducts(recommendations);
}

/// Spreads packs one-of-each across groups of equivalent products.
///
/// Products are grouped by [productEquivalenceKey]. Within a group of 2+ products
/// that each need the same number of packs `p >= 1`, the `p` packs are distributed
/// via [distributeEquivalentPacks] in the group's (already sorted) order: earlier
/// products absorb the remainder (so `p = 1` gives the first product 1 and the rest
/// 0, keeping the group total exact). Waste fields are left untouched: equivalent
/// products have identical waste, and it is a per-product "if this were the sole
/// supplier" figure that the UI compares to flag the best option, so all group
/// members stay tied for best.
List<ProductRecommendation> _cycleEquivalentProducts(List<ProductRecommendation> recommendations) {
Map<String, List<int>> groups = {};
for (int i = 0; i < recommendations.length; i++) {
groups.putIfAbsent(productEquivalenceKey(recommendations[i].product), () => []).add(i);
}

List<ProductRecommendation> result = List<ProductRecommendation>.of(recommendations);
for (List<int> memberIndexes in groups.values) {
if (memberIndexes.length < 2) continue;
int totalPacks = recommendations[memberIndexes.first].packsNeeded;
if (totalPacks <= 0) continue; // Nothing to distribute when no packs are needed.

List<int> shares = distributeEquivalentPacks(totalPacks: totalPacks, groupSize: memberIndexes.length);
for (int position = 0; position < memberIndexes.length; position++) {
int index = memberIndexes[position];
result[index] = recommendations[index].copyWithPacksNeeded(shares[position]);
}
}
return result;
}

/// Distributes [totalPacks] one-of-each across [groupSize] equivalent products.
///
/// Returns per-product pack counts in group order: `base = totalPacks ~/ groupSize`
/// each, with the remainder given to the earliest products. Examples: (3, 3) -> [1,1,1];
/// (4, 3) -> [2,1,1]; (2, 3) -> [1,1,0]; (5, 1) -> [5]. Single source of truth for the
/// one-of-each split used by [rankProducts], the shopping card, and the copied list.
List<int> distributeEquivalentPacks({required int totalPacks, required int groupSize}) {
if (groupSize <= 0) return const [];
int base = totalPacks ~/ groupSize;
int remainder = totalPacks % groupSize;
return List<int>.generate(groupSize, (int position) => base + (position < remainder ? 1 : 0));
}

/// The set of characteristics that make two products interchangeable for buying.
///
/// Includes every field that changes how a product is bought and consumed:
/// pack shape ([Product.itemsPerPack], [Product.quantityPerItem], [Product.unit]),
/// both shelf lives, and whether it can be frozen. Excludes [Product.link], which
/// only identifies the store item or variant (e.g. two pizza flavors of the same
/// size are equivalent and should be cycled). Price is not modeled on [Product];
/// if it is added later it belongs in this key.
///
/// Public so the shopping card and the copied list define equivalence in exactly
/// one place (same grouping the cycle above uses).
String productEquivalenceKey(Product product) {
return [
product.itemsPerPack,
product.quantityPerItem,
product.unit.name,
product.shelfLifeDaysOpened,
product.shelfLifeDaysClosed,
product.canBeFrozen,
].join("|");
}

/// Simulates sequential container consumption across cooking events for a single product.
Expand Down
Loading
Loading