From d79defe9e603113fb58d919c70b52017ea231dae Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:06:01 +0200 Subject: [PATCH 01/11] fix: turn off the hero animation of the menu page buttons The Export and Save buttons of the menu page both used the default FloatingActionButton hero tag. Flutter asserts that the tags of one page are unique, so each push to the shopping page threw in debug builds. A null hero tag turns off the animation and the assertion. Co-Authored-By: Claude Opus 5.5 --- menu_management/lib/menu/widgets/menu_page.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/menu_management/lib/menu/widgets/menu_page.dart b/menu_management/lib/menu/widgets/menu_page.dart index af40ca7..ffcf3fa 100644 --- a/menu_management/lib/menu/widgets/menu_page.dart +++ b/menu_management/lib/menu/widgets/menu_page.dart @@ -188,9 +188,12 @@ class _MenuPageState extends State { floatingActionButton: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - FloatingActionButton(tooltip: "Export menu", onPressed: _showExportDialog, child: const Icon(Icons.ios_share_rounded)), + // Two buttons with the default hero tag on one page make Flutter throw at each page change. + // A null hero tag turns off the hero animation of the button. + FloatingActionButton(heroTag: null, tooltip: "Export menu", onPressed: _showExportDialog, child: const Icon(Icons.ios_share_rounded)), const SizedBox(height: 10), FloatingActionButton( + heroTag: null, tooltip: "Save Menu", child: const Icon(Icons.save_rounded), onPressed: () { From ec6fb7157935db91c71c40676cfc7386d06d8d2b Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:06:01 +0200 Subject: [PATCH 02/11] refactor: extract the owned unit list of the shopping row Move the unit list of the header "Owned" dropdown out of the ShoppingIngredient state into the pure function availableOwnedUnits. The shopping page needs the same list to check a saved owned unit before it restores it, because the dropdown asserts that its value is one of its items. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_ingredient.dart | 67 ++++++++++--------- .../test/shopping_default_unit_test.dart | 27 ++++++++ 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index e85b784..81af7ff 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -29,6 +29,41 @@ class OwnedUnit { int get hashCode => unit.hashCode; } +/// Lists the units that the header "Owned" dropdown of an ingredient offers: pieces first, then +/// the other units of the products and of [desiredQuantities], then packs when the ingredient has +/// products. The shopping page reads it too, to drop a saved unit that the dropdown cannot show. +List availableOwnedUnits({required Ingredient ingredient, required List desiredQuantities}) { + Set seen = {}; + List unitEntries = []; + + // Collect unique units from products + for (Product product in ingredient.products) { + if (seen.add(product.unit)) { + unitEntries.add(OwnedUnit(unit: product.unit)); + } + } + // Add unique units from desired quantities not already covered by products + for (Quantity q in desiredQuantities) { + if (seen.add(q.unit)) { + unitEntries.add(OwnedUnit(unit: q.unit)); + } + } + + // Sort: pieces first, then other base units, then packs last + unitEntries.sort((OwnedUnit a, OwnedUnit b) { + if (a.unit == Unit.pieces && b.unit != Unit.pieces) return -1; + if (b.unit == Unit.pieces && a.unit != Unit.pieces) return 1; + return 0; + }); + + // Add "packs" at the end if there are products + if (ingredient.products.isNotEmpty) { + unitEntries.add(const OwnedUnit()); + } + + return unitEntries; +} + /// Returns the default [OwnedUnit] for an ingredient's "owned" dropdown. /// /// Defaults to pieces when all products are single-piece packs (1 piece per pack), @@ -133,37 +168,7 @@ class _ShoppingIngredientState extends State { (ShoppingTrip trip) => trip.items.any((TripItem item) => item.ingredientId == widget.ingredient.id && item.freezeOnArrival), ); - List get _availableUnits { - Set seen = {}; - List unitEntries = []; - - // Collect unique units from products - for (Product product in widget.ingredient.products) { - if (seen.add(product.unit)) { - unitEntries.add(OwnedUnit(unit: product.unit)); - } - } - // Add unique units from desired quantities not already covered by products - for (Quantity q in widget.quantitiesDesired) { - if (seen.add(q.unit)) { - unitEntries.add(OwnedUnit(unit: q.unit)); - } - } - - // Sort: pieces first, then other base units, then packs last - unitEntries.sort((OwnedUnit a, OwnedUnit b) { - if (a.unit == Unit.pieces && b.unit != Unit.pieces) return -1; - if (b.unit == Unit.pieces && a.unit != Unit.pieces) return 1; - return 0; - }); - - // Add "packs" at the end if there are products - if (widget.ingredient.products.isNotEmpty) { - unitEntries.add(const OwnedUnit()); - } - - return unitEntries; - } + List get _availableUnits => availableOwnedUnits(ingredient: widget.ingredient, desiredQuantities: widget.quantitiesDesired); @override void initState() { diff --git a/menu_management/test/shopping_default_unit_test.dart b/menu_management/test/shopping_default_unit_test.dart index c00851f..1674a03 100644 --- a/menu_management/test/shopping_default_unit_test.dart +++ b/menu_management/test/shopping_default_unit_test.dart @@ -122,4 +122,31 @@ void main() { }); }); }); + + group("availableOwnedUnits", () { + test("lists pieces first, then the other units, then packs", () { + Ingredient ingredient = _ingredientWithProducts([_product(Unit.grams), _product(Unit.pieces)]); + + List result = availableOwnedUnits( + ingredient: ingredient, + desiredQuantities: [const Quantity(amount: 1, unit: Unit.teaspoons)], + ); + + expect(result, [ + const OwnedUnit(unit: Unit.pieces), + const OwnedUnit(unit: Unit.grams), + const OwnedUnit(unit: Unit.teaspoons), + const OwnedUnit(), + ]); + }); + + test("offers no packs for an ingredient with no products", () { + List result = availableOwnedUnits( + ingredient: _ingredientNoProducts(), + desiredQuantities: [const Quantity(amount: 100, unit: Unit.grams)], + ); + + expect(result, [const OwnedUnit(unit: Unit.grams)]); + }); + }); } From 0e1bc5979dbdaab36c9bf87f75479e37a8ab7d7a Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:06:01 +0200 Subject: [PATCH 03/11] feat: store the shopping progress on the multi-week menu Add the ShoppingProgress model and the optional MultiWeekMenu.shoppingProgress field, so a .tsm file can keep what the user typed on the shopping page: the header owned amounts, the owned product counts and the "Try to make one trip" switch. The file keys each product count by the product link, because the product index changes when the user edits the products. The model converts between link keys and the index keys of the page, drops a link that matches no product, and keeps the stock of ingredients that the current menu does not need. The JSON leaves out an empty progress. fromJsonLenient drops each bad value with a warning, so one bad value never costs the whole menu file. Refs #79 Co-Authored-By: Claude Opus 5.5 --- .../lib/menu/models/multi_week_menu.dart | 12 +- .../menu/models/multi_week_menu.freezed.dart | 75 +++-- .../lib/menu/models/multi_week_menu.g.dart | 15 +- .../lib/shopping/shopping_progress.dart | 200 ++++++++++++ .../shopping/shopping_progress.freezed.dart | 289 ++++++++++++++++++ .../test/multi_week_menu_test.dart | 69 +++++ menu_management/test/persistency_test.dart | 91 ++++++ .../test/shopping_progress_test.dart | 252 +++++++++++++++ 8 files changed, 973 insertions(+), 30 deletions(-) create mode 100644 menu_management/lib/shopping/shopping_progress.dart create mode 100644 menu_management/lib/shopping/shopping_progress.freezed.dart create mode 100644 menu_management/test/shopping_progress_test.dart diff --git a/menu_management/lib/menu/models/multi_week_menu.dart b/menu_management/lib/menu/models/multi_week_menu.dart index 8d255f9..7314cf9 100644 --- a/menu_management/lib/menu/models/multi_week_menu.dart +++ b/menu_management/lib/menu/models/multi_week_menu.dart @@ -12,6 +12,7 @@ import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/shopping/ingredient_meal_requirement.dart"; import "package:menu_management/shopping/ingredient_source.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; part "multi_week_menu.freezed.dart"; part "multi_week_menu.g.dart"; @@ -22,7 +23,16 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { /// keeps the date-less day order of the WeekDay enum, which starts at Saturday. /// The date never changes the planning math, which stays in absolute day offsets. /// Use the functions in `menu_dates.dart` to turn a day offset into a date or a label. - const factory MultiWeekMenu({@Default([]) List weeks, @JsonKey(includeIfNull: false) DateTime? startDate}) = _MultiWeekMenu; + /// + /// [shoppingProgress] is what the user typed on the shopping page. It is optional: the JSON + /// leaves it out when it is null or empty, and a bad value in a file loads as null or loses + /// only that value (see [ShoppingProgress.fromJsonLenient]). + const factory MultiWeekMenu({ + @Default([]) List weeks, + @JsonKey(includeIfNull: false) DateTime? startDate, + @JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) + ShoppingProgress? shoppingProgress, + }) = _MultiWeekMenu; factory MultiWeekMenu.fromJson(Map json) => _$MultiWeekMenuFromJson(json); diff --git a/menu_management/lib/menu/models/multi_week_menu.freezed.dart b/menu_management/lib/menu/models/multi_week_menu.freezed.dart index 5015c2c..36d3a4e 100644 --- a/menu_management/lib/menu/models/multi_week_menu.freezed.dart +++ b/menu_management/lib/menu/models/multi_week_menu.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$MultiWeekMenu { - List get weeks;@JsonKey(includeIfNull: false) DateTime? get startDate; + List get weeks;@JsonKey(includeIfNull: false) DateTime? get startDate;@JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? get shoppingProgress; /// Create a copy of MultiWeekMenu /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $MultiWeekMenuCopyWith get copyWith => _$MultiWeekMenuCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is MultiWeekMenu&&const DeepCollectionEquality().equals(other.weeks, weeks)&&(identical(other.startDate, startDate) || other.startDate == startDate)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is MultiWeekMenu&&const DeepCollectionEquality().equals(other.weeks, weeks)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.shoppingProgress, shoppingProgress) || other.shoppingProgress == shoppingProgress)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weeks),startDate); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weeks),startDate,shoppingProgress); @override String toString() { - return 'MultiWeekMenu(weeks: $weeks, startDate: $startDate)'; + return 'MultiWeekMenu(weeks: $weeks, startDate: $startDate, shoppingProgress: $shoppingProgress)'; } @@ -48,11 +48,11 @@ abstract mixin class $MultiWeekMenuCopyWith<$Res> { factory $MultiWeekMenuCopyWith(MultiWeekMenu value, $Res Function(MultiWeekMenu) _then) = _$MultiWeekMenuCopyWithImpl; @useResult $Res call({ - List weeks,@JsonKey(includeIfNull: false) DateTime? startDate + List weeks,@JsonKey(includeIfNull: false) DateTime? startDate,@JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? shoppingProgress }); - +$ShoppingProgressCopyWith<$Res>? get shoppingProgress; } /// @nodoc @@ -65,14 +65,27 @@ class _$MultiWeekMenuCopyWithImpl<$Res> /// Create a copy of MultiWeekMenu /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? weeks = null,Object? startDate = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? weeks = null,Object? startDate = freezed,Object? shoppingProgress = freezed,}) { return _then(_self.copyWith( weeks: null == weeks ? _self.weeks : weeks // ignore: cast_nullable_to_non_nullable as List,startDate: freezed == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,shoppingProgress: freezed == shoppingProgress ? _self.shoppingProgress : shoppingProgress // ignore: cast_nullable_to_non_nullable +as ShoppingProgress?, )); } - +/// Create a copy of MultiWeekMenu +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ShoppingProgressCopyWith<$Res>? get shoppingProgress { + if (_self.shoppingProgress == null) { + return null; + } + + return $ShoppingProgressCopyWith<$Res>(_self.shoppingProgress!, (value) { + return _then(_self.copyWith(shoppingProgress: value)); + }); +} } @@ -154,10 +167,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate, @JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? shoppingProgress)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _MultiWeekMenu() when $default != null: -return $default(_that.weeks,_that.startDate);case _: +return $default(_that.weeks,_that.startDate,_that.shoppingProgress);case _: return orElse(); } @@ -175,10 +188,10 @@ return $default(_that.weeks,_that.startDate);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate, @JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? shoppingProgress) $default,) {final _that = this; switch (_that) { case _MultiWeekMenu(): -return $default(_that.weeks,_that.startDate);case _: +return $default(_that.weeks,_that.startDate,_that.shoppingProgress);case _: throw StateError('Unexpected subclass'); } @@ -195,10 +208,10 @@ return $default(_that.weeks,_that.startDate);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List weeks, @JsonKey(includeIfNull: false) DateTime? startDate, @JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? shoppingProgress)? $default,) {final _that = this; switch (_that) { case _MultiWeekMenu() when $default != null: -return $default(_that.weeks,_that.startDate);case _: +return $default(_that.weeks,_that.startDate,_that.shoppingProgress);case _: return null; } @@ -210,7 +223,7 @@ return $default(_that.weeks,_that.startDate);case _: @JsonSerializable() class _MultiWeekMenu extends MultiWeekMenu { - const _MultiWeekMenu({final List weeks = const [], @JsonKey(includeIfNull: false) this.startDate}): _weeks = weeks,super._(); + const _MultiWeekMenu({final List weeks = const [], @JsonKey(includeIfNull: false) this.startDate, @JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) this.shoppingProgress}): _weeks = weeks,super._(); factory _MultiWeekMenu.fromJson(Map json) => _$MultiWeekMenuFromJson(json); final List _weeks; @@ -221,6 +234,7 @@ class _MultiWeekMenu extends MultiWeekMenu { } @override@JsonKey(includeIfNull: false) final DateTime? startDate; +@override@JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) final ShoppingProgress? shoppingProgress; /// Create a copy of MultiWeekMenu /// with the given fields replaced by the non-null parameter values. @@ -235,16 +249,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _MultiWeekMenu&&const DeepCollectionEquality().equals(other._weeks, _weeks)&&(identical(other.startDate, startDate) || other.startDate == startDate)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MultiWeekMenu&&const DeepCollectionEquality().equals(other._weeks, _weeks)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.shoppingProgress, shoppingProgress) || other.shoppingProgress == shoppingProgress)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weeks),startDate); +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weeks),startDate,shoppingProgress); @override String toString() { - return 'MultiWeekMenu(weeks: $weeks, startDate: $startDate)'; + return 'MultiWeekMenu(weeks: $weeks, startDate: $startDate, shoppingProgress: $shoppingProgress)'; } @@ -255,11 +269,11 @@ abstract mixin class _$MultiWeekMenuCopyWith<$Res> implements $MultiWeekMenuCopy factory _$MultiWeekMenuCopyWith(_MultiWeekMenu value, $Res Function(_MultiWeekMenu) _then) = __$MultiWeekMenuCopyWithImpl; @override @useResult $Res call({ - List weeks,@JsonKey(includeIfNull: false) DateTime? startDate + List weeks,@JsonKey(includeIfNull: false) DateTime? startDate,@JsonKey(includeIfNull: false, fromJson: ShoppingProgress.fromJsonLenient, toJson: ShoppingProgress.toJsonOrNull) ShoppingProgress? shoppingProgress }); - +@override $ShoppingProgressCopyWith<$Res>? get shoppingProgress; } /// @nodoc @@ -272,15 +286,28 @@ class __$MultiWeekMenuCopyWithImpl<$Res> /// Create a copy of MultiWeekMenu /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? weeks = null,Object? startDate = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? weeks = null,Object? startDate = freezed,Object? shoppingProgress = freezed,}) { return _then(_MultiWeekMenu( weeks: null == weeks ? _self._weeks : weeks // ignore: cast_nullable_to_non_nullable as List,startDate: freezed == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,shoppingProgress: freezed == shoppingProgress ? _self.shoppingProgress : shoppingProgress // ignore: cast_nullable_to_non_nullable +as ShoppingProgress?, )); } - +/// Create a copy of MultiWeekMenu +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ShoppingProgressCopyWith<$Res>? get shoppingProgress { + if (_self.shoppingProgress == null) { + return null; + } + + return $ShoppingProgressCopyWith<$Res>(_self.shoppingProgress!, (value) { + return _then(_self.copyWith(shoppingProgress: value)); + }); +} } // dart format on diff --git a/menu_management/lib/menu/models/multi_week_menu.g.dart b/menu_management/lib/menu/models/multi_week_menu.g.dart index 41a2c50..3a3de31 100644 --- a/menu_management/lib/menu/models/multi_week_menu.g.dart +++ b/menu_management/lib/menu/models/multi_week_menu.g.dart @@ -16,10 +16,15 @@ _MultiWeekMenu _$MultiWeekMenuFromJson(Map json) => startDate: json['startDate'] == null ? null : DateTime.parse(json['startDate'] as String), + shoppingProgress: ShoppingProgress.fromJsonLenient( + json['shoppingProgress'], + ), ); -Map _$MultiWeekMenuToJson(_MultiWeekMenu instance) => - { - 'weeks': instance.weeks.map((e) => e.toJson()).toList(), - 'startDate': ?instance.startDate?.toIso8601String(), - }; +Map _$MultiWeekMenuToJson( + _MultiWeekMenu instance, +) => { + 'weeks': instance.weeks.map((e) => e.toJson()).toList(), + 'startDate': ?instance.startDate?.toIso8601String(), + 'shoppingProgress': ?ShoppingProgress.toJsonOrNull(instance.shoppingProgress), +}; diff --git a/menu_management/lib/shopping/shopping_progress.dart b/menu_management/lib/shopping/shopping_progress.dart new file mode 100644 index 0000000..c6a12cf --- /dev/null +++ b/menu_management/lib/shopping/shopping_progress.dart @@ -0,0 +1,200 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:menu_management/flutter_essentials/library.dart"; +import "package:menu_management/ingredients/models/ingredient.dart"; +import "package:menu_management/recipes/enums/unit.dart"; + +part "shopping_progress.freezed.dart"; + +/// One owned amount that the user typed in the header input of an ingredient. +/// A null [unit] means "packs", the same rule as `OwnedStock.unit`. +typedef OwnedAmountProgress = ({double amount, Unit? unit}); + +/// The JSON text of the "packs" unit, which has no [Unit] value. +const String _packsUnitName = "packs"; + +/// What the user typed on the shopping page, so the user can stop and continue later. +/// +/// The menu file (`.tsm`) stores this object on `MultiWeekMenu.shoppingProgress`. +/// - [ownedAmounts]: the header owned amount and unit per ingredient id. +/// - [ownedProductCounts]: the owned count per product, keyed by ingredient id and then by the +/// product link. The shopping page keys the counts by product index, and the index changes when +/// the user adds, removes or reorders products. The link stays the same. +/// - [useFreezerStrategy]: the "Try to make one trip" switch. +/// +/// Only amounts and counts above zero are stored. +@freezed +abstract class ShoppingProgress with _$ShoppingProgress { + const factory ShoppingProgress({ + @Default({}) Map ownedAmounts, + @Default({}) Map> ownedProductCounts, + @Default(false) bool useFreezerStrategy, + }) = _ShoppingProgress; + + const ShoppingProgress._(); + + /// Builds the progress from the state of the shopping page. + /// + /// [ownedAmounts] and [ownedProductCountsByIndex] hold the ingredients that the page shows. + /// The function turns each product index into the link of that product in [ingredients], and + /// drops an index that matches no product. + /// + /// The page shows only the ingredients of the current menu. The function copies the entries of + /// [previous] for every other ingredient, so the stock at home survives a new menu. + factory ShoppingProgress.fromPageState({ + required ShoppingProgress? previous, + required Map ownedAmounts, + required Map> ownedProductCountsByIndex, + required bool useFreezerStrategy, + required List ingredients, + }) { + Set pageIngredientIds = {...ownedAmounts.keys, ...ownedProductCountsByIndex.keys}; + + Map mergedAmounts = { + for (MapEntry entry in (previous?.ownedAmounts ?? const {}).entries) + if (!pageIngredientIds.contains(entry.key)) entry.key: entry.value, + for (MapEntry entry in ownedAmounts.entries) + if (entry.value.amount > 0) entry.key: entry.value, + }; + + Map> mergedCounts = { + for (MapEntry> entry in (previous?.ownedProductCounts ?? const {}).entries) + if (!pageIngredientIds.contains(entry.key)) entry.key: entry.value, + }; + for (MapEntry> entry in ownedProductCountsByIndex.entries) { + Ingredient? ingredient = ingredients.firstWhereOrNull((Ingredient i) => i.id == entry.key); + if (ingredient == null) continue; + Map countsByLink = { + for (MapEntry count in entry.value.entries) + if (count.value > 0 && count.key >= 0 && count.key < ingredient.products.length) ingredient.products[count.key].link: count.value, + }; + if (countsByLink.isNotEmpty) mergedCounts[entry.key] = countsByLink; + } + + return ShoppingProgress(ownedAmounts: mergedAmounts, ownedProductCounts: mergedCounts, useFreezerStrategy: useFreezerStrategy); + } + + /// True when the progress holds no amount, no count, and the trip switch is off. + bool get isEmpty => ownedAmounts.isEmpty && ownedProductCounts.isEmpty && !useFreezerStrategy; + + /// Returns the owned product counts keyed by the index of each product in [ingredients], which + /// is the shape that the shopping page uses. + /// + /// A count whose link matches no product of its ingredient is dropped with a warning. The counts + /// of an ingredient that is not in [ingredients] are dropped with a warning too. + Map> ownedProductCountsByIndex({required List ingredients}) { + Map> countsByIndex = {}; + for (MapEntry> entry in ownedProductCounts.entries) { + Ingredient? ingredient = ingredients.firstWhereOrNull((Ingredient i) => i.id == entry.key); + if (ingredient == null) { + Debug.logWarning(true, 'Shopping progress dropped: no ingredient has the id "${entry.key}".', asAssertion: false); + continue; + } + Map counts = {}; + for (MapEntry count in entry.value.entries) { + int productIndex = ingredient.products.indexWhere((product) => product.link == count.key); + if (productIndex < 0) { + Debug.logWarning(true, 'Shopping progress dropped: ${ingredient.name} has no product with the link "${count.key}".', asAssertion: false); + continue; + } + counts[productIndex] = count.value; + } + if (counts.isNotEmpty) countsByIndex[entry.key] = counts; + } + return countsByIndex; + } + + /// Writes the progress as JSON. Each empty part is left out. + Map toJson() { + return { + if (useFreezerStrategy) "useFreezerStrategy": true, + if (ownedAmounts.isNotEmpty) + "ownedAmounts": { + for (MapEntry entry in ownedAmounts.entries) + entry.key: {"amount": entry.value.amount, "unit": entry.value.unit?.name ?? _packsUnitName}, + }, + if (ownedProductCounts.isNotEmpty) "ownedProductCounts": ownedProductCounts, + }; + } + + /// Writes [progress] as JSON, or null when it holds nothing. `MultiWeekMenu` uses it, so the + /// menu file leaves out an empty progress. + static Map? toJsonOrNull(ShoppingProgress? progress) { + if (progress == null || progress.isEmpty) return null; + return progress.toJson(); + } + + /// Reads the progress from JSON, and never throws. + /// + /// The menu loader catches every error and returns no menu, so one bad value would cost the + /// user the whole file. This function drops each bad value with a warning and keeps the rest. + /// It returns null when [json] is not an object or when nothing valid is left. + static ShoppingProgress? fromJsonLenient(Object? json) { + if (json == null) return null; + if (json is! Map) { + Debug.logWarning(true, 'Shopping progress dropped: "$json" is not an object.', asAssertion: false); + return null; + } + + Object? rawSwitch = json["useFreezerStrategy"]; + if (rawSwitch != null && rawSwitch is! bool) { + Debug.logWarning(true, 'Shopping progress: the trip switch "$rawSwitch" is not true or false.', asAssertion: false); + } + + ShoppingProgress progress = ShoppingProgress( + ownedAmounts: _parseOwnedAmounts(json["ownedAmounts"]), + ownedProductCounts: _parseOwnedProductCounts(json["ownedProductCounts"]), + useFreezerStrategy: rawSwitch == true, + ); + return progress.isEmpty ? null : progress; + } + + static Map _parseOwnedAmounts(Object? json) { + if (json == null) return {}; + if (json is! Map) { + Debug.logWarning(true, 'Shopping progress: the owned amounts "$json" are not an object.', asAssertion: false); + return {}; + } + Map amounts = {}; + for (MapEntry entry in json.entries) { + Object? rawEntry = entry.value; + Object? rawAmount = rawEntry is Map ? rawEntry["amount"] : null; + Object? rawUnit = rawEntry is Map ? rawEntry["unit"] : null; + Unit? unit = Unit.values.firstWhereOrNull((Unit u) => u.name == rawUnit); + bool isValidUnit = unit != null || rawUnit == _packsUnitName; + if (entry.key is! String || !_isPositiveNumber(rawAmount) || !isValidUnit) { + Debug.logWarning(true, 'Shopping progress: dropped the owned amount "$rawEntry" of "${entry.key}".', asAssertion: false); + continue; + } + amounts[entry.key as String] = (amount: (rawAmount as num).toDouble(), unit: unit); + } + return amounts; + } + + static Map> _parseOwnedProductCounts(Object? json) { + if (json == null) return {}; + if (json is! Map) { + Debug.logWarning(true, 'Shopping progress: the product counts "$json" are not an object.', asAssertion: false); + return {}; + } + Map> countsByIngredient = {}; + for (MapEntry entry in json.entries) { + Object? rawCounts = entry.value; + if (entry.key is! String || rawCounts is! Map) { + Debug.logWarning(true, 'Shopping progress: dropped the product counts "$rawCounts" of "${entry.key}".', asAssertion: false); + continue; + } + Map counts = {}; + for (MapEntry count in rawCounts.entries) { + if (count.key is! String || !_isPositiveNumber(count.value)) { + Debug.logWarning(true, 'Shopping progress: dropped the count "${count.value}" of "${count.key}".', asAssertion: false); + continue; + } + counts[count.key as String] = (count.value as num).toDouble(); + } + if (counts.isNotEmpty) countsByIngredient[entry.key as String] = counts; + } + return countsByIngredient; + } + + static bool _isPositiveNumber(Object? value) => value is num && value.isFinite && value > 0; +} diff --git a/menu_management/lib/shopping/shopping_progress.freezed.dart b/menu_management/lib/shopping/shopping_progress.freezed.dart new file mode 100644 index 0000000..30fab34 --- /dev/null +++ b/menu_management/lib/shopping/shopping_progress.freezed.dart @@ -0,0 +1,289 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'shopping_progress.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ShoppingProgress { + + Map get ownedAmounts; Map> get ownedProductCounts; bool get useFreezerStrategy; +/// Create a copy of ShoppingProgress +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ShoppingProgressCopyWith get copyWith => _$ShoppingProgressCopyWithImpl(this as ShoppingProgress, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ShoppingProgress&&const DeepCollectionEquality().equals(other.ownedAmounts, ownedAmounts)&&const DeepCollectionEquality().equals(other.ownedProductCounts, ownedProductCounts)&&(identical(other.useFreezerStrategy, useFreezerStrategy) || other.useFreezerStrategy == useFreezerStrategy)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(ownedAmounts),const DeepCollectionEquality().hash(ownedProductCounts),useFreezerStrategy); + +@override +String toString() { + return 'ShoppingProgress(ownedAmounts: $ownedAmounts, ownedProductCounts: $ownedProductCounts, useFreezerStrategy: $useFreezerStrategy)'; +} + + +} + +/// @nodoc +abstract mixin class $ShoppingProgressCopyWith<$Res> { + factory $ShoppingProgressCopyWith(ShoppingProgress value, $Res Function(ShoppingProgress) _then) = _$ShoppingProgressCopyWithImpl; +@useResult +$Res call({ + Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy +}); + + + + +} +/// @nodoc +class _$ShoppingProgressCopyWithImpl<$Res> + implements $ShoppingProgressCopyWith<$Res> { + _$ShoppingProgressCopyWithImpl(this._self, this._then); + + final ShoppingProgress _self; + final $Res Function(ShoppingProgress) _then; + +/// Create a copy of ShoppingProgress +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? ownedAmounts = null,Object? ownedProductCounts = null,Object? useFreezerStrategy = null,}) { + return _then(_self.copyWith( +ownedAmounts: null == ownedAmounts ? _self.ownedAmounts : ownedAmounts // ignore: cast_nullable_to_non_nullable +as Map,ownedProductCounts: null == ownedProductCounts ? _self.ownedProductCounts : ownedProductCounts // ignore: cast_nullable_to_non_nullable +as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ShoppingProgress]. +extension ShoppingProgressPatterns on ShoppingProgress { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ShoppingProgress value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ShoppingProgress() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ShoppingProgress value) $default,){ +final _that = this; +switch (_that) { +case _ShoppingProgress(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ShoppingProgress value)? $default,){ +final _that = this; +switch (_that) { +case _ShoppingProgress() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ShoppingProgress() when $default != null: +return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy) $default,) {final _that = this; +switch (_that) { +case _ShoppingProgress(): +return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,) {final _that = this; +switch (_that) { +case _ShoppingProgress() when $default != null: +return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ShoppingProgress extends ShoppingProgress { + const _ShoppingProgress({final Map ownedAmounts = const {}, final Map> ownedProductCounts = const {}, this.useFreezerStrategy = false}): _ownedAmounts = ownedAmounts,_ownedProductCounts = ownedProductCounts,super._(); + + + final Map _ownedAmounts; +@override@JsonKey() Map get ownedAmounts { + if (_ownedAmounts is EqualUnmodifiableMapView) return _ownedAmounts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_ownedAmounts); +} + + final Map> _ownedProductCounts; +@override@JsonKey() Map> get ownedProductCounts { + if (_ownedProductCounts is EqualUnmodifiableMapView) return _ownedProductCounts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_ownedProductCounts); +} + +@override@JsonKey() final bool useFreezerStrategy; + +/// Create a copy of ShoppingProgress +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ShoppingProgressCopyWith<_ShoppingProgress> get copyWith => __$ShoppingProgressCopyWithImpl<_ShoppingProgress>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ShoppingProgress&&const DeepCollectionEquality().equals(other._ownedAmounts, _ownedAmounts)&&const DeepCollectionEquality().equals(other._ownedProductCounts, _ownedProductCounts)&&(identical(other.useFreezerStrategy, useFreezerStrategy) || other.useFreezerStrategy == useFreezerStrategy)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_ownedAmounts),const DeepCollectionEquality().hash(_ownedProductCounts),useFreezerStrategy); + +@override +String toString() { + return 'ShoppingProgress(ownedAmounts: $ownedAmounts, ownedProductCounts: $ownedProductCounts, useFreezerStrategy: $useFreezerStrategy)'; +} + + +} + +/// @nodoc +abstract mixin class _$ShoppingProgressCopyWith<$Res> implements $ShoppingProgressCopyWith<$Res> { + factory _$ShoppingProgressCopyWith(_ShoppingProgress value, $Res Function(_ShoppingProgress) _then) = __$ShoppingProgressCopyWithImpl; +@override @useResult +$Res call({ + Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy +}); + + + + +} +/// @nodoc +class __$ShoppingProgressCopyWithImpl<$Res> + implements _$ShoppingProgressCopyWith<$Res> { + __$ShoppingProgressCopyWithImpl(this._self, this._then); + + final _ShoppingProgress _self; + final $Res Function(_ShoppingProgress) _then; + +/// Create a copy of ShoppingProgress +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? ownedAmounts = null,Object? ownedProductCounts = null,Object? useFreezerStrategy = null,}) { + return _then(_ShoppingProgress( +ownedAmounts: null == ownedAmounts ? _self._ownedAmounts : ownedAmounts // ignore: cast_nullable_to_non_nullable +as Map,ownedProductCounts: null == ownedProductCounts ? _self._ownedProductCounts : ownedProductCounts // ignore: cast_nullable_to_non_nullable +as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/menu_management/test/multi_week_menu_test.dart b/menu_management/test/multi_week_menu_test.dart index 7ec344b..26c6ece 100644 --- a/menu_management/test/multi_week_menu_test.dart +++ b/menu_management/test/multi_week_menu_test.dart @@ -17,6 +17,7 @@ import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/shopping/ingredient_meal_requirement.dart"; import "package:menu_management/shopping/ingredient_source.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; /// Helper to create a minimal recipe for testing Recipe _testRecipe({required String id, required String name, List instructions = const []}) { @@ -1393,4 +1394,72 @@ void main() { ]); }); }); + + group("MultiWeekMenu shopping progress", () { + const ShoppingProgress progress = ShoppingProgress( + ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, + ownedProductCounts: { + "rice": {"https://example.com/rice": 2}, + }, + useFreezerStrategy: true, + ); + + test("survives a JSON round trip", () { + Recipe recipe = _testRecipe(id: "r1", name: "Soup"); + MultiWeekMenu multi = MultiWeekMenu( + shoppingProgress: progress, + weeks: [_singleMealMenu(recipe: recipe)], + ); + + MultiWeekMenu restored = MultiWeekMenu.fromJson(jsonDecode(jsonEncode(multi.toJson()))); + + expect(restored.shoppingProgress, progress); + }); + + test("writes the progress under its own key", () { + Recipe recipe = _testRecipe(id: "r1", name: "Soup"); + MultiWeekMenu multi = MultiWeekMenu( + shoppingProgress: progress, + weeks: [_singleMealMenu(recipe: recipe)], + ); + + expect(multi.toJson()["shoppingProgress"], { + "useFreezerStrategy": true, + "ownedAmounts": { + "salt": {"amount": 20.0, "unit": "grams"}, + }, + "ownedProductCounts": { + "rice": {"https://example.com/rice": 2.0}, + }, + }); + }); + + test("is absent from the JSON when the menu has no progress", () { + Recipe recipe = _testRecipe(id: "r1", name: "Soup"); + MultiWeekMenu multi = MultiWeekMenu(weeks: [_singleMealMenu(recipe: recipe)]); + + expect(multi.toJson().containsKey("shoppingProgress"), isFalse); + }); + + test("is absent from the JSON when the progress is empty", () { + Recipe recipe = _testRecipe(id: "r1", name: "Soup"); + MultiWeekMenu multi = MultiWeekMenu( + shoppingProgress: const ShoppingProgress(), + weeks: [_singleMealMenu(recipe: recipe)], + ); + + expect(multi.toJson().containsKey("shoppingProgress"), isFalse); + }); + + test("a malformed progress loads as no progress and keeps the meals", () { + Recipe recipe = _testRecipe(id: "r1", name: "Soup"); + Map json = MultiWeekMenu(weeks: [_singleMealMenu(recipe: recipe)]).toJson(); + json["shoppingProgress"] = "half done"; + + MultiWeekMenu restored = MultiWeekMenu.fromJson(jsonDecode(jsonEncode(json))); + + expect(restored.shoppingProgress, isNull); + expect(restored.weeks, [_singleMealMenu(recipe: recipe)]); + }); + }); } diff --git a/menu_management/test/persistency_test.dart b/menu_management/test/persistency_test.dart index 69166d2..ea0c10c 100644 --- a/menu_management/test/persistency_test.dart +++ b/menu_management/test/persistency_test.dart @@ -23,6 +23,7 @@ import "package:menu_management/recipes/models/instruction.dart"; import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; /// A save dialog that picks no file of its own, so a test says what the dialog returns. /// `FilePicker.platform` accepts any subclass of [FilePicker], and only `saveFile` is used here. @@ -433,6 +434,51 @@ void main() { expect(menu!.weeks.first.meals.first.subMeals.first.cooking, isNotNull); expect(menu.weeks.first.meals.first.subMeals.first.cooking!.recipeId, "valid-id"); }); + + test("keeps the shopping progress of a menu whose recipes are all missing", () async { + File tsmFile = File("${tempDir.path}/progress_missing_recipes.tsm"); + tsmFile.writeAsStringSync( + jsonEncode({ + "shoppingProgress": { + "useFreezerStrategy": true, + "ownedProductCounts": { + "rice": {"https://example.com/rice": 2}, + }, + }, + "weeks": jsonDecode(_validTsmContent())["weeks"], + }), + ); + + MultiWeekMenu? menu = await Persistency.loadMenuFromPath(tsmFile.path, recipes: []); + + expect( + menu!.shoppingProgress, + const ShoppingProgress( + ownedProductCounts: { + "rice": {"https://example.com/rice": 2}, + }, + useFreezerStrategy: true, + ), + ); + }); + + test("keeps every meal when the shopping progress in the file is malformed", () async { + Recipe r1 = _recipe(); + Recipe r2 = _recipe(id: "r2", name: "Dinner Recipe"); + File tsmFile = File("${tempDir.path}/broken_progress.tsm"); + tsmFile.writeAsStringSync( + jsonEncode({ + "shoppingProgress": [1, 2, 3], + "weeks": jsonDecode(_validTsmContent())["weeks"], + }), + ); + + MultiWeekMenu? menu = await Persistency.loadMenuFromPath(tsmFile.path, recipes: [r1, r2]); + + expect(menu, isNotNull); + expect(menu!.shoppingProgress, isNull); + expect(menu.weeks.first.meals.length, 2); + }); }); // ── saveDataToPath ── @@ -797,6 +843,51 @@ void main() { Map cooking = savedJson["weeks"][0]["meals"][0]["subMeals"][0]["cooking"]; expect(cooking["ref_name"], "Pizza"); }); + + test("saves and loads the shopping progress", () async { + Recipe r1 = _recipe(id: "r1", name: "Lunch Recipe"); + List recipes = [r1]; + const ShoppingProgress progress = ShoppingProgress( + ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, + ownedProductCounts: { + "rice": {"https://example.com/rice": 2}, + }, + useFreezerStrategy: true, + ); + MultiWeekMenu menu = MultiWeekMenu( + shoppingProgress: progress, + weeks: [ + Menu( + meals: [_meal(weekDay: WeekDay.saturday, mealType: MealType.lunch, recipe: r1)], + ), + ], + ); + + String path = "${tempDir.path}/progress_menu_test.tsm"; + await Persistency.saveMenuToPath(path: path, multiWeekMenu: menu, recipes: recipes); + + MultiWeekMenu? loaded = await Persistency.loadMenuFromPath(path, recipes: recipes); + + expect(loaded!.shoppingProgress, progress); + }); + + test("omits the shopping progress from the file when the menu has none", () async { + Recipe r1 = _recipe(id: "r1", name: "Lunch Recipe"); + List recipes = [r1]; + MultiWeekMenu menu = MultiWeekMenu( + weeks: [ + Menu( + meals: [_meal(weekDay: WeekDay.saturday, mealType: MealType.lunch, recipe: r1)], + ), + ], + ); + + String path = "${tempDir.path}/no_progress_menu_test.tsm"; + await Persistency.saveMenuToPath(path: path, multiWeekMenu: menu, recipes: recipes); + + Map savedJson = jsonDecode(File(path).readAsStringSync()); + expect(savedJson.containsKey("shoppingProgress"), isFalse); + }); }); // ── toJson produces fully expanded maps (explicitToJson) ── diff --git a/menu_management/test/shopping_progress_test.dart b/menu_management/test/shopping_progress_test.dart new file mode 100644 index 0000000..4aef97f --- /dev/null +++ b/menu_management/test/shopping_progress_test.dart @@ -0,0 +1,252 @@ +import "dart:convert"; + +import "package:flutter_test/flutter_test.dart"; +import "package:menu_management/ingredients/models/ingredient.dart"; +import "package:menu_management/ingredients/models/product.dart"; +import "package:menu_management/recipes/enums/unit.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; + +const String _riceSmallLink = "https://example.com/rice-500"; +const String _riceLargeLink = "https://example.com/rice-1000"; + +const Ingredient _rice = Ingredient( + id: "rice", + name: "Rice", + products: [ + Product(link: _riceSmallLink, quantityPerItem: 500, itemsPerPack: 1, unit: Unit.grams), + Product(link: _riceLargeLink, quantityPerItem: 1000, itemsPerPack: 1, unit: Unit.grams), + ], +); + +const Ingredient _salt = Ingredient(id: "salt", name: "Salt"); + +void main() { + group("ShoppingProgress JSON", () { + test("writes every part of the progress", () { + const ShoppingProgress progress = ShoppingProgress( + ownedAmounts: {"salt": (amount: 20, unit: Unit.grams), "oil": (amount: 1.5, unit: null)}, + ownedProductCounts: { + "rice": {_riceLargeLink: 2}, + }, + useFreezerStrategy: true, + ); + + expect(progress.toJson(), { + "useFreezerStrategy": true, + "ownedAmounts": { + "salt": {"amount": 20.0, "unit": "grams"}, + "oil": {"amount": 1.5, "unit": "packs"}, + }, + "ownedProductCounts": { + "rice": {_riceLargeLink: 2.0}, + }, + }); + }); + + test("leaves out each empty part", () { + expect(const ShoppingProgress(useFreezerStrategy: true).toJson(), {"useFreezerStrategy": true}); + expect(const ShoppingProgress(ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}).toJson(), { + "ownedAmounts": { + "salt": {"amount": 20.0, "unit": "grams"}, + }, + }); + }); + + test("writes nothing for an empty progress", () { + expect(ShoppingProgress.toJsonOrNull(const ShoppingProgress()), isNull); + expect(ShoppingProgress.toJsonOrNull(null), isNull); + }); + + test("survives a JSON round trip", () { + const ShoppingProgress progress = ShoppingProgress( + ownedAmounts: {"salt": (amount: 20, unit: Unit.grams), "oil": (amount: 1.5, unit: null)}, + ownedProductCounts: { + "rice": {_riceLargeLink: 2}, + }, + useFreezerStrategy: true, + ); + + expect(ShoppingProgress.fromJsonLenient(jsonDecode(jsonEncode(progress.toJson()))), progress); + }); + + test("reads no progress from a value that is not an object", () { + expect(ShoppingProgress.fromJsonLenient(null), isNull); + expect(ShoppingProgress.fromJsonLenient("full"), isNull); + expect(ShoppingProgress.fromJsonLenient([1, 2]), isNull); + }); + + test("reads no progress from an object that holds nothing", () { + expect(ShoppingProgress.fromJsonLenient({}), isNull); + }); + + test("drops only the bad owned amounts", () { + Map json = { + "ownedAmounts": { + "salt": {"amount": 20, "unit": "grams"}, + "text": {"amount": "lots", "unit": "grams"}, + "negative": {"amount": -3, "unit": "grams"}, + "unknownUnit": {"amount": 2, "unit": "buckets"}, + "noUnit": {"amount": 2}, + "notAnObject": 4, + }, + }; + + expect(ShoppingProgress.fromJsonLenient(json), const ShoppingProgress(ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)})); + }); + + test("drops only the bad product counts", () { + Map json = { + "ownedProductCounts": { + "rice": {_riceSmallLink: 1, _riceLargeLink: "two"}, + "milk": {"https://example.com/milk": 0}, + "pasta": "three", + }, + }; + + expect( + ShoppingProgress.fromJsonLenient(json), + const ShoppingProgress( + ownedProductCounts: { + "rice": {_riceSmallLink: 1}, + }, + ), + ); + }); + + test("drops a trip switch that is not true or false", () { + Map json = { + "useFreezerStrategy": "yes", + "ownedAmounts": { + "salt": {"amount": 20, "unit": "grams"}, + }, + }; + + expect(ShoppingProgress.fromJsonLenient(json), const ShoppingProgress(ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)})); + }); + + test("drops a whole part that is not an object", () { + Map json = {"useFreezerStrategy": true, "ownedAmounts": "none", "ownedProductCounts": 5}; + + expect(ShoppingProgress.fromJsonLenient(json), const ShoppingProgress(useFreezerStrategy: true)); + }); + }); + + group("ShoppingProgress.ownedProductCountsByIndex", () { + test("turns each product link into the index of that product", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "rice": {_riceLargeLink: 2, _riceSmallLink: 1}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_rice, _salt]), { + "rice": {1: 2.0, 0: 1.0}, + }); + }); + + test("drops a count whose link matches no product", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "rice": {_riceSmallLink: 1, "https://example.com/rice-gone": 3}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_rice]), { + "rice": {0: 1.0}, + }); + }); + + test("drops the counts of an ingredient that no longer exists", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "rice": {_riceSmallLink: 1}, + "gone": {"https://example.com/gone": 2}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_rice]), { + "rice": {0: 1.0}, + }); + }); + }); + + group("ShoppingProgress.fromPageState", () { + test("keys each product count by the link of the product", () { + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: null, + ownedAmounts: const {"salt": (amount: 20, unit: Unit.grams), "rice": (amount: 0, unit: null)}, + ownedProductCountsByIndex: const { + "salt": {}, + "rice": {1: 2, 0: 0}, + }, + useFreezerStrategy: true, + ingredients: const [_rice, _salt], + ); + + expect( + progress, + const ShoppingProgress( + ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, + ownedProductCounts: { + "rice": {_riceLargeLink: 2}, + }, + useFreezerStrategy: true, + ), + ); + }); + + test("drops a count whose index matches no product", () { + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: null, + ownedAmounts: const {"rice": (amount: 0, unit: null)}, + ownedProductCountsByIndex: const { + "rice": {0: 1, 5: 3}, + }, + useFreezerStrategy: false, + ingredients: const [_rice], + ); + + expect( + progress, + const ShoppingProgress( + ownedProductCounts: { + "rice": {_riceSmallLink: 1}, + }, + ), + ); + }); + + test("keeps the stock of an ingredient that the page does not show", () { + const ShoppingProgress previous = ShoppingProgress( + ownedAmounts: {"flour": (amount: 300, unit: Unit.grams), "salt": (amount: 50, unit: Unit.grams)}, + ownedProductCounts: { + "pasta": {"https://example.com/pasta": 2}, + "rice": {_riceSmallLink: 4}, + }, + useFreezerStrategy: true, + ); + + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: previous, + ownedAmounts: const {"salt": (amount: 0, unit: Unit.grams), "rice": (amount: 0, unit: null)}, + ownedProductCountsByIndex: const { + "salt": {}, + "rice": {1: 1}, + }, + useFreezerStrategy: false, + ingredients: const [_rice, _salt], + ); + + expect( + progress, + const ShoppingProgress( + ownedAmounts: {"flour": (amount: 300, unit: Unit.grams)}, + ownedProductCounts: { + "pasta": {"https://example.com/pasta": 2}, + "rice": {_riceLargeLink: 1}, + }, + ), + ); + }); + }); +} From faaccdb351a297a4c8977faad7df1e2601e9ad64 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:06:02 +0200 Subject: [PATCH 04/11] feat: restore and save the shopping progress on the shopping page The shopping page now restores the owned stock and the trip switch from the menu, and reports each change to MenuPage through a callback. MenuPage stores it through its menu setter, so the MenuProvider mirror stays in sync and every way to leave the page, the system back button too, keeps the progress. A new Save button saves the .tsm with the progress on screen, through the same dialog as the menu page. A menu regeneration keeps the progress, like it keeps the first day. Closes #79 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- adr/0003-tsr-file-persistence.md | 3 + .../lib/menu/widgets/menu_page.dart | 20 +- .../lib/shopping/shopping_page.dart | 71 ++++++- menu_management/test/menu_page_test.dart | 33 +++ menu_management/test/shopping_page_test.dart | 193 +++++++++++++++++- 6 files changed, 309 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1cc74b6..701aa24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it; | Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | | | Build release | `cd menu_management && flutter build windows` | | | Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop | -| Run all tests | `cd menu_management && flutter test test/` | 1073 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1108 tests across 43 files | | Run single test | `cd menu_management && flutter test test/.dart` | | | List devices | `flutter devices` | | | Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` | @@ -106,6 +106,7 @@ Core logic in `menu_generator.dart`. Fully parameterized: receives `List - **`.tsr` files**: JSON with top-level `"Ingredients"` and `"Recipes"` arrays. On save, `ref_name` fields are injected into `IngredientUsage` entries for human readability. - **`.tsm` files**: Menus store `recipeId` (UUID) + `ref_name` per meal, not full Recipe objects. On load, each `recipeId` is validated; missing recipes are skipped with a warning. A menu may also carry `startDate`, the real date of menu day 0; a file without it keeps the Saturday-first, date-less behavior. Use `menu/menu_dates.dart` to turn a day offset into a date or a label. +- **Shopping progress**: a `.tsm` file can carry `shoppingProgress`, the owned stock and the trip switch of `ShoppingPage`. It keys product counts by product `link`, not index; `shopping/shopping_progress.dart` converts at the page boundary. See ADR 0003. - **PDF export**: each PDF is two files. `_pdf_document.dart` decides what the PDF says, and `_pdf.dart` renders it and exposes its composed strings as public pure functions. The split keeps the content testable with no PDF to decode. The two PDFs are `menu/menu_pdf.dart` and `shopping/shopping_pdf.dart`. `Persistency.saveBytes` writes any export that is not JSON, and `Persistency.supportsFileSaving()` says if the device has a save dialog. - Data is **not** automatically saved -- users must manually save via the save button - On startup, dialogs ask whether to load last session, bundled defaults, or skip (for both recipes and menus) diff --git a/adr/0003-tsr-file-persistence.md b/adr/0003-tsr-file-persistence.md index 2daea60..9f2f75b 100644 --- a/adr/0003-tsr-file-persistence.md +++ b/adr/0003-tsr-file-persistence.md @@ -12,6 +12,8 @@ Persist data as JSON-based files via `FilePicker`, using two distinct formats: - **`.tsm` files** -- Store generated menus (single-week or multi-week). Each meal's `Cooking` object stores a `recipeId` (UUID string) referencing a recipe in the recipe book, plus a `ref_name` field for human readability (ignored by the app on load). Saved through `Persistency.saveMenu()`, loaded through `Persistency.loadMultiWeekMenu()`. The loader detects whether the JSON contains a `"weeks"` key (multi-week format) or just `"meals"` (old single-week format) and handles both. On load, each `recipeId` is validated against the loaded recipe book; meals referencing missing recipes have their cooking set to null with a warning logged. +- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link`, not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link does not. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. `ShoppingProgress.fromJsonLenient` drops each bad value with a warning, and the page drops a count whose link matches no product. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu. + - **Exports that are not JSON** (for example the menu PDF) -- `Persistency.saveBytes()` asks the user where to save through the same `FilePicker` save dialog, then writes raw bytes with `saveBytesToPath()`. `saveBytesToPath()` writes the picked path as it came back and adds no extension: the save dialog asks about the name that the user typed, so a changed name could overwrite a file that the user never saw. `Persistency.supportsFileSaving()` reports whether the device has a save dialog at all; `FileExportOption` asks it before it builds any byte and warns the user instead of failing silently on iOS/Android. The `.tsm` and `.tsr` saves do not ask it yet, so they still fail without a word on mobile. An export writes no `last_session.json` entry, because the app has nothing that reads such a file back. `Persistency.defaultMenuFileName()` takes an `extension` parameter so a `.tsm` save and a PDF export of the same menu propose the same file name, differing only in extension. On startup, sequential dialogs ask the user whether to load recipes (last saved, bundled defaults, or skip) and then whether to load a menu (same options). The app tracks the last saved/loaded `.tsr` and `.tsm` paths in `%APPDATA%/MenuManagement/last_session.json`. @@ -21,6 +23,7 @@ On startup, sequential dialogs ask the user whether to load recipes (last saved, - No database dependency; fully portable data files. - Save/load is unavailable on iOS/Android due to `FilePicker` limitations. - Users manage their own file storage locations. +- A product whose `link` changes loses its saved owned count, because the link is the key of that count. - Menu configurations (the 21-slot grid in `MenuProvider`) are not persisted. They reset to defaults on each app launch. - The release-mode startup load requires user interaction (file picker), so there is no silent background restore. - A byte-based export (the PDF) reuses the save dialog but skips the last-session bookkeeping, so it cannot silently grow into a format the app tries to load back; a future loadable binary format needs its own decision. diff --git a/menu_management/lib/menu/widgets/menu_page.dart b/menu_management/lib/menu/widgets/menu_page.dart index ffcf3fa..e7ff0bf 100644 --- a/menu_management/lib/menu/widgets/menu_page.dart +++ b/menu_management/lib/menu/widgets/menu_page.dart @@ -16,6 +16,7 @@ import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; import "package:menu_management/recipes/widgets/play_recipe_page.dart"; import "package:menu_management/shopping/shopping_page.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; import "package:menu_management/theme/theme_custom.dart"; class MenuPage extends StatefulWidget { @@ -161,9 +162,9 @@ class _MenuPageState extends State { initialSeed: DateTime.now().millisecondsSinceEpoch, recipes: RecipesProvider.instance.recipes, ); - // The first day is user configuration, not a result of the generator. - // Regeneration only replaces the recipes, so the date must survive it. - multiWeekMenu = regenerated.copyWith(startDate: multiWeekMenu.startDate); + // The first day and the shopping progress are user input, not a result of the + // generator. Regeneration only replaces the recipes, so both must survive it. + multiWeekMenu = regenerated.copyWith(startDate: multiWeekMenu.startDate, shoppingProgress: multiWeekMenu.shoppingProgress); currentWeekIndex = 0; }); }, @@ -180,7 +181,18 @@ class _MenuPageState extends State { tooltip: "Create Shopping List", icon: const Icon(Icons.shopping_basket_rounded), onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => ShoppingPage(multiWeekMenu: multiWeekMenu))); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ShoppingPage( + multiWeekMenu: multiWeekMenu, + // Each change comes back to this page at once, so every way to leave the + // shopping page keeps the progress, the system back button too. + onShoppingProgressChanged: (ShoppingProgress progress) { + setState(() => multiWeekMenu = multiWeekMenu.copyWith(shoppingProgress: progress)); + }, + ), + ), + ); }, ), ], diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index ebff8ca..60977d7 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -19,13 +19,20 @@ import "package:menu_management/shopping/ingredient_source.dart"; import "package:menu_management/shopping/shopping_copy_text.dart"; import "package:menu_management/shopping/shopping_ingredient.dart"; import "package:menu_management/shopping/shopping_pdf.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; import "package:menu_management/shopping/waste_optimizer.dart"; class ShoppingPage extends StatefulWidget { - const ShoppingPage({super.key, required this.multiWeekMenu}); + const ShoppingPage({super.key, required this.multiWeekMenu, this.onShoppingProgressChanged}); + /// The menu to shop for. The page restores the owned stock and the trip switch from its + /// [MultiWeekMenu.shoppingProgress]. final MultiWeekMenu multiWeekMenu; + /// Receives the progress after each change that the user makes, so the caller can store it on + /// its menu. The page itself never changes [multiWeekMenu]. + final ValueChanged? onShoppingProgressChanged; + @override State createState() => _ShoppingPageState(); } @@ -71,16 +78,55 @@ class _ShoppingPageState extends State { ownedUnits = {}; ownedProductCounts = {}; + ShoppingProgress? savedProgress = widget.multiWeekMenu.shoppingProgress; + Map> savedProductCounts = savedProgress?.ownedProductCountsByIndex(ingredients: allIngredients) ?? {}; + _useFreezerStrategy = savedProgress?.useFreezerStrategy ?? false; + for (MapEntry> entry in ingredientsRequired.entries) { String ingredientId = entry.key; Ingredient? ingredient = allIngredients.firstWhereOrNull((i) => i.id == ingredientId); ownedAmounts[ingredientId] = 0; ownedUnits[ingredientId] = defaultOwnedUnit(ingredient: ingredient, desiredQuantities: entry.value); - ownedProductCounts[ingredientId] = {}; + ownedProductCounts[ingredientId] = {...?savedProductCounts[ingredientId]}; + + OwnedAmountProgress? savedAmount = savedProgress?.ownedAmounts[ingredientId]; + if (savedAmount == null || ingredient == null) continue; + OwnedUnit savedUnit = OwnedUnit(unit: savedAmount.unit); + // The unit dropdown asserts that its value is one of its items. A recipe or product edit can + // remove the saved unit, so the page drops such an amount and does not crash. + if (!availableOwnedUnits(ingredient: ingredient, desiredQuantities: entry.value).contains(savedUnit)) { + Debug.logWarning(true, "Shopping progress dropped: ${ingredient.name} no longer offers the unit ${savedUnit.label}.", asAssertion: false); + continue; + } + ownedAmounts[ingredientId] = savedAmount.amount; + ownedUnits[ingredientId] = savedUnit; } } + /// The progress on screen, in the shape that the menu file stores. + ShoppingProgress _currentProgress() { + return ShoppingProgress.fromPageState( + previous: widget.multiWeekMenu.shoppingProgress, + ownedAmounts: { + for (String ingredientId in ownedAmounts.keys) ingredientId: (amount: ownedAmounts[ingredientId] ?? 0, unit: ownedUnits[ingredientId]?.unit), + }, + ownedProductCountsByIndex: ownedProductCounts, + useFreezerStrategy: _useFreezerStrategy, + ingredients: IngredientsProvider.instance.ingredients, + ); + } + + /// Hands the progress to [ShoppingPage.onShoppingProgressChanged]. Call it after each change. + void _reportProgress() { + widget.onShoppingProgressChanged?.call(_currentProgress()); + } + + /// Saves the menu with the progress on screen, through the same save dialog as the menu page. + Future _saveMenu() { + return Persistency.saveMenu(widget.multiWeekMenu.copyWith(shoppingProgress: _currentProgress()), recipes: RecipesProvider.instance.recipes); + } + /// 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 @@ -118,6 +164,7 @@ class _ShoppingPageState extends State { value: _useFreezerStrategy, onChanged: (bool value) { setState(() => _useFreezerStrategy = value); + _reportProgress(); }, ), ], @@ -134,10 +181,20 @@ class _ShoppingPageState extends State { ), ), ), - floatingActionButton: FloatingActionButton( - tooltip: "Export shopping list", - onPressed: _showExportDialog, - child: const Icon(Icons.ios_share_rounded), + floatingActionButton: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Two buttons with the default hero tag on one page make Flutter throw at each page change. + // A null hero tag turns off the hero animation of the button. + FloatingActionButton( + heroTag: null, + tooltip: "Export shopping list", + onPressed: _showExportDialog, + child: const Icon(Icons.ios_share_rounded), + ), + const SizedBox(height: 10), + FloatingActionButton(heroTag: null, tooltip: "Save Menu", onPressed: _saveMenu, child: const Icon(Icons.save_rounded)), + ], ), body: ListView.builder( itemCount: ingredientsRequired.length, @@ -189,11 +246,13 @@ class _ShoppingPageState extends State { ownedAmounts[ingredientId] = amount; ownedUnits[ingredientId] = unit; }); + _reportProgress(); }, onProductOwnedChanged: (int productIndex, double count) { setState(() { (ownedProductCounts[ingredientId] ??= {})[productIndex] = count; }); + _reportProgress(); }, ); }, diff --git a/menu_management/test/menu_page_test.dart b/menu_management/test/menu_page_test.dart index 9291e18..2a47b19 100644 --- a/menu_management/test/menu_page_test.dart +++ b/menu_management/test/menu_page_test.dart @@ -3,6 +3,7 @@ import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; import "package:menu_management/menu/enums/meal_type.dart"; import "package:menu_management/menu/enums/week_day.dart"; +import "package:menu_management/menu/menu_provider.dart"; import "package:menu_management/menu/models/cooking.dart"; import "package:menu_management/menu/models/meal.dart"; import "package:menu_management/menu/models/meal_time.dart"; @@ -11,9 +12,11 @@ import "package:menu_management/menu/models/multi_week_menu.dart"; import "package:menu_management/menu/models/sub_meal.dart"; import "package:menu_management/menu/widgets/menu_page.dart"; import "package:menu_management/recipes/enums/recipe_type.dart"; +import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/models/instruction.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; Recipe _breakfastRecipe(String id) { return Recipe( @@ -177,6 +180,36 @@ void main() { expect(find.text("6 Aug"), findsOneWidget); expect(find.text("Set first day"), findsNothing); }); + + testWidgets("keeps the shopping progress when the user regenerates the menu", (WidgetTester tester) async { + const ShoppingProgress progress = ShoppingProgress(ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, useFreezerStrategy: true); + await _pumpMenuPage(tester, MultiWeekMenu(shoppingProgress: progress, weeks: [_week()])); + + await tester.tap(find.byIcon(Icons.refresh_rounded)); + await tester.pump(); + + expect(MenuProvider.instance.multiWeekMenu!.shoppingProgress, progress); + }); + }); + + group("MenuPage shopping progress", () { + testWidgets("keeps the progress of the shopping page after the user goes back", (WidgetTester tester) async { + await _pumpMenuPage(tester, MultiWeekMenu(weeks: [_week()])); + + await tester.tap(find.byTooltip("Create Shopping List")); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pump(); + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(MenuProvider.instance.multiWeekMenu!.shoppingProgress, const ShoppingProgress(useFreezerStrategy: true)); + + await tester.tap(find.byTooltip("Create Shopping List")); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Switch)).value, isTrue); + }); }); group("MenuPage start date picker", () { diff --git a/menu_management/test/shopping_page_test.dart b/menu_management/test/shopping_page_test.dart index dbcf496..cdfc7cf 100644 --- a/menu_management/test/shopping_page_test.dart +++ b/menu_management/test/shopping_page_test.dart @@ -1,3 +1,7 @@ +import "dart:convert"; +import "dart:io"; + +import "package:file_picker/file_picker.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; @@ -17,14 +21,17 @@ import "package:menu_management/recipes/models/ingredient_usage.dart"; import "package:menu_management/recipes/models/instruction.dart"; import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/recipes/models/recipe.dart"; +import "package:menu_management/persistency.dart"; import "package:menu_management/recipes/recipes_provider.dart"; +import "package:menu_management/shopping/shopping_ingredient.dart"; import "package:menu_management/shopping/shopping_page.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; import "package:provider/provider.dart"; const Ingredient _rice = Ingredient( id: "rice", name: "Rice", - products: [Product(link: "https://example.com/rice", quantityPerItem: 500, itemsPerPack: 1, unit: Unit.grams)], + products: [Product(link: _riceLink, quantityPerItem: 500, itemsPerPack: 1, unit: Unit.grams)], ); Recipe _riceRecipe() { @@ -123,6 +130,59 @@ MultiWeekMenu _crossWeekLeftoverMenu() { ); } +// The product is sold in grams, but the recipe counts pieces. No product row matches the recipe +// unit, so the page shows the header "Owned" input with a unit dropdown: pieces, grams, packs. +const Ingredient _egg = Ingredient( + id: "egg", + name: "Egg", + products: [Product(link: "https://example.com/eggs", quantityPerItem: 60, itemsPerPack: 6, unit: Unit.grams)], +); + +/// A one-meal menu that cooks two eggs, with the [progress] that the user saved before. +MultiWeekMenu _eggMenu({required ShoppingProgress progress}) { + return MultiWeekMenu( + shoppingProgress: progress, + weeks: const [ + Menu( + meals: [ + Meal( + mealTime: MealTime(weekDay: WeekDay.monday, mealType: MealType.lunch), + subMeals: [SubMeal(cooking: Cooking(recipeId: "r3", yield: 1), people: 1)], + ), + ], + ), + ], + ); +} + +void _seedEggs() { + const Recipe eggRecipe = Recipe( + id: "r3", + name: "Boiled eggs", + instructions: [ + Instruction( + id: "i3", + description: "boil the eggs", + workingTimeMinutes: 5, + cookingTimeMinutes: 10, + ingredientsUsed: [ + IngredientUsage( + ingredient: "egg", + quantity: Quantity(amount: 2, unit: Unit.pieces), + ), + ], + ), + ], + ); + IngredientsProvider.instance.setData([_egg]); + RecipesProvider.instance.setData([eggRecipe], ingredients: [_egg]); +} + +/// The unit that the header "Owned" dropdown of the page shows. +OwnedUnit? _headerOwnedUnit(WidgetTester tester) { + return tester.widget>(find.byType(DropdownButtonFormField)).initialValue; +} + MultiWeekMenu _menu({DateTime? startDate}) { return MultiWeekMenu( startDate: startDate, @@ -139,7 +199,7 @@ MultiWeekMenu _menu({DateTime? startDate}) { ); } -Future _pumpShoppingPage(WidgetTester tester, MultiWeekMenu menu) async { +Future _pumpShoppingPage(WidgetTester tester, MultiWeekMenu menu, {ValueChanged? onShoppingProgressChanged}) async { tester.view.physicalSize = const Size(1800, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -149,12 +209,41 @@ Future _pumpShoppingPage(WidgetTester tester, MultiWeekMenu menu) async { ChangeNotifierProvider.value(value: IngredientsProvider.instance), ChangeNotifierProvider.value(value: RecipesProvider.instance), ], - child: MaterialApp(home: ShoppingPage(multiWeekMenu: menu)), + child: MaterialApp( + // The flutter_test placeholder font is wider than the real font, so it overflows the + // fixed-width unit dropdown of the header owned input. Shrink the text scale to make room. + builder: (BuildContext context, Widget? child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(0.7)), + child: child!, + ), + home: ShoppingPage(multiWeekMenu: menu, onShoppingProgressChanged: onShoppingProgressChanged), + ), ), ); await tester.pump(); } +/// A save dialog that returns [pickedPath], so a test says where the page saves the menu. +class _FakeFilePicker extends FilePicker { + _FakeFilePicker(this.pickedPath); + + final String pickedPath; + + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Uint8List? bytes, + bool lockParentWindow = false, + }) async => pickedPath; +} + +/// The link of the only rice product, which keys the saved owned count of that product. +const String _riceLink = "https://example.com/rice"; + /// The texts that the page copied to the clipboard, in the order of the copies. late List _copiedTexts; @@ -295,4 +384,102 @@ void main() { expect(find.text("Copied the detailed shopping list to the clipboard."), findsOneWidget); }); }); + + group("ShoppingPage progress", () { + testWidgets("restores the owned count and the trip switch of the menu", (WidgetTester tester) async { + MultiWeekMenu menu = _menu().copyWith( + shoppingProgress: const ShoppingProgress( + ownedProductCounts: { + "rice": {_riceLink: 2}, + }, + useFreezerStrategy: true, + ), + ); + + await _pumpShoppingPage(tester, menu); + + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect(find.widgetWithText(TextField, "2"), findsOneWidget); + }); + + testWidgets("restores the header owned amount and its unit", (WidgetTester tester) async { + _seedEggs(); + + await _pumpShoppingPage(tester, _eggMenu(progress: const ShoppingProgress(ownedAmounts: {"egg": (amount: 120, unit: Unit.grams)}))); + + expect(find.widgetWithText(TextField, "120"), findsOneWidget); + expect(_headerOwnedUnit(tester), const OwnedUnit(unit: Unit.grams)); + }); + + testWidgets("drops a saved owned amount in a unit that the dropdown does not offer", (WidgetTester tester) async { + _seedEggs(); + + await _pumpShoppingPage(tester, _eggMenu(progress: const ShoppingProgress(ownedAmounts: {"egg": (amount: 3, unit: Unit.teaspoons)}))); + + expect(tester.takeException(), isNull); + expect(find.widgetWithText(TextField, "3"), findsNothing); + expect(_headerOwnedUnit(tester), const OwnedUnit(unit: Unit.pieces)); + }); + + testWidgets("reports the owned count keyed by the product link", (WidgetTester tester) async { + List reported = []; + await _pumpShoppingPage(tester, _menu(), onShoppingProgressChanged: reported.add); + + await tester.enterText(find.widgetWithText(TextField, "Owned"), "2"); + await tester.pump(); + + expect( + reported.last, + const ShoppingProgress( + ownedProductCounts: { + "rice": {_riceLink: 2}, + }, + ), + ); + }); + + testWidgets("reports the trip switch", (WidgetTester tester) async { + List reported = []; + await _pumpShoppingPage(tester, _menu(), onShoppingProgressChanged: reported.add); + + await tester.tap(find.byType(Switch)); + await tester.pump(); + + expect(reported.last, const ShoppingProgress(useFreezerStrategy: true)); + }); + + testWidgets("saves the menu with the progress on screen", (WidgetTester tester) async { + Directory tempDir = Directory.systemTemp.createTempSync("shopping_page_save_test_"); + addTearDown(() => tempDir.deleteSync(recursive: true)); + Persistency.sessionDirOverride = tempDir.path; + addTearDown(() => Persistency.sessionDirOverride = null); + File savedFile = File("${tempDir.path}/menu.tsm"); + FilePicker.platform = _FakeFilePicker(savedFile.path); + await _pumpShoppingPage(tester, _menu()); + + await tester.enterText(find.widgetWithText(TextField, "Owned"), "2"); + await tester.pump(); + // The save writes a real file, so the test taps and waits on real time and not on the fake clock. + Map? savedJson; + await tester.runAsync(() async { + await tester.tap(find.byTooltip("Save Menu")); + for (int attempt = 0; attempt < 100 && savedJson == null; attempt++) { + await Future.delayed(const Duration(milliseconds: 20)); + if (savedFile.existsSync()) { + try { + savedJson = jsonDecode(savedFile.readAsStringSync()); + } on FormatException { + savedJson = null; + } + } + } + }); + + expect(savedJson!["shoppingProgress"], { + "ownedProductCounts": { + "rice": {_riceLink: 2.0}, + }, + }); + }); + }); } From f9d517181f27dda5d4f1dade648d454b37478d97 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:20:03 +0200 Subject: [PATCH 05/11] fix: tell the user when a menu save fails The menu page Save button dropped the Future of Persistency.saveMenu, and saveMenu catches no error. A failed save dialog or file write became an unhandled async error, and the user saw nothing. The new SaveMenuButton awaits the save, logs the error with Debug.logError, and shows a snackbar. The menu page uses it now, and the shopping page uses it next, so both Save buttons behave alike. Co-Authored-By: Claude Opus 5.5 --- .../lib/menu/widgets/menu_page.dart | 10 +---- .../lib/menu/widgets/save_menu_button.dart | 38 +++++++++++++++++++ .../test/save_menu_button_test.dart | 38 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 menu_management/lib/menu/widgets/save_menu_button.dart create mode 100644 menu_management/test/save_menu_button_test.dart diff --git a/menu_management/lib/menu/widgets/menu_page.dart b/menu_management/lib/menu/widgets/menu_page.dart index e7ff0bf..e730138 100644 --- a/menu_management/lib/menu/widgets/menu_page.dart +++ b/menu_management/lib/menu/widgets/menu_page.dart @@ -11,6 +11,7 @@ import "package:menu_management/menu/models/meal.dart"; import "package:menu_management/menu/models/menu.dart"; import "package:menu_management/menu/models/multi_week_menu.dart"; import "package:menu_management/menu/models/sub_meal.dart"; +import "package:menu_management/menu/widgets/save_menu_button.dart"; import "package:menu_management/persistency.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; @@ -204,14 +205,7 @@ class _MenuPageState extends State { // A null hero tag turns off the hero animation of the button. FloatingActionButton(heroTag: null, tooltip: "Export menu", onPressed: _showExportDialog, child: const Icon(Icons.ios_share_rounded)), const SizedBox(height: 10), - FloatingActionButton( - heroTag: null, - tooltip: "Save Menu", - child: const Icon(Icons.save_rounded), - onPressed: () { - Persistency.saveMenu(multiWeekMenu, recipes: _recipes); - }, - ), + SaveMenuButton(buildMenu: () => multiWeekMenu), ], ), body: LayoutBuilder( diff --git a/menu_management/lib/menu/widgets/save_menu_button.dart b/menu_management/lib/menu/widgets/save_menu_button.dart new file mode 100644 index 0000000..370ba56 --- /dev/null +++ b/menu_management/lib/menu/widgets/save_menu_button.dart @@ -0,0 +1,38 @@ +import "package:flutter/material.dart"; +import "package:menu_management/flutter_essentials/library.dart"; +import "package:menu_management/menu/models/multi_week_menu.dart"; +import "package:menu_management/persistency.dart"; +import "package:menu_management/recipes/recipes_provider.dart"; + +/// The "Save Menu" button of the menu page and of the shopping page. +/// +/// It asks [buildMenu] for the menu at the moment of the tap, so the caller can add state that +/// only it holds, for example the shopping progress on screen. A failed save shows a snackbar, +/// because `Persistency.saveMenu` lets the error of the save dialog or of the write through. +class SaveMenuButton extends StatelessWidget { + const SaveMenuButton({super.key, required this.buildMenu}); + + final MultiWeekMenu Function() buildMenu; + + Future _save(BuildContext context) async { + try { + await Persistency.saveMenu(buildMenu(), recipes: RecipesProvider.instance.recipes); + } catch (error, stack) { + Debug.logError("Could not save the menu: $error", asException: false, stack: stack); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Could not save the menu. $error"))); + } + } + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + // Two buttons with the default hero tag on one page make Flutter throw at each page change. + // A null hero tag turns off the hero animation of the button. + heroTag: null, + tooltip: "Save Menu", + onPressed: () => _save(context), + child: const Icon(Icons.save_rounded), + ); + } +} diff --git a/menu_management/test/save_menu_button_test.dart b/menu_management/test/save_menu_button_test.dart new file mode 100644 index 0000000..5777c9d --- /dev/null +++ b/menu_management/test/save_menu_button_test.dart @@ -0,0 +1,38 @@ +import "package:file_picker/file_picker.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:menu_management/menu/models/multi_week_menu.dart"; +import "package:menu_management/menu/widgets/save_menu_button.dart"; + +/// A save dialog that fails, the way a broken file picker plugin does. +class _FailingFilePicker extends FilePicker { + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Uint8List? bytes, + bool lockParentWindow = false, + }) async => throw Exception("no save dialog"); +} + +void main() { + group("SaveMenuButton", () { + testWidgets("tells the user when the save fails", (WidgetTester tester) async { + FilePicker.platform = _FailingFilePicker(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(floatingActionButton: SaveMenuButton(buildMenu: () => const MultiWeekMenu())), + ), + ); + + await tester.tap(find.byTooltip("Save Menu")); + await tester.pump(); + + expect(find.text("Could not save the menu. Exception: no save dialog"), findsOneWidget); + }); + }); +} From 947b73c8264420e10b4953cc9178087b23dd0269 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:20:03 +0200 Subject: [PATCH 06/11] fix: keep the shopping progress when the configuration page generates Only the regenerate button of MenuPage kept the shopping progress. "Generate Menu" on the configuration page started with none, although the stock at home does not depend on the menu. It now copies the progress of the active menu from the MenuProvider mirror. Co-Authored-By: Claude Opus 5.5 --- .../menu/widgets/menu_configuration_page.dart | 5 +- .../test/menu_configuration_page_test.dart | 54 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/menu_management/lib/menu/widgets/menu_configuration_page.dart b/menu_management/lib/menu/widgets/menu_configuration_page.dart index f2eb85c..e1e93bb 100644 --- a/menu_management/lib/menu/widgets/menu_configuration_page.dart +++ b/menu_management/lib/menu/widgets/menu_configuration_page.dart @@ -48,10 +48,13 @@ class MenuConfigurationPage extends StatelessWidget { tooltip: "Generate Menu", child: const Icon(Icons.auto_awesome_sharp), onPressed: () { - MultiWeekMenu multiWeekMenu = MenuProvider.generateMenu( + MultiWeekMenu generated = MenuProvider.generateMenu( initialSeed: DateTime.now().millisecondsSinceEpoch, recipes: RecipesProvider.instance.recipes, ); + // The stock at home does not depend on the menu, so the new menu keeps the shopping + // progress of the active menu. + MultiWeekMenu multiWeekMenu = generated.copyWith(shoppingProgress: MenuProvider.instance.multiWeekMenu?.shoppingProgress); Navigator.of(context).push(MaterialPageRoute(builder: (context) => MenuPage(multiWeekMenu: multiWeekMenu))); }, ), diff --git a/menu_management/test/menu_configuration_page_test.dart b/menu_management/test/menu_configuration_page_test.dart index 67609a4..fc7020b 100644 --- a/menu_management/test/menu_configuration_page_test.dart +++ b/menu_management/test/menu_configuration_page_test.dart @@ -10,7 +10,12 @@ import "package:menu_management/menu/models/menu.dart"; import "package:menu_management/menu/models/multi_week_menu.dart"; import "package:menu_management/menu/models/sub_meal.dart"; import "package:menu_management/menu/widgets/menu_configuration_page.dart"; +import "package:menu_management/recipes/enums/recipe_type.dart"; +import "package:menu_management/recipes/enums/unit.dart"; +import "package:menu_management/recipes/models/instruction.dart"; +import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; +import "package:menu_management/shopping/shopping_progress.dart"; import "package:provider/provider.dart"; Menu _week() { @@ -24,6 +29,31 @@ Menu _week() { ); } +/// Seeds enough recipes for the generator to fill every slot, so it does not warn. +void _seedRecipes() { + RecipesProvider.instance.setData([ + for (int i = 0; i < 10; i++) + Recipe( + id: "b$i", + name: "Breakfast $i", + type: RecipeType.breakfast, + lunch: false, + dinner: false, + instructions: [Instruction(id: "b${i}_i", description: "make it", workingTimeMinutes: 10, cookingTimeMinutes: 0)], + ), + for (int i = 0; i < 20; i++) + Recipe( + id: "m$i", + name: "Meal $i", + type: RecipeType.meal, + lunch: true, + dinner: true, + carbs: true, + instructions: [Instruction(id: "m${i}_i", description: "cook it", workingTimeMinutes: 20, cookingTimeMinutes: 0)], + ), + ], ingredients: []); +} + /// Renders the page on a desktop-sized surface, because the configuration grid needs seven columns. Future _pumpConfigurationPage(WidgetTester tester) async { tester.view.physicalSize = const Size(1800, 1200); @@ -35,7 +65,15 @@ Future _pumpConfigurationPage(WidgetTester tester) async { ChangeNotifierProvider.value(value: MenuProvider.instance), ChangeNotifierProvider.value(value: RecipesProvider.instance), ], - child: const MaterialApp(home: MenuConfigurationPage()), + child: MaterialApp( + // The flutter_test placeholder font is wider than the real font, so the menu page that the + // generate button opens overflows its meal cards. Shrink the text scale to make room. + builder: (BuildContext context, Widget? child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(0.7)), + child: child!, + ), + home: const MenuConfigurationPage(), + ), ), ); await tester.pump(); @@ -65,6 +103,20 @@ void main() { }); }); + group("MenuConfigurationPage generate button", () { + testWidgets("keeps the shopping progress of the active menu", (WidgetTester tester) async { + _seedRecipes(); + const ShoppingProgress progress = ShoppingProgress(ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, useFreezerStrategy: true); + MenuProvider.setMultiWeekMenu(MultiWeekMenu(shoppingProgress: progress, weeks: [_week()])); + await _pumpConfigurationPage(tester); + + await tester.tap(find.byTooltip("Generate Menu")); + await tester.pumpAndSettle(); + + expect(MenuProvider.instance.multiWeekMenu!.shoppingProgress, progress); + }); + }); + group("MenuConfigurationPage day labels", () { testWidgets("starts at Saturday when no menu is active", (WidgetTester tester) async { await _pumpConfigurationPage(tester); From b79846c5c215733fb2df32144b8f70fdee6b9f6a Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:20:03 +0200 Subject: [PATCH 07/11] fix: key saved product counts by link and unit 27 bundled ingredients have a grams product and a pieces product with the same store link. With link-only keys, the two counts collided on save, and on load a pieces count landed on the grams product, where OwnedStock still subtracted it. Each count is now saved as one object with its link, unit and count, and the load matches both. When two products share link and unit, the first of them gets the count. The shopping page also drops, with a warning, a header amount that product rows now replace and the amount of an ingredient that no longer exists. Widget tests cover a shared link, a "packs" amount, and the owned count on the back navigation. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_page.dart | 23 ++- .../lib/shopping/shopping_progress.dart | 95 +++++++---- .../shopping/shopping_progress.freezed.dart | 22 +-- menu_management/test/menu_page_test.dart | 70 +++++++- .../test/multi_week_menu_test.dart | 6 +- menu_management/test/persistency_test.dart | 8 +- menu_management/test/shopping_page_test.dart | 120 +++++++++++++- .../test/shopping_progress_test.dart | 149 +++++++++++++++--- 8 files changed, 417 insertions(+), 76 deletions(-) diff --git a/menu_management/lib/shopping/shopping_page.dart b/menu_management/lib/shopping/shopping_page.dart index 60977d7..bb9bc5a 100644 --- a/menu_management/lib/shopping/shopping_page.dart +++ b/menu_management/lib/shopping/shopping_page.dart @@ -7,6 +7,7 @@ import "package:menu_management/ingredients/models/ingredient.dart"; import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/menu/menu_dates.dart"; import "package:menu_management/menu/models/multi_week_menu.dart"; +import "package:menu_management/menu/widgets/save_menu_button.dart"; import "package:menu_management/persistency.dart"; import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/recipes_provider.dart"; @@ -31,7 +32,7 @@ class ShoppingPage extends StatefulWidget { /// Receives the progress after each change that the user makes, so the caller can store it on /// its menu. The page itself never changes [multiWeekMenu]. - final ValueChanged? onShoppingProgressChanged; + final void Function(ShoppingProgress progress)? onShoppingProgressChanged; @override State createState() => _ShoppingPageState(); @@ -91,7 +92,17 @@ class _ShoppingPageState extends State { ownedProductCounts[ingredientId] = {...?savedProductCounts[ingredientId]}; OwnedAmountProgress? savedAmount = savedProgress?.ownedAmounts[ingredientId]; - if (savedAmount == null || ingredient == null) continue; + if (savedAmount == null) continue; + if (ingredient == null) { + Debug.logWarning(true, 'Shopping progress dropped: no ingredient has the id "$ingredientId".', asAssertion: false); + continue; + } + // Product rows replace the header input when a product unit matches a recipe unit. The user + // could not see or clear a restored header amount, so the page drops it. + if (usesPerProductOwnedInputs(ingredient: ingredient, desiredQuantities: entry.value)) { + Debug.logWarning(true, "Shopping progress dropped: ${ingredient.name} now shows one owned input per product.", asAssertion: false); + continue; + } OwnedUnit savedUnit = OwnedUnit(unit: savedAmount.unit); // The unit dropdown asserts that its value is one of its items. A recipe or product edit can // remove the saved unit, so the page drops such an amount and does not crash. @@ -122,11 +133,6 @@ class _ShoppingPageState extends State { widget.onShoppingProgressChanged?.call(_currentProgress()); } - /// Saves the menu with the progress on screen, through the same save dialog as the menu page. - Future _saveMenu() { - return Persistency.saveMenu(widget.multiWeekMenu.copyWith(shoppingProgress: _currentProgress()), recipes: RecipesProvider.instance.recipes); - } - /// 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 @@ -193,7 +199,8 @@ class _ShoppingPageState extends State { child: const Icon(Icons.ios_share_rounded), ), const SizedBox(height: 10), - FloatingActionButton(heroTag: null, tooltip: "Save Menu", onPressed: _saveMenu, child: const Icon(Icons.save_rounded)), + // The save keeps the progress on screen, through the same button as the menu page. + SaveMenuButton(buildMenu: () => widget.multiWeekMenu.copyWith(shoppingProgress: _currentProgress())), ], ), body: ListView.builder( diff --git a/menu_management/lib/shopping/shopping_progress.dart b/menu_management/lib/shopping/shopping_progress.dart index c6a12cf..14f76ec 100644 --- a/menu_management/lib/shopping/shopping_progress.dart +++ b/menu_management/lib/shopping/shopping_progress.dart @@ -1,6 +1,7 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:menu_management/flutter_essentials/library.dart"; import "package:menu_management/ingredients/models/ingredient.dart"; +import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/recipes/enums/unit.dart"; part "shopping_progress.freezed.dart"; @@ -9,6 +10,12 @@ part "shopping_progress.freezed.dart"; /// A null [unit] means "packs", the same rule as `OwnedStock.unit`. typedef OwnedAmountProgress = ({double amount, Unit? unit}); +/// Names one product of an ingredient in a saved count: its store link plus its unit. +/// +/// The link alone is not unique. The grams product and the pieces product of one store item share +/// one link, so the unit tells them apart. +typedef ProductCountKey = ({String link, Unit unit}); + /// The JSON text of the "packs" unit, which has no [Unit] value. const String _packsUnitName = "packs"; @@ -17,8 +24,10 @@ const String _packsUnitName = "packs"; /// The menu file (`.tsm`) stores this object on `MultiWeekMenu.shoppingProgress`. /// - [ownedAmounts]: the header owned amount and unit per ingredient id. /// - [ownedProductCounts]: the owned count per product, keyed by ingredient id and then by the -/// product link. The shopping page keys the counts by product index, and the index changes when -/// the user adds, removes or reorders products. The link stays the same. +/// [ProductCountKey] of the product. The shopping page keys the counts by product index, and the +/// index changes when the user adds, removes or reorders products. The link and the unit stay the +/// same. Two products with the same link and the same unit share one key: the first of them in +/// `Ingredient.products` gets the count. /// - [useFreezerStrategy]: the "Try to make one trip" switch. /// /// Only amounts and counts above zero are stored. @@ -26,7 +35,7 @@ const String _packsUnitName = "packs"; abstract class ShoppingProgress with _$ShoppingProgress { const factory ShoppingProgress({ @Default({}) Map ownedAmounts, - @Default({}) Map> ownedProductCounts, + @Default({}) Map> ownedProductCounts, @Default(false) bool useFreezerStrategy, }) = _ShoppingProgress; @@ -35,8 +44,9 @@ abstract class ShoppingProgress with _$ShoppingProgress { /// Builds the progress from the state of the shopping page. /// /// [ownedAmounts] and [ownedProductCountsByIndex] hold the ingredients that the page shows. - /// The function turns each product index into the link of that product in [ingredients], and - /// drops an index that matches no product. + /// The function turns each product index into the [ProductCountKey] of that product in + /// [ingredients], and drops an index that matches no product. When two products share a key, the + /// count of the lower index wins and the other count is dropped with a warning. /// /// The page shows only the ingredients of the current menu. The function copies the entries of /// [previous] for every other ingredient, so the stock at home survives a new menu. @@ -56,18 +66,31 @@ abstract class ShoppingProgress with _$ShoppingProgress { if (entry.value.amount > 0) entry.key: entry.value, }; - Map> mergedCounts = { - for (MapEntry> entry in (previous?.ownedProductCounts ?? const {}).entries) + Map> mergedCounts = { + for (MapEntry> entry in (previous?.ownedProductCounts ?? const {}).entries) if (!pageIngredientIds.contains(entry.key)) entry.key: entry.value, }; for (MapEntry> entry in ownedProductCountsByIndex.entries) { Ingredient? ingredient = ingredients.firstWhereOrNull((Ingredient i) => i.id == entry.key); if (ingredient == null) continue; - Map countsByLink = { - for (MapEntry count in entry.value.entries) - if (count.value > 0 && count.key >= 0 && count.key < ingredient.products.length) ingredient.products[count.key].link: count.value, - }; - if (countsByLink.isNotEmpty) mergedCounts[entry.key] = countsByLink; + Map countsByKey = {}; + List productIndexes = entry.value.keys.toList()..sort(); + for (int productIndex in productIndexes) { + double count = entry.value[productIndex]!; + if (count <= 0 || productIndex < 0 || productIndex >= ingredient.products.length) continue; + Product product = ingredient.products[productIndex]; + ProductCountKey key = (link: product.link, unit: product.unit); + if (countsByKey.containsKey(key)) { + Debug.logWarning( + true, + "Shopping progress dropped: ${ingredient.name} has two products with the link ${product.link} in ${product.unit.name}.", + asAssertion: false, + ); + continue; + } + countsByKey[key] = count; + } + if (countsByKey.isNotEmpty) mergedCounts[entry.key] = countsByKey; } return ShoppingProgress(ownedAmounts: mergedAmounts, ownedProductCounts: mergedCounts, useFreezerStrategy: useFreezerStrategy); @@ -79,21 +102,26 @@ abstract class ShoppingProgress with _$ShoppingProgress { /// Returns the owned product counts keyed by the index of each product in [ingredients], which /// is the shape that the shopping page uses. /// - /// A count whose link matches no product of its ingredient is dropped with a warning. The counts - /// of an ingredient that is not in [ingredients] are dropped with a warning too. + /// A count goes to the first product whose link and unit match its [ProductCountKey]. A count that + /// matches no product is dropped with a warning. The counts of an ingredient that is not in + /// [ingredients] are dropped with a warning too. Map> ownedProductCountsByIndex({required List ingredients}) { Map> countsByIndex = {}; - for (MapEntry> entry in ownedProductCounts.entries) { + for (MapEntry> entry in ownedProductCounts.entries) { Ingredient? ingredient = ingredients.firstWhereOrNull((Ingredient i) => i.id == entry.key); if (ingredient == null) { Debug.logWarning(true, 'Shopping progress dropped: no ingredient has the id "${entry.key}".', asAssertion: false); continue; } Map counts = {}; - for (MapEntry count in entry.value.entries) { - int productIndex = ingredient.products.indexWhere((product) => product.link == count.key); + for (MapEntry count in entry.value.entries) { + int productIndex = ingredient.products.indexWhere((Product product) => product.link == count.key.link && product.unit == count.key.unit); if (productIndex < 0) { - Debug.logWarning(true, 'Shopping progress dropped: ${ingredient.name} has no product with the link "${count.key}".', asAssertion: false); + Debug.logWarning( + true, + 'Shopping progress dropped: ${ingredient.name} has no product with the link "${count.key.link}" in ${count.key.unit.name}.', + asAssertion: false, + ); continue; } counts[productIndex] = count.value; @@ -104,6 +132,9 @@ abstract class ShoppingProgress with _$ShoppingProgress { } /// Writes the progress as JSON. Each empty part is left out. + /// + /// The code writes the JSON by hand: json_serializable cannot write a record type, and each + /// product count becomes one object with its link, its unit and its count. Map toJson() { return { if (useFreezerStrategy) "useFreezerStrategy": true, @@ -112,7 +143,14 @@ abstract class ShoppingProgress with _$ShoppingProgress { for (MapEntry entry in ownedAmounts.entries) entry.key: {"amount": entry.value.amount, "unit": entry.value.unit?.name ?? _packsUnitName}, }, - if (ownedProductCounts.isNotEmpty) "ownedProductCounts": ownedProductCounts, + if (ownedProductCounts.isNotEmpty) + "ownedProductCounts": { + for (MapEntry> entry in ownedProductCounts.entries) + entry.key: [ + for (MapEntry count in entry.value.entries) + {"link": count.key.link, "unit": count.key.unit.name, "count": count.value}, + ], + }, }; } @@ -170,26 +208,29 @@ abstract class ShoppingProgress with _$ShoppingProgress { return amounts; } - static Map> _parseOwnedProductCounts(Object? json) { + static Map> _parseOwnedProductCounts(Object? json) { if (json == null) return {}; if (json is! Map) { Debug.logWarning(true, 'Shopping progress: the product counts "$json" are not an object.', asAssertion: false); return {}; } - Map> countsByIngredient = {}; + Map> countsByIngredient = {}; for (MapEntry entry in json.entries) { Object? rawCounts = entry.value; - if (entry.key is! String || rawCounts is! Map) { + if (entry.key is! String || rawCounts is! List) { Debug.logWarning(true, 'Shopping progress: dropped the product counts "$rawCounts" of "${entry.key}".', asAssertion: false); continue; } - Map counts = {}; - for (MapEntry count in rawCounts.entries) { - if (count.key is! String || !_isPositiveNumber(count.value)) { - Debug.logWarning(true, 'Shopping progress: dropped the count "${count.value}" of "${count.key}".', asAssertion: false); + Map counts = {}; + for (Object? rawCount in rawCounts) { + Object? rawLink = rawCount is Map ? rawCount["link"] : null; + Unit? unit = rawCount is Map ? Unit.values.firstWhereOrNull((Unit u) => u.name == rawCount["unit"]) : null; + Object? rawValue = rawCount is Map ? rawCount["count"] : null; + if (rawLink is! String || unit == null || !_isPositiveNumber(rawValue)) { + Debug.logWarning(true, 'Shopping progress: dropped the product count "$rawCount" of "${entry.key}".', asAssertion: false); continue; } - counts[count.key as String] = (count.value as num).toDouble(); + counts[(link: rawLink, unit: unit)] = (rawValue as num).toDouble(); } if (counts.isNotEmpty) countsByIngredient[entry.key as String] = counts; } diff --git a/menu_management/lib/shopping/shopping_progress.freezed.dart b/menu_management/lib/shopping/shopping_progress.freezed.dart index 30fab34..9a64bd6 100644 --- a/menu_management/lib/shopping/shopping_progress.freezed.dart +++ b/menu_management/lib/shopping/shopping_progress.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$ShoppingProgress { - Map get ownedAmounts; Map> get ownedProductCounts; bool get useFreezerStrategy; + Map get ownedAmounts; Map> get ownedProductCounts; bool get useFreezerStrategy; /// Create a copy of ShoppingProgress /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -45,7 +45,7 @@ abstract mixin class $ShoppingProgressCopyWith<$Res> { factory $ShoppingProgressCopyWith(ShoppingProgress value, $Res Function(ShoppingProgress) _then) = _$ShoppingProgressCopyWithImpl; @useResult $Res call({ - Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy + Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy }); @@ -66,7 +66,7 @@ class _$ShoppingProgressCopyWithImpl<$Res> return _then(_self.copyWith( ownedAmounts: null == ownedAmounts ? _self.ownedAmounts : ownedAmounts // ignore: cast_nullable_to_non_nullable as Map,ownedProductCounts: null == ownedProductCounts ? _self.ownedProductCounts : ownedProductCounts // ignore: cast_nullable_to_non_nullable -as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable +as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -152,7 +152,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _ShoppingProgress() when $default != null: return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: @@ -173,7 +173,7 @@ return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStra /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy) $default,) {final _that = this; switch (_that) { case _ShoppingProgress(): return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: @@ -193,7 +193,7 @@ return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStra /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy)? $default,) {final _that = this; switch (_that) { case _ShoppingProgress() when $default != null: return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStrategy);case _: @@ -208,7 +208,7 @@ return $default(_that.ownedAmounts,_that.ownedProductCounts,_that.useFreezerStra class _ShoppingProgress extends ShoppingProgress { - const _ShoppingProgress({final Map ownedAmounts = const {}, final Map> ownedProductCounts = const {}, this.useFreezerStrategy = false}): _ownedAmounts = ownedAmounts,_ownedProductCounts = ownedProductCounts,super._(); + const _ShoppingProgress({final Map ownedAmounts = const {}, final Map> ownedProductCounts = const {}, this.useFreezerStrategy = false}): _ownedAmounts = ownedAmounts,_ownedProductCounts = ownedProductCounts,super._(); final Map _ownedAmounts; @@ -218,8 +218,8 @@ class _ShoppingProgress extends ShoppingProgress { return EqualUnmodifiableMapView(_ownedAmounts); } - final Map> _ownedProductCounts; -@override@JsonKey() Map> get ownedProductCounts { + final Map> _ownedProductCounts; +@override@JsonKey() Map> get ownedProductCounts { if (_ownedProductCounts is EqualUnmodifiableMapView) return _ownedProductCounts; // ignore: implicit_dynamic_type return EqualUnmodifiableMapView(_ownedProductCounts); @@ -257,7 +257,7 @@ abstract mixin class _$ShoppingProgressCopyWith<$Res> implements $ShoppingProgre factory _$ShoppingProgressCopyWith(_ShoppingProgress value, $Res Function(_ShoppingProgress) _then) = __$ShoppingProgressCopyWithImpl; @override @useResult $Res call({ - Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy + Map ownedAmounts, Map> ownedProductCounts, bool useFreezerStrategy }); @@ -278,7 +278,7 @@ class __$ShoppingProgressCopyWithImpl<$Res> return _then(_ShoppingProgress( ownedAmounts: null == ownedAmounts ? _self._ownedAmounts : ownedAmounts // ignore: cast_nullable_to_non_nullable as Map,ownedProductCounts: null == ownedProductCounts ? _self._ownedProductCounts : ownedProductCounts // ignore: cast_nullable_to_non_nullable -as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable +as Map>,useFreezerStrategy: null == useFreezerStrategy ? _self.useFreezerStrategy : useFreezerStrategy // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/menu_management/test/menu_page_test.dart b/menu_management/test/menu_page_test.dart index 2a47b19..965714e 100644 --- a/menu_management/test/menu_page_test.dart +++ b/menu_management/test/menu_page_test.dart @@ -1,6 +1,9 @@ import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; +import "package:menu_management/ingredients/ingredients_provider.dart"; +import "package:menu_management/ingredients/models/ingredient.dart"; +import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/menu/enums/meal_type.dart"; import "package:menu_management/menu/enums/week_day.dart"; import "package:menu_management/menu/menu_provider.dart"; @@ -13,10 +16,13 @@ import "package:menu_management/menu/models/sub_meal.dart"; import "package:menu_management/menu/widgets/menu_page.dart"; import "package:menu_management/recipes/enums/recipe_type.dart"; import "package:menu_management/recipes/enums/unit.dart"; +import "package:menu_management/recipes/models/ingredient_usage.dart"; import "package:menu_management/recipes/models/instruction.dart"; +import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/recipes/models/recipe.dart"; import "package:menu_management/recipes/recipes_provider.dart"; import "package:menu_management/shopping/shopping_progress.dart"; +import "package:provider/provider.dart"; Recipe _breakfastRecipe(String id) { return Recipe( @@ -65,7 +71,16 @@ Future _pumpMenuPage(WidgetTester tester, MultiWeekMenu menu) async { tester.view.physicalSize = const Size(1800, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - await tester.pumpWidget(MaterialApp(home: MenuPage(multiWeekMenu: menu))); + // The shopping page reads the ingredients provider from the tree. + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: IngredientsProvider.instance), + ChangeNotifierProvider.value(value: RecipesProvider.instance), + ], + child: MaterialApp(home: MenuPage(multiWeekMenu: menu)), + ), + ); await tester.pump(); } @@ -82,6 +97,18 @@ Menu _cookingWeek() { ); } +/// A week that cooks the recipe "rice_bowl" for two people on the Saturday lunch. +Menu _riceWeek() { + return const Menu( + meals: [ + Meal( + mealTime: MealTime(weekDay: WeekDay.saturday, mealType: MealType.lunch), + subMeals: [SubMeal(cooking: Cooking(recipeId: "rice_bowl", yield: 1), people: 2)], + ), + ], + ); +} + /// The days that hold no meal. Every format writes all seven days of the week. List _emptyDays(List dayNames) { return [ @@ -105,6 +132,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { + IngredientsProvider.instance.setData([]); _seedRecipes(); _copiedTexts = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall call) async { @@ -194,21 +222,57 @@ void main() { group("MenuPage shopping progress", () { testWidgets("keeps the progress of the shopping page after the user goes back", (WidgetTester tester) async { - await _pumpMenuPage(tester, MultiWeekMenu(weeks: [_week()])); + const Ingredient rice = Ingredient( + id: "rice", + name: "Rice", + products: [Product(link: "https://example.com/rice", quantityPerItem: 500, itemsPerPack: 1, unit: Unit.grams)], + ); + const Recipe riceRecipe = Recipe( + id: "rice_bowl", + name: "Rice bowl", + instructions: [ + Instruction( + id: "rice_bowl_i", + description: "cook the rice", + workingTimeMinutes: 10, + cookingTimeMinutes: 0, + ingredientsUsed: [ + IngredientUsage( + ingredient: "rice", + quantity: Quantity(amount: 200, unit: Unit.grams), + ), + ], + ), + ], + ); + IngredientsProvider.instance.setData([rice]); + RecipesProvider.instance.setData([riceRecipe], ingredients: [rice]); + await _pumpMenuPage(tester, MultiWeekMenu(weeks: [_riceWeek()])); await tester.tap(find.byTooltip("Create Shopping List")); await tester.pumpAndSettle(); await tester.tap(find.byType(Switch)); await tester.pump(); + await tester.enterText(find.widgetWithText(TextField, "Owned"), "2"); + await tester.pump(); await tester.pageBack(); await tester.pumpAndSettle(); - expect(MenuProvider.instance.multiWeekMenu!.shoppingProgress, const ShoppingProgress(useFreezerStrategy: true)); + expect( + MenuProvider.instance.multiWeekMenu!.shoppingProgress, + const ShoppingProgress( + ownedProductCounts: { + "rice": {(link: "https://example.com/rice", unit: Unit.grams): 2}, + }, + useFreezerStrategy: true, + ), + ); await tester.tap(find.byTooltip("Create Shopping List")); await tester.pumpAndSettle(); expect(tester.widget(find.byType(Switch)).value, isTrue); + expect(find.widgetWithText(TextField, "2"), findsOneWidget); }); }); diff --git a/menu_management/test/multi_week_menu_test.dart b/menu_management/test/multi_week_menu_test.dart index 26c6ece..598af9a 100644 --- a/menu_management/test/multi_week_menu_test.dart +++ b/menu_management/test/multi_week_menu_test.dart @@ -1399,7 +1399,7 @@ void main() { const ShoppingProgress progress = ShoppingProgress( ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, ownedProductCounts: { - "rice": {"https://example.com/rice": 2}, + "rice": {(link: "https://example.com/rice", unit: Unit.grams): 2}, }, useFreezerStrategy: true, ); @@ -1429,7 +1429,9 @@ void main() { "salt": {"amount": 20.0, "unit": "grams"}, }, "ownedProductCounts": { - "rice": {"https://example.com/rice": 2.0}, + "rice": [ + {"link": "https://example.com/rice", "unit": "grams", "count": 2.0}, + ], }, }); }); diff --git a/menu_management/test/persistency_test.dart b/menu_management/test/persistency_test.dart index ea0c10c..a8f84c5 100644 --- a/menu_management/test/persistency_test.dart +++ b/menu_management/test/persistency_test.dart @@ -442,7 +442,9 @@ void main() { "shoppingProgress": { "useFreezerStrategy": true, "ownedProductCounts": { - "rice": {"https://example.com/rice": 2}, + "rice": [ + {"link": "https://example.com/rice", "unit": "grams", "count": 2}, + ], }, }, "weeks": jsonDecode(_validTsmContent())["weeks"], @@ -455,7 +457,7 @@ void main() { menu!.shoppingProgress, const ShoppingProgress( ownedProductCounts: { - "rice": {"https://example.com/rice": 2}, + "rice": {(link: "https://example.com/rice", unit: Unit.grams): 2}, }, useFreezerStrategy: true, ), @@ -850,7 +852,7 @@ void main() { const ShoppingProgress progress = ShoppingProgress( ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, ownedProductCounts: { - "rice": {"https://example.com/rice": 2}, + "rice": {(link: "https://example.com/rice", unit: Unit.grams): 2}, }, useFreezerStrategy: true, ); diff --git a/menu_management/test/shopping_page_test.dart b/menu_management/test/shopping_page_test.dart index cdfc7cf..7e5f286 100644 --- a/menu_management/test/shopping_page_test.dart +++ b/menu_management/test/shopping_page_test.dart @@ -183,6 +183,54 @@ OwnedUnit? _headerOwnedUnit(WidgetTester tester) { return tester.widget>(find.byType(DropdownButtonFormField)).initialValue; } +const String _breadLink = "https://example.com/bread"; + +// The store sells one loaf, so the weight product and the pieces product share one link. +const Ingredient _bread = Ingredient( + id: "bread", + name: "Bread", + products: [ + Product(link: _breadLink, quantityPerItem: 400, itemsPerPack: 1, unit: Unit.grams), + Product(link: _breadLink, quantityPerItem: 1, itemsPerPack: 1, unit: Unit.pieces), + ], +); + +// No product, so the header "Owned" input offers grams only, and no packs. +const Ingredient _salt = Ingredient(id: "salt", name: "Salt"); + +/// Seeds one recipe that needs [usages] and returns a one-meal menu that cooks it, with [progress]. +MultiWeekMenu _seedSingleRecipeMenu({ + required List ingredients, + required List usages, + required ShoppingProgress progress, +}) { + Recipe recipe = Recipe( + id: "r4", + name: "Test dish", + instructions: [Instruction(id: "i4", description: "cook it", workingTimeMinutes: 5, cookingTimeMinutes: 0, ingredientsUsed: usages)], + ); + IngredientsProvider.instance.setData(ingredients); + RecipesProvider.instance.setData([recipe], ingredients: ingredients); + return MultiWeekMenu( + shoppingProgress: progress, + weeks: const [ + Menu( + meals: [ + Meal( + mealTime: MealTime(weekDay: WeekDay.monday, mealType: MealType.lunch), + subMeals: [SubMeal(cooking: Cooking(recipeId: "r4", yield: 1), people: 1)], + ), + ], + ), + ], + ); +} + +/// Finds the "Owned" field that shows [text] inside the product row of [productIndex]. +Finder _ownedFieldOfProductRow({required int productIndex, required String text}) { + return find.descendant(of: find.byKey(ValueKey(productIndex)), matching: find.widgetWithText(TextField, text)); +} + MultiWeekMenu _menu({DateTime? startDate}) { return MultiWeekMenu( startDate: startDate, @@ -390,7 +438,7 @@ void main() { MultiWeekMenu menu = _menu().copyWith( shoppingProgress: const ShoppingProgress( ownedProductCounts: { - "rice": {_riceLink: 2}, + "rice": {(link: _riceLink, unit: Unit.grams): 2}, }, useFreezerStrategy: true, ), @@ -399,7 +447,71 @@ void main() { await _pumpShoppingPage(tester, menu); expect(tester.widget(find.byType(Switch)).value, isTrue); + expect(_ownedFieldOfProductRow(productIndex: 0, text: "2"), findsOneWidget); + }); + + testWidgets("puts a restored count on the product of its link and unit when two products share a link", (WidgetTester tester) async { + MultiWeekMenu menu = _seedSingleRecipeMenu( + ingredients: const [_bread], + usages: const [ + IngredientUsage( + ingredient: "bread", + quantity: Quantity(amount: 200, unit: Unit.grams), + ), + IngredientUsage( + ingredient: "bread", + quantity: Quantity(amount: 2, unit: Unit.pieces), + ), + ], + progress: const ShoppingProgress( + ownedProductCounts: { + "bread": {(link: _breadLink, unit: Unit.pieces): 3}, + }, + ), + ); + + await _pumpShoppingPage(tester, menu); + + expect(_ownedFieldOfProductRow(productIndex: 1, text: "3"), findsOneWidget); + expect(_ownedFieldOfProductRow(productIndex: 0, text: "3"), findsNothing); + }); + + testWidgets("restores a header owned amount in packs", (WidgetTester tester) async { + _seedEggs(); + + await _pumpShoppingPage(tester, _eggMenu(progress: const ShoppingProgress(ownedAmounts: {"egg": (amount: 2, unit: null)}))); + expect(find.widgetWithText(TextField, "2"), findsOneWidget); + expect(_headerOwnedUnit(tester), const OwnedUnit()); + }); + + testWidgets("drops a header owned amount in packs when the ingredient has no product", (WidgetTester tester) async { + MultiWeekMenu menu = _seedSingleRecipeMenu( + ingredients: const [_salt], + usages: const [ + IngredientUsage( + ingredient: "salt", + quantity: Quantity(amount: 10, unit: Unit.grams), + ), + ], + progress: const ShoppingProgress(ownedAmounts: {"salt": (amount: 3, unit: null)}), + ); + + await _pumpShoppingPage(tester, menu); + + expect(tester.takeException(), isNull); + expect(find.widgetWithText(TextField, "3"), findsNothing); + }); + + testWidgets("drops a header owned amount when product rows replace the header input", (WidgetTester tester) async { + List reported = []; + MultiWeekMenu menu = _menu().copyWith(shoppingProgress: const ShoppingProgress(ownedAmounts: {"rice": (amount: 300, unit: Unit.grams)})); + await _pumpShoppingPage(tester, menu, onShoppingProgressChanged: reported.add); + + await tester.tap(find.byType(Switch)); + await tester.pump(); + + expect(reported.last, const ShoppingProgress(useFreezerStrategy: true)); }); testWidgets("restores the header owned amount and its unit", (WidgetTester tester) async { @@ -432,7 +544,7 @@ void main() { reported.last, const ShoppingProgress( ownedProductCounts: { - "rice": {_riceLink: 2}, + "rice": {(link: _riceLink, unit: Unit.grams): 2}, }, ), ); @@ -477,7 +589,9 @@ void main() { expect(savedJson!["shoppingProgress"], { "ownedProductCounts": { - "rice": {_riceLink: 2.0}, + "rice": [ + {"link": _riceLink, "unit": "grams", "count": 2.0}, + ], }, }); }); diff --git a/menu_management/test/shopping_progress_test.dart b/menu_management/test/shopping_progress_test.dart index 4aef97f..18c2927 100644 --- a/menu_management/test/shopping_progress_test.dart +++ b/menu_management/test/shopping_progress_test.dart @@ -8,6 +8,7 @@ import "package:menu_management/shopping/shopping_progress.dart"; const String _riceSmallLink = "https://example.com/rice-500"; const String _riceLargeLink = "https://example.com/rice-1000"; +const String _breadLink = "https://example.com/bread"; const Ingredient _rice = Ingredient( id: "rice", @@ -18,6 +19,26 @@ const Ingredient _rice = Ingredient( ], ); +// The store sells one loaf, so the weight product and the pieces product share one link. +const Ingredient _bread = Ingredient( + id: "bread", + name: "Bread", + products: [ + Product(link: _breadLink, quantityPerItem: 400, itemsPerPack: 1, unit: Unit.grams), + Product(link: _breadLink, quantityPerItem: 1, itemsPerPack: 1, unit: Unit.pieces), + ], +); + +// Two products with the same link and the same unit: only the first one can carry a saved count. +const Ingredient _twinFlour = Ingredient( + id: "flour", + name: "Flour", + products: [ + Product(link: "https://example.com/flour", quantityPerItem: 1000, itemsPerPack: 1, unit: Unit.grams), + Product(link: "https://example.com/flour", quantityPerItem: 1000, itemsPerPack: 2, unit: Unit.grams), + ], +); + const Ingredient _salt = Ingredient(id: "salt", name: "Salt"); void main() { @@ -26,7 +47,7 @@ void main() { const ShoppingProgress progress = ShoppingProgress( ownedAmounts: {"salt": (amount: 20, unit: Unit.grams), "oil": (amount: 1.5, unit: null)}, ownedProductCounts: { - "rice": {_riceLargeLink: 2}, + "rice": {(link: _riceLargeLink, unit: Unit.grams): 2}, }, useFreezerStrategy: true, ); @@ -38,7 +59,9 @@ void main() { "oil": {"amount": 1.5, "unit": "packs"}, }, "ownedProductCounts": { - "rice": {_riceLargeLink: 2.0}, + "rice": [ + {"link": _riceLargeLink, "unit": "grams", "count": 2.0}, + ], }, }); }); @@ -61,7 +84,8 @@ void main() { const ShoppingProgress progress = ShoppingProgress( ownedAmounts: {"salt": (amount: 20, unit: Unit.grams), "oil": (amount: 1.5, unit: null)}, ownedProductCounts: { - "rice": {_riceLargeLink: 2}, + "rice": {(link: _riceLargeLink, unit: Unit.grams): 2}, + "bread": {(link: _breadLink, unit: Unit.grams): 1, (link: _breadLink, unit: Unit.pieces): 3}, }, useFreezerStrategy: true, ); @@ -97,9 +121,18 @@ void main() { test("drops only the bad product counts", () { Map json = { "ownedProductCounts": { - "rice": {_riceSmallLink: 1, _riceLargeLink: "two"}, - "milk": {"https://example.com/milk": 0}, - "pasta": "three", + "rice": [ + {"link": _riceSmallLink, "unit": "grams", "count": 1}, + {"link": _riceLargeLink, "unit": "grams", "count": "two"}, + {"link": _riceLargeLink, "unit": "packs", "count": 2}, + {"link": _riceLargeLink, "count": 2}, + {"unit": "grams", "count": 2}, + "not an object", + ], + "milk": [ + {"link": "https://example.com/milk", "unit": "grams", "count": 0}, + ], + "pasta": {"https://example.com/pasta": 3}, }, }; @@ -107,7 +140,7 @@ void main() { ShoppingProgress.fromJsonLenient(json), const ShoppingProgress( ownedProductCounts: { - "rice": {_riceSmallLink: 1}, + "rice": {(link: _riceSmallLink, unit: Unit.grams): 1}, }, ), ); @@ -132,10 +165,10 @@ void main() { }); group("ShoppingProgress.ownedProductCountsByIndex", () { - test("turns each product link into the index of that product", () { + test("turns each product key into the index of that product", () { const ShoppingProgress progress = ShoppingProgress( ownedProductCounts: { - "rice": {_riceLargeLink: 2, _riceSmallLink: 1}, + "rice": {(link: _riceLargeLink, unit: Unit.grams): 2, (link: _riceSmallLink, unit: Unit.grams): 1}, }, ); @@ -144,10 +177,46 @@ void main() { }); }); + test("puts each count on its own product when two products share a link", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "bread": {(link: _breadLink, unit: Unit.pieces): 3, (link: _breadLink, unit: Unit.grams): 1}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_bread]), { + "bread": {0: 1.0, 1: 3.0}, + }); + }); + + test("puts the count on the first product when two products share a link and a unit", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "flour": {(link: "https://example.com/flour", unit: Unit.grams): 2}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_twinFlour]), { + "flour": {0: 2.0}, + }); + }); + test("drops a count whose link matches no product", () { const ShoppingProgress progress = ShoppingProgress( ownedProductCounts: { - "rice": {_riceSmallLink: 1, "https://example.com/rice-gone": 3}, + "rice": {(link: _riceSmallLink, unit: Unit.grams): 1, (link: "https://example.com/rice-gone", unit: Unit.grams): 3}, + }, + ); + + expect(progress.ownedProductCountsByIndex(ingredients: const [_rice]), { + "rice": {0: 1.0}, + }); + }); + + test("drops a count whose unit matches no product of its link", () { + const ShoppingProgress progress = ShoppingProgress( + ownedProductCounts: { + "rice": {(link: _riceSmallLink, unit: Unit.grams): 1, (link: _riceLargeLink, unit: Unit.pieces): 3}, }, ); @@ -159,8 +228,8 @@ void main() { test("drops the counts of an ingredient that no longer exists", () { const ShoppingProgress progress = ShoppingProgress( ownedProductCounts: { - "rice": {_riceSmallLink: 1}, - "gone": {"https://example.com/gone": 2}, + "rice": {(link: _riceSmallLink, unit: Unit.grams): 1}, + "gone": {(link: "https://example.com/gone", unit: Unit.grams): 2}, }, ); @@ -171,7 +240,7 @@ void main() { }); group("ShoppingProgress.fromPageState", () { - test("keys each product count by the link of the product", () { + test("keys each product count by the link and the unit of the product", () { ShoppingProgress progress = ShoppingProgress.fromPageState( previous: null, ownedAmounts: const {"salt": (amount: 20, unit: Unit.grams), "rice": (amount: 0, unit: null)}, @@ -188,13 +257,55 @@ void main() { const ShoppingProgress( ownedAmounts: {"salt": (amount: 20, unit: Unit.grams)}, ownedProductCounts: { - "rice": {_riceLargeLink: 2}, + "rice": {(link: _riceLargeLink, unit: Unit.grams): 2}, }, useFreezerStrategy: true, ), ); }); + test("keeps both counts when two products share a link", () { + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: null, + ownedAmounts: const {"bread": (amount: 0, unit: null)}, + ownedProductCountsByIndex: const { + "bread": {0: 1, 1: 3}, + }, + useFreezerStrategy: false, + ingredients: const [_bread], + ); + + expect( + progress, + const ShoppingProgress( + ownedProductCounts: { + "bread": {(link: _breadLink, unit: Unit.grams): 1, (link: _breadLink, unit: Unit.pieces): 3}, + }, + ), + ); + }); + + test("keeps the count of the first product when two products share a link and a unit", () { + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: null, + ownedAmounts: const {"flour": (amount: 0, unit: null)}, + ownedProductCountsByIndex: const { + "flour": {1: 5, 0: 2}, + }, + useFreezerStrategy: false, + ingredients: const [_twinFlour], + ); + + expect( + progress, + const ShoppingProgress( + ownedProductCounts: { + "flour": {(link: "https://example.com/flour", unit: Unit.grams): 2}, + }, + ), + ); + }); + test("drops a count whose index matches no product", () { ShoppingProgress progress = ShoppingProgress.fromPageState( previous: null, @@ -210,7 +321,7 @@ void main() { progress, const ShoppingProgress( ownedProductCounts: { - "rice": {_riceSmallLink: 1}, + "rice": {(link: _riceSmallLink, unit: Unit.grams): 1}, }, ), ); @@ -220,8 +331,8 @@ void main() { const ShoppingProgress previous = ShoppingProgress( ownedAmounts: {"flour": (amount: 300, unit: Unit.grams), "salt": (amount: 50, unit: Unit.grams)}, ownedProductCounts: { - "pasta": {"https://example.com/pasta": 2}, - "rice": {_riceSmallLink: 4}, + "pasta": {(link: "https://example.com/pasta", unit: Unit.grams): 2}, + "rice": {(link: _riceSmallLink, unit: Unit.grams): 4}, }, useFreezerStrategy: true, ); @@ -242,8 +353,8 @@ void main() { const ShoppingProgress( ownedAmounts: {"flour": (amount: 300, unit: Unit.grams)}, ownedProductCounts: { - "pasta": {"https://example.com/pasta": 2}, - "rice": {_riceLargeLink: 1}, + "pasta": {(link: "https://example.com/pasta", unit: Unit.grams): 2}, + "rice": {(link: _riceLargeLink, unit: Unit.grams): 1}, }, ), ); From 6e5263532c130183ae906c0e6182c7b9e3148eaf Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:20:03 +0200 Subject: [PATCH 08/11] docs: record the shopping progress in the ADRs and AGENTS.md ADR 0003 gains the link plus unit key, the dropped-value rules, and why the JSON is hand-written and parsed in the model. ADR 0008 lists the optional progress, ADR 0015 says where the trip switch is stored, and ADR 0016 says why the delete cascade leaves stale progress. AGENTS.md lists ShoppingProgress and the new test count. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++--- adr/0003-tsr-file-persistence.md | 2 +- adr/0008-multi-week-menus.md | 1 + adr/0015-freezable-products-and-freezer-aware-trips.md | 2 +- adr/0016-reference-guarded-deletion.md | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 701aa24..a48aa7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it; | Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | | | Build release | `cd menu_management && flutter build windows` | | | Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop | -| Run all tests | `cd menu_management && flutter test test/` | 1108 tests across 43 files | +| Run all tests | `cd menu_management && flutter test test/` | 1119 tests across 44 files | | Run single test | `cd menu_management && flutter test test/.dart` | | | List devices | `flutter devices` | | | Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` | @@ -93,7 +93,7 @@ UI (Widgets) --> State (Providers) --> Data (Freezed Models) | `ingredients/` | `IngredientsProvider` | `Ingredient`, `Product` | CRUD for food items; optional store product link per ingredient | | `recipes/` | `RecipesProvider` | `Recipe`, `Instruction`, `IngredientUsage`, `Quantity`, `Result` | Recipe management with multi-step instructions, inputs/outputs | | `menu/` | `MenuProvider` | `MultiWeekMenu`, `Menu`, `Meal`, `MealTime`, `Cooking`, `MenuConfiguration` | Multi-week menus (each week = 21 meal slots), generation algorithm | -| `shopping/` | (derived) | `ShoppingIngredient` | Aggregated shopping list from generated menu | +| `shopping/` | (derived) | `ShoppingIngredient`, `ShoppingProgress` | Aggregated shopping list from generated menu; saved owned stock | | `theme/` | - | `DynamicTheme`, `ThemeCustom` | Material 3 theming | ### Menu Generation Algorithm @@ -106,7 +106,7 @@ Core logic in `menu_generator.dart`. Fully parameterized: receives `List - **`.tsr` files**: JSON with top-level `"Ingredients"` and `"Recipes"` arrays. On save, `ref_name` fields are injected into `IngredientUsage` entries for human readability. - **`.tsm` files**: Menus store `recipeId` (UUID) + `ref_name` per meal, not full Recipe objects. On load, each `recipeId` is validated; missing recipes are skipped with a warning. A menu may also carry `startDate`, the real date of menu day 0; a file without it keeps the Saturday-first, date-less behavior. Use `menu/menu_dates.dart` to turn a day offset into a date or a label. -- **Shopping progress**: a `.tsm` file can carry `shoppingProgress`, the owned stock and the trip switch of `ShoppingPage`. It keys product counts by product `link`, not index; `shopping/shopping_progress.dart` converts at the page boundary. See ADR 0003. +- **Shopping progress**: a `.tsm` file can carry `shoppingProgress`, the owned stock and the trip switch of `ShoppingPage`. It keys product counts by product `link` plus `unit`, not index; `shopping/shopping_progress.dart` converts at the page boundary. See ADR 0003. - **PDF export**: each PDF is two files. `_pdf_document.dart` decides what the PDF says, and `_pdf.dart` renders it and exposes its composed strings as public pure functions. The split keeps the content testable with no PDF to decode. The two PDFs are `menu/menu_pdf.dart` and `shopping/shopping_pdf.dart`. `Persistency.saveBytes` writes any export that is not JSON, and `Persistency.supportsFileSaving()` says if the device has a save dialog. - Data is **not** automatically saved -- users must manually save via the save button - On startup, dialogs ask whether to load last session, bundled defaults, or skip (for both recipes and menus) diff --git a/adr/0003-tsr-file-persistence.md b/adr/0003-tsr-file-persistence.md index 9f2f75b..8a2c2d0 100644 --- a/adr/0003-tsr-file-persistence.md +++ b/adr/0003-tsr-file-persistence.md @@ -12,7 +12,7 @@ Persist data as JSON-based files via `FilePicker`, using two distinct formats: - **`.tsm` files** -- Store generated menus (single-week or multi-week). Each meal's `Cooking` object stores a `recipeId` (UUID string) referencing a recipe in the recipe book, plus a `ref_name` field for human readability (ignored by the app on load). Saved through `Persistency.saveMenu()`, loaded through `Persistency.loadMultiWeekMenu()`. The loader detects whether the JSON contains a `"weeks"` key (multi-week format) or just `"meals"` (old single-week format) and handles both. On load, each `recipeId` is validated against the loaded recipe book; meals referencing missing recipes have their cooking set to null with a warning logged. -- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link`, not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link does not. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. `ShoppingProgress.fromJsonLenient` drops each bad value with a warning, and the page drops a count whose link matches no product. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu. +- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link` plus its `unit` (`ProductCountKey`), not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link and the unit do not. The link alone is not unique: the grams product and the pieces product of one store item share it. When two products share link and unit, the first of them gets the count. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. The code writes this JSON by hand, because json_serializable cannot write the record types of the model. The lenient parse lives in the model (`ShoppingProgress.fromJsonLenient`), not in `Persistency` like `startDate`, so every `MultiWeekMenu.fromJson` call gets it. It drops each bad value with a warning. On open, the page also drops a count that matches no product, a header amount whose unit the unit dropdown no longer offers, and a header amount of an ingredient that now shows one input per product. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu. - **Exports that are not JSON** (for example the menu PDF) -- `Persistency.saveBytes()` asks the user where to save through the same `FilePicker` save dialog, then writes raw bytes with `saveBytesToPath()`. `saveBytesToPath()` writes the picked path as it came back and adds no extension: the save dialog asks about the name that the user typed, so a changed name could overwrite a file that the user never saw. `Persistency.supportsFileSaving()` reports whether the device has a save dialog at all; `FileExportOption` asks it before it builds any byte and warns the user instead of failing silently on iOS/Android. The `.tsm` and `.tsr` saves do not ask it yet, so they still fail without a word on mobile. An export writes no `last_session.json` entry, because the app has nothing that reads such a file back. `Persistency.defaultMenuFileName()` takes an `extension` parameter so a `.tsm` save and a PDF export of the same menu propose the same file name, differing only in extension. diff --git a/adr/0008-multi-week-menus.md b/adr/0008-multi-week-menus.md index 3dd851f..4268faf 100644 --- a/adr/0008-multi-week-menus.md +++ b/adr/0008-multi-week-menus.md @@ -20,6 +20,7 @@ Key design choices: - **Optional start date**: `MultiWeekMenu.startDate` holds the real calendar date of menu day 0. It is optional. The planning math never reads it: every calculation stays in absolute day offsets (`weekIndex * 7 + weekDay.value`). The date only translates an offset into a real date for the user. The functions in `lib/menu/menu_dates.dart` do that translation and are the one home for it. - **Day order comes from the start date**: the `WeekDay` enum value is the day offset inside the week, so the grid always renders offset 0 to 6 in that order. The start date changes only the name and the date of each column. A menu that starts on a Wednesday reads Wednesday to Tuesday. No meal moves, because no meal is re-keyed. - **The start date survives regeneration**: the start date is user configuration, not generator output. `MenuGenerator` knows nothing about dates, so `MenuPage` re-applies the date to the regenerated menu. +- **Optional shopping progress**: `MultiWeekMenu.shoppingProgress` holds what the user typed on the shopping page (ADR 0003 has the file format). It is user input, like the start date, so the regenerate button of `MenuPage` and the generate button of `MenuConfigurationPage` both keep it. `ShoppingPage` hands each change back to `MenuPage` through a callback, and `MenuPage` stores it through its menu setter. - **Date-less menus keep the old wording**: a `.tsm` file without `startDate` loads with a null date. Then the day names come from the `WeekDay` enum, which starts at Saturday, and no date is shown. The shopping trip label falls back to "Week N". - **UI navigation**: Week switching uses left/right chevrons in the app bar. Add/remove week uses +/- circle buttons. When only one week exists, the navigation arrows are hidden. diff --git a/adr/0015-freezable-products-and-freezer-aware-trips.md b/adr/0015-freezable-products-and-freezer-aware-trips.md index 3221ec8..ba0242d 100644 --- a/adr/0015-freezable-products-and-freezer-aware-trips.md +++ b/adr/0015-freezable-products-and-freezer-aware-trips.md @@ -38,7 +38,7 @@ Red trumps blue: a meal with one impossible ingredient and one freeze-required i ### Shopping list: replacement of the freshness toggle -The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (internal flag `_useFreezerStrategy`). The two states are: +The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (page flag `_useFreezerStrategy`). The menu file keeps it in `ShoppingProgress.useFreezerStrategy` (ADR 0003), so the page restores it. The two states are: - **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks weekly trips so every event is within sealed shelf life (ADR 0014 says when the set is the smallest). Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking". - **On (One-trip mode)**: every event whose matching product is freezable (any-match across same-unit variants) is treated as non-perishable for trip assignment AND pinned to trip 0. Such an event is also tagged `freezeOnArrival = true` when its sealed shelf life would not have covered the gap from trip 0 to the cooking day (i.e. the user actually has to freeze it). Non-freezable perishables continue to flow through the original interval-cover algorithm and may still force later trips when their shelf life is exceeded. Tooltip: "shop once and freeze items that would otherwise expire". diff --git a/adr/0016-reference-guarded-deletion.md b/adr/0016-reference-guarded-deletion.md index d0efbaa..b203219 100644 --- a/adr/0016-reference-guarded-deletion.md +++ b/adr/0016-reference-guarded-deletion.md @@ -63,6 +63,7 @@ Option 2 was chosen because `MenuProvider` is already the app-lifetime singleton - All three delete flows are now safe: no dangling ingredient IDs in `IngredientUsage`, no dangling `recipeId` in `Cooking`, no dangling result IDs in `Instruction.inputs`. - Undo is symmetric with the cascade: the snackbar's "Undo" action restores not just the deleted entity but also every recipe/menu it had modified, by closing over the pre-cascade objects (`referencingRecipes`, `menu`) in the callback. - `MenuProvider.multiWeekMenu` is a best-effort mirror, not a guaranteed-fresh source of truth. It is set only when `MenuPage` is open or was opened at least once this session. Every current generate/load path pushes a new `MenuPage`, which replaces the mirror, but nothing ever resets it to `null` -- it outlives the page it came from, and it is not touched when a `.tsr` recipe file is loaded that removes recipes the mirrored menu still references. `MenuConfigurationPage` also reads the mirror on purpose: it borrows the start date of the active menu to name its day columns, so the grid can show the dates of a menu that the user already closed, and it falls back to the Saturday-first `WeekDay` order before any menu exists. `RecipesPage._deleteSelectedRecipe` tolerates this because `findReferencingMeals` is a plain string comparison with no assumption that every `recipeId` it finds still resolves to a loaded `Recipe`; worst case, a stale mirrored menu under-reports or over-reports references for a menu the user is no longer looking at, but it cannot crash. +- The delete cascade does not touch `MultiWeekMenu.shoppingProgress`. A deleted ingredient or product leaves its saved owned stock in the menu on purpose: the shopping page drops each entry that matches nothing when it opens, with a warning (ADR 0003). - `MenuProvider` now has two unrelated responsibilities: the fixed 21-slot `MenuConfiguration` grid (ADR 0002) and this one mutable "active menu" reference. A future reviewer of ADR 0002 should be aware the "fixed-grid provider" description is no longer the whole picture. - The reference-check methods intentionally cascade-delete rather than block the deletion outright. The user is warned and shown exactly what will be cleaned up, but there is no "cannot delete while referenced" mode. This matches the app's existing delete-then-undo pattern (immediate action, reversible) rather than introducing a new blocking-validation pattern. - `showDeleteConfirmationDialog` is domain-agnostic and reusable; a fourth delete flow needing the same warn-and-list treatment (e.g., deleting a `Product` referenced by shopping calculations) can reuse it without changes. From 264f61c39ae543c383a0dcc4222249dbc492a2b4 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:24:31 +0200 Subject: [PATCH 09/11] fix: drop stale shopping progress of deleted ingredients and products fromPageState copied every previous entry of an ingredient that the page did not show. A deleted ingredient never shows on the page, so its entry stayed in the .tsm file forever, and the open logged the same warning each time. It now drops a previous entry whose ingredient no longer exists, and a previous count whose link and unit match no product. Entries of existing ingredients outside the current menu stay. ADR 0003 and ADR 0016 now describe when the page drops such entries. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- adr/0003-tsr-file-persistence.md | 2 +- adr/0016-reference-guarded-deletion.md | 2 +- .../lib/shopping/shopping_progress.dart | 21 +++++-- .../test/shopping_progress_test.dart | 62 ++++++++++++++++++- 5 files changed, 79 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a48aa7d..0f6cd51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it; | Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | | | Build release | `cd menu_management && flutter build windows` | | | Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop | -| Run all tests | `cd menu_management && flutter test test/` | 1119 tests across 44 files | +| Run all tests | `cd menu_management && flutter test test/` | 1121 tests across 44 files | | Run single test | `cd menu_management && flutter test test/.dart` | | | List devices | `flutter devices` | | | Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` | diff --git a/adr/0003-tsr-file-persistence.md b/adr/0003-tsr-file-persistence.md index 8a2c2d0..31ac96c 100644 --- a/adr/0003-tsr-file-persistence.md +++ b/adr/0003-tsr-file-persistence.md @@ -12,7 +12,7 @@ Persist data as JSON-based files via `FilePicker`, using two distinct formats: - **`.tsm` files** -- Store generated menus (single-week or multi-week). Each meal's `Cooking` object stores a `recipeId` (UUID string) referencing a recipe in the recipe book, plus a `ref_name` field for human readability (ignored by the app on load). Saved through `Persistency.saveMenu()`, loaded through `Persistency.loadMultiWeekMenu()`. The loader detects whether the JSON contains a `"weeks"` key (multi-week format) or just `"meals"` (old single-week format) and handles both. On load, each `recipeId` is validated against the loaded recipe book; meals referencing missing recipes have their cooking set to null with a warning logged. -- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link` plus its `unit` (`ProductCountKey`), not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link and the unit do not. The link alone is not unique: the grams product and the pieces product of one store item share it. When two products share link and unit, the first of them gets the count. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. The code writes this JSON by hand, because json_serializable cannot write the record types of the model. The lenient parse lives in the model (`ShoppingProgress.fromJsonLenient`), not in `Persistency` like `startDate`, so every `MultiWeekMenu.fromJson` call gets it. It drops each bad value with a warning. On open, the page also drops a count that matches no product, a header amount whose unit the unit dropdown no longer offers, and a header amount of an ingredient that now shows one input per product. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu. +- **Shopping progress in the `.tsm` file** -- `MultiWeekMenu.shoppingProgress` (`ShoppingProgress`) stores what the user typed on the shopping page: the header owned amount and unit per ingredient, the owned count per product, and the "Try to make one trip" switch. The progress lives in the menu file because the user shops for one menu, and one Save button then keeps both. The file keys each product count by the product `link` plus its `unit` (`ProductCountKey`), not by its index in `Ingredient.products`. The index changes when the user adds, removes or reorders products; the link and the unit do not. The link alone is not unique: the grams product and the pieces product of one store item share it. When two products share link and unit, the first of them gets the count. The shopping page keeps its index keys, and `ShoppingProgress` converts between the two keys when the page opens and when the page reports a change. The code writes this JSON by hand, because json_serializable cannot write the record types of the model. The lenient parse lives in the model (`ShoppingProgress.fromJsonLenient`), not in `Persistency` like `startDate`, so every `MultiWeekMenu.fromJson` call gets it. It drops each bad value with a warning. On open, the page also drops a count that matches no product, a header amount whose unit the unit dropdown no longer offers, and a header amount of an ingredient that now shows one input per product. The first change that the page reports also drops a saved entry of a deleted ingredient and a saved count that matches no product, for the ingredients of other menus too, so a stale entry does not stay in the file. The JSON leaves out an empty progress, so a file with no progress loads with empty owned fields. The shopping page sends each change to `MenuPage` through a callback, so the progress survives every way to leave the page, the system back button too. A regeneration of the menu keeps the progress, because the stock at home does not depend on the menu. - **Exports that are not JSON** (for example the menu PDF) -- `Persistency.saveBytes()` asks the user where to save through the same `FilePicker` save dialog, then writes raw bytes with `saveBytesToPath()`. `saveBytesToPath()` writes the picked path as it came back and adds no extension: the save dialog asks about the name that the user typed, so a changed name could overwrite a file that the user never saw. `Persistency.supportsFileSaving()` reports whether the device has a save dialog at all; `FileExportOption` asks it before it builds any byte and warns the user instead of failing silently on iOS/Android. The `.tsm` and `.tsr` saves do not ask it yet, so they still fail without a word on mobile. An export writes no `last_session.json` entry, because the app has nothing that reads such a file back. `Persistency.defaultMenuFileName()` takes an `extension` parameter so a `.tsm` save and a PDF export of the same menu propose the same file name, differing only in extension. diff --git a/adr/0016-reference-guarded-deletion.md b/adr/0016-reference-guarded-deletion.md index b203219..3b06982 100644 --- a/adr/0016-reference-guarded-deletion.md +++ b/adr/0016-reference-guarded-deletion.md @@ -63,7 +63,7 @@ Option 2 was chosen because `MenuProvider` is already the app-lifetime singleton - All three delete flows are now safe: no dangling ingredient IDs in `IngredientUsage`, no dangling `recipeId` in `Cooking`, no dangling result IDs in `Instruction.inputs`. - Undo is symmetric with the cascade: the snackbar's "Undo" action restores not just the deleted entity but also every recipe/menu it had modified, by closing over the pre-cascade objects (`referencingRecipes`, `menu`) in the callback. - `MenuProvider.multiWeekMenu` is a best-effort mirror, not a guaranteed-fresh source of truth. It is set only when `MenuPage` is open or was opened at least once this session. Every current generate/load path pushes a new `MenuPage`, which replaces the mirror, but nothing ever resets it to `null` -- it outlives the page it came from, and it is not touched when a `.tsr` recipe file is loaded that removes recipes the mirrored menu still references. `MenuConfigurationPage` also reads the mirror on purpose: it borrows the start date of the active menu to name its day columns, so the grid can show the dates of a menu that the user already closed, and it falls back to the Saturday-first `WeekDay` order before any menu exists. `RecipesPage._deleteSelectedRecipe` tolerates this because `findReferencingMeals` is a plain string comparison with no assumption that every `recipeId` it finds still resolves to a loaded `Recipe`; worst case, a stale mirrored menu under-reports or over-reports references for a menu the user is no longer looking at, but it cannot crash. -- The delete cascade does not touch `MultiWeekMenu.shoppingProgress`. A deleted ingredient or product leaves its saved owned stock in the menu on purpose: the shopping page drops each entry that matches nothing when it opens, with a warning (ADR 0003). +- The delete cascade does not touch `MultiWeekMenu.shoppingProgress`. A deleted ingredient or product leaves its saved owned stock in the menu on purpose. When the shopping page opens, it logs a warning for each saved product count that matches no product. At the first change that the page reports, `ShoppingProgress.fromPageState` drops every saved entry of a deleted ingredient and every saved count that matches no product, so the next save writes a clean file (ADR 0003). - `MenuProvider` now has two unrelated responsibilities: the fixed 21-slot `MenuConfiguration` grid (ADR 0002) and this one mutable "active menu" reference. A future reviewer of ADR 0002 should be aware the "fixed-grid provider" description is no longer the whole picture. - The reference-check methods intentionally cascade-delete rather than block the deletion outright. The user is warned and shown exactly what will be cleaned up, but there is no "cannot delete while referenced" mode. This matches the app's existing delete-then-undo pattern (immediate action, reversible) rather than introducing a new blocking-validation pattern. - `showDeleteConfirmationDialog` is domain-agnostic and reusable; a fourth delete flow needing the same warn-and-list treatment (e.g., deleting a `Product` referenced by shopping calculations) can reuse it without changes. diff --git a/menu_management/lib/shopping/shopping_progress.dart b/menu_management/lib/shopping/shopping_progress.dart index 14f76ec..0b249c2 100644 --- a/menu_management/lib/shopping/shopping_progress.dart +++ b/menu_management/lib/shopping/shopping_progress.dart @@ -49,7 +49,9 @@ abstract class ShoppingProgress with _$ShoppingProgress { /// count of the lower index wins and the other count is dropped with a warning. /// /// The page shows only the ingredients of the current menu. The function copies the entries of - /// [previous] for every other ingredient, so the stock at home survives a new menu. + /// [previous] for every other ingredient in [ingredients], so the stock at home survives a new + /// menu. It drops a previous entry of an ingredient that is not in [ingredients], and a previous + /// count whose link and unit match no product of its ingredient. factory ShoppingProgress.fromPageState({ required ShoppingProgress? previous, required Map ownedAmounts, @@ -58,18 +60,25 @@ abstract class ShoppingProgress with _$ShoppingProgress { required List ingredients, }) { Set pageIngredientIds = {...ownedAmounts.keys, ...ownedProductCountsByIndex.keys}; + Map ingredientsById = {for (Ingredient ingredient in ingredients) ingredient.id: ingredient}; Map mergedAmounts = { for (MapEntry entry in (previous?.ownedAmounts ?? const {}).entries) - if (!pageIngredientIds.contains(entry.key)) entry.key: entry.value, + if (!pageIngredientIds.contains(entry.key) && ingredientsById.containsKey(entry.key)) entry.key: entry.value, for (MapEntry entry in ownedAmounts.entries) if (entry.value.amount > 0) entry.key: entry.value, }; - Map> mergedCounts = { - for (MapEntry> entry in (previous?.ownedProductCounts ?? const {}).entries) - if (!pageIngredientIds.contains(entry.key)) entry.key: entry.value, - }; + Map> mergedCounts = {}; + for (MapEntry> entry in (previous?.ownedProductCounts ?? const {}).entries) { + Ingredient? ingredient = ingredientsById[entry.key]; + if (pageIngredientIds.contains(entry.key) || ingredient == null) continue; + Map counts = { + for (MapEntry count in entry.value.entries) + if (ingredient.products.any((Product product) => product.link == count.key.link && product.unit == count.key.unit)) count.key: count.value, + }; + if (counts.isNotEmpty) mergedCounts[entry.key] = counts; + } for (MapEntry> entry in ownedProductCountsByIndex.entries) { Ingredient? ingredient = ingredients.firstWhereOrNull((Ingredient i) => i.id == entry.key); if (ingredient == null) continue; diff --git a/menu_management/test/shopping_progress_test.dart b/menu_management/test/shopping_progress_test.dart index 18c2927..9a60805 100644 --- a/menu_management/test/shopping_progress_test.dart +++ b/menu_management/test/shopping_progress_test.dart @@ -41,6 +41,12 @@ const Ingredient _twinFlour = Ingredient( const Ingredient _salt = Ingredient(id: "salt", name: "Salt"); +const Ingredient _pasta = Ingredient( + id: "pasta", + name: "Pasta", + products: [Product(link: "https://example.com/pasta", quantityPerItem: 500, itemsPerPack: 1, unit: Unit.grams)], +); + void main() { group("ShoppingProgress JSON", () { test("writes every part of the progress", () { @@ -345,7 +351,7 @@ void main() { "rice": {1: 1}, }, useFreezerStrategy: false, - ingredients: const [_rice, _salt], + ingredients: const [_rice, _salt, _twinFlour, _pasta], ); expect( @@ -359,5 +365,59 @@ void main() { ), ); }); + + test("drops the previous stock of an ingredient that no longer exists", () { + const ShoppingProgress previous = ShoppingProgress( + ownedAmounts: {"gone": (amount: 300, unit: Unit.grams), "flour": (amount: 200, unit: Unit.grams)}, + ownedProductCounts: { + "goneToo": {(link: "https://example.com/gone", unit: Unit.grams): 2}, + "pasta": {(link: "https://example.com/pasta", unit: Unit.grams): 1}, + }, + ); + + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: previous, + ownedAmounts: const {"salt": (amount: 0, unit: Unit.grams)}, + ownedProductCountsByIndex: const {"salt": {}}, + useFreezerStrategy: false, + ingredients: const [_salt, _twinFlour, _pasta], + ); + + expect( + progress, + const ShoppingProgress( + ownedAmounts: {"flour": (amount: 200, unit: Unit.grams)}, + ownedProductCounts: { + "pasta": {(link: "https://example.com/pasta", unit: Unit.grams): 1}, + }, + ), + ); + }); + + test("drops a previous count whose product no longer exists", () { + const ShoppingProgress previous = ShoppingProgress( + ownedProductCounts: { + "bread": {(link: _breadLink, unit: Unit.grams): 1, (link: _breadLink, unit: Unit.teaspoons): 2}, + "pasta": {(link: "https://example.com/pasta-gone", unit: Unit.grams): 3}, + }, + ); + + ShoppingProgress progress = ShoppingProgress.fromPageState( + previous: previous, + ownedAmounts: const {"salt": (amount: 0, unit: Unit.grams)}, + ownedProductCountsByIndex: const {"salt": {}}, + useFreezerStrategy: false, + ingredients: const [_salt, _bread, _pasta], + ); + + expect( + progress, + const ShoppingProgress( + ownedProductCounts: { + "bread": {(link: _breadLink, unit: Unit.grams): 1}, + }, + ), + ); + }); }); } From dbb82317908fd3adaefc9dd414f3b15d884a145c Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:24:31 +0200 Subject: [PATCH 10/11] test: check the restored count on the rice product row The back-navigation test found the text "2" in any field of the page. It now looks only inside the product row of the rice. Co-Authored-By: Claude Opus 5.5 --- menu_management/test/menu_page_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/menu_management/test/menu_page_test.dart b/menu_management/test/menu_page_test.dart index 965714e..2fa6751 100644 --- a/menu_management/test/menu_page_test.dart +++ b/menu_management/test/menu_page_test.dart @@ -272,7 +272,8 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(find.byType(Switch)).value, isTrue); - expect(find.widgetWithText(TextField, "2"), findsOneWidget); + // The rice has one product, so its product row has the key 0. + expect(find.descendant(of: find.byKey(const ValueKey(0)), matching: find.widgetWithText(TextField, "2")), findsOneWidget); }); }); From 2925a834b282e013197c9bba1dd466c82031b3ba Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 16:38:24 +0200 Subject: [PATCH 11/11] docs: note that a product link is not unique inside an ingredient The grams product and the pieces product of one store item share a link, so saved data must identify a product by link plus unit. The review of PR #80 caught this collision late; the gotcha keeps the next change from repeating it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 0f6cd51..6e426fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,6 +227,7 @@ ADRs capture **why** decisions were made, not just what was built. This includes - Platform target is desktop-first. Mobile platforms have limited save/load support. - CI installs the latest stable Flutter and pins no version, on purpose. When `flutter analyze` or a test compile fails on code that `main` already merged with a green check, your local SDK is too old. Run `flutter upgrade`. Never change the code to fit an old SDK. - To add days to a calendar date, use the `DateTime(year, month, day + n)` constructor, never `add(Duration(days: n))`. A `Duration` counts hours, so it drifts by one hour at each daylight-saving change and can land on the wrong calendar day. +- A `Product.link` is not unique inside one ingredient: the grams product and the pieces product of one store item share it. To identify a product in saved data, use the link plus the `unit`, never the link alone. ## Git Workflow