From 06e83a4fdc1fa5518f9e3a1cfd70bbea2c618717 Mon Sep 17 00:00:00 2001 From: Anja Klipic Date: Fri, 22 May 2026 21:46:28 +0200 Subject: [PATCH 1/5] feat: adds brand field to ingredient model --- lib/models/nutrition/ingredient.dart | 5 +++ lib/models/nutrition/ingredient.g.dart | 49 ++++++++++++++------------ test_data/nutritional_plans.dart | 5 +++ 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/models/nutrition/ingredient.dart b/lib/models/nutrition/ingredient.dart index be64ae5db..d4ee39d59 100644 --- a/lib/models/nutrition/ingredient.dart +++ b/lib/models/nutrition/ingredient.dart @@ -67,6 +67,10 @@ class Ingredient { @JsonKey(required: true) final String name; + /// Brand of the product + @JsonKey(name: 'brand') + final String? brand; + @JsonKey(required: true, name: 'created') final DateTime created; @@ -123,6 +127,7 @@ class Ingredient { required this.id, required this.code, required this.name, + this.brand, required this.created, required this.energy, required this.carbohydrates, diff --git a/lib/models/nutrition/ingredient.g.dart b/lib/models/nutrition/ingredient.g.dart index 39c74cad6..dee5d2df4 100644 --- a/lib/models/nutrition/ingredient.g.dart +++ b/lib/models/nutrition/ingredient.g.dart @@ -36,6 +36,7 @@ Ingredient _$IngredientFromJson(Map json) { id: (json['id'] as num).toInt(), code: json['code'] as String?, name: json['name'] as String, + brand: json['brand'] as String?, created: DateTime.parse(json['created'] as String), energy: (json['energy'] as num).toInt(), carbohydrates: stringToNum(json['carbohydrates'] as String?), @@ -59,29 +60,31 @@ Ingredient _$IngredientFromJson(Map json) { ); } -Map _$IngredientToJson(Ingredient instance) => { - 'id': instance.id, - 'remote_id': instance.remoteId, - 'source_name': instance.sourceName, - 'source_url': instance.sourceUrl, - 'license_object_url': instance.licenseObjectURl, - 'code': instance.code, - 'name': instance.name, - 'created': instance.created.toIso8601String(), - 'energy': instance.energy, - 'carbohydrates': numToString(instance.carbohydrates), - 'carbohydrates_sugar': numToString(instance.carbohydratesSugar), - 'protein': numToString(instance.protein), - 'fat': numToString(instance.fat), - 'fat_saturated': numToString(instance.fatSaturated), - 'fiber': numToString(instance.fiber), - 'sodium': numToString(instance.sodium), - 'is_vegan': instance.isVegan, - 'is_vegetarian': instance.isVegetarian, - 'nutriscore': _$NutriScoreEnumMap[instance.nutriscore], - 'image': instance.image, - 'thumbnails': instance.thumbnails, -}; +Map _$IngredientToJson(Ingredient instance) => + { + 'id': instance.id, + 'remote_id': instance.remoteId, + 'source_name': instance.sourceName, + 'source_url': instance.sourceUrl, + 'license_object_url': instance.licenseObjectURl, + 'code': instance.code, + 'name': instance.name, + 'brand': instance.brand, + 'created': instance.created.toIso8601String(), + 'energy': instance.energy, + 'carbohydrates': numToString(instance.carbohydrates), + 'carbohydrates_sugar': numToString(instance.carbohydratesSugar), + 'protein': numToString(instance.protein), + 'fat': numToString(instance.fat), + 'fat_saturated': numToString(instance.fatSaturated), + 'fiber': numToString(instance.fiber), + 'sodium': numToString(instance.sodium), + 'is_vegan': instance.isVegan, + 'is_vegetarian': instance.isVegetarian, + 'nutriscore': _$NutriScoreEnumMap[instance.nutriscore], + 'image': instance.image, + 'thumbnails': instance.thumbnails, + }; const _$NutriScoreEnumMap = { NutriScore.a: 'a', diff --git a/test_data/nutritional_plans.dart b/test_data/nutritional_plans.dart index 771bbb9cd..00a18e26d 100644 --- a/test_data/nutritional_plans.dart +++ b/test_data/nutritional_plans.dart @@ -32,6 +32,7 @@ final ingredient1 = Ingredient( id: 1, code: '123456787', name: 'Water', + brand: null, created: DateTime(2021, 5, 1), energy: 500, carbohydrates: 10, @@ -52,6 +53,7 @@ final ingredient2 = Ingredient( id: 2, code: '123456788', name: 'Burger soup', + brand: null, created: DateTime(2021, 5, 10), energy: 25, carbohydrates: 10, @@ -69,6 +71,7 @@ final ingredient3 = Ingredient( id: 3, code: '123456789', name: 'Broccoli cake', + brand: 'Weightwatchers', created: DateTime(2021, 5, 2), energy: 1200, carbohydrates: 110, @@ -86,6 +89,7 @@ final muesli = Ingredient( id: 1, code: '123456787', name: 'Müsli', + brand: 'Spar Gourmet', created: DateTime(2021, 5, 1), energy: 500, carbohydrates: 10, @@ -106,6 +110,7 @@ final milk = Ingredient( id: 1, code: '123456787', name: 'Milk', + brand: 'null', created: DateTime(2021, 5, 1), energy: 500, carbohydrates: 10, From 14577602ac325dacc9ebdb15c9e8a253d7912be8 Mon Sep 17 00:00:00 2001 From: Anja Klipic Date: Fri, 22 May 2026 21:47:00 +0200 Subject: [PATCH 2/5] feat: displays brand name next to ingredient name in search list --- lib/widgets/nutrition/widgets.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/widgets/nutrition/widgets.dart b/lib/widgets/nutrition/widgets.dart index 2a90b99a5..919855295 100644 --- a/lib/widgets/nutrition/widgets.dart +++ b/lib/widgets/nutrition/widgets.dart @@ -212,8 +212,19 @@ class _IngredientTypeaheadState extends ConsumerState { : const CircleIconAvatar( Icon(Icons.image, color: Colors.grey), ), - title: Text( - ingredient.name, + title: Text.rich( + TextSpan( + children: [ + TextSpan(text: ingredient.name), + if (ingredient.brand != null && ingredient.brand!.isNotEmpty) + TextSpan( + text: ' ${ingredient.brand}', + style: TextStyle( + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6), + ), + ), + ], + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), From 8b5a38169444674ff14681825fc00a7228329e45 Mon Sep 17 00:00:00 2001 From: Anja Klipic Date: Sat, 23 May 2026 08:14:36 +0200 Subject: [PATCH 3/5] feat: displays brand name underneath title in ingredient details --- lib/widgets/nutrition/ingredient_dialogs.dart | 17 ++++++++++++++++- test_data/nutritional_plans.dart | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/widgets/nutrition/ingredient_dialogs.dart b/lib/widgets/nutrition/ingredient_dialogs.dart index 0ae19872f..36d16fdb5 100644 --- a/lib/widgets/nutrition/ingredient_dialogs.dart +++ b/lib/widgets/nutrition/ingredient_dialogs.dart @@ -108,7 +108,22 @@ class IngredientDetails extends StatelessWidget { } return AlertDialog( - title: (snapshot.hasData) ? Text(ingredient!.name) : null, + title: snapshot.hasData + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(ingredient!.name), + if (ingredient.brand != null && ingredient.brand!.isNotEmpty) + Text( + ingredient.brand!, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6), + ), + ), + ], + ) + : null, content: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8.0), diff --git a/test_data/nutritional_plans.dart b/test_data/nutritional_plans.dart index 00a18e26d..2e9fd68ba 100644 --- a/test_data/nutritional_plans.dart +++ b/test_data/nutritional_plans.dart @@ -110,7 +110,7 @@ final milk = Ingredient( id: 1, code: '123456787', name: 'Milk', - brand: 'null', + brand: null, created: DateTime(2021, 5, 1), energy: 500, carbohydrates: 10, From 10cb77cffd2310a1e9fc6015f0e0005a307fe1d2 Mon Sep 17 00:00:00 2001 From: Anja Klipic Date: Sat, 23 May 2026 13:10:16 +0200 Subject: [PATCH 4/5] chore: add tests for changes related to brand field --- test/nutrition/ingredient_typeahead_test.dart | 21 +++++++ .../nutrition/ingredient_dialogs_test.dart | 61 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/test/nutrition/ingredient_typeahead_test.dart b/test/nutrition/ingredient_typeahead_test.dart index 5c9194a8c..37b023ecf 100644 --- a/test/nutrition/ingredient_typeahead_test.dart +++ b/test/nutrition/ingredient_typeahead_test.dart @@ -174,6 +174,27 @@ void main() { expect(find.text('Vegan'), findsNothing); }); + testWidgets('Shows brand inline in search result tile', (WidgetTester tester) async { + // ingredient3 (Broccoli cake) has brand 'Weightwatchers' + when( + mockNutrition.searchIngredient( + any, + languageCode: anyNamed('languageCode'), + searchLanguage: anyNamed('searchLanguage'), + isVegan: anyNamed('isVegan'), + isVegetarian: anyNamed('isVegetarian'), + nutriscoreMax: anyNamed('nutriscoreMax'), + ), + ).thenAnswer((_) => Future.value([ingredient3])); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.enterText(find.byType(TextFormField), 'Broccoli'); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Weightwatchers'), findsOneWidget); + }); + testWidgets('Shows no dietary chips when ingredient has no info', (WidgetTester tester) async { // ingredient2 (Burger soup) has no dietary info when( diff --git a/test/widgets/nutrition/ingredient_dialogs_test.dart b/test/widgets/nutrition/ingredient_dialogs_test.dart index 5a51220e4..91f65a582 100644 --- a/test/widgets/nutrition/ingredient_dialogs_test.dart +++ b/test/widgets/nutrition/ingredient_dialogs_test.dart @@ -22,6 +22,34 @@ import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/widgets/nutrition/ingredient_dialogs.dart'; +import '../../../test_data/nutritional_plans.dart'; + +Future pumpIngredientDetailsDialog( + WidgetTester tester, { + required AsyncSnapshot snapshot, +}) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => IngredientDetails(snapshot), + ); + }, + child: const Text('Show Dialog'), + ), + ), + ), + ), + ); +} + Future pumpIngredientScanDialog( WidgetTester tester, { required AsyncSnapshot snapshot, @@ -54,6 +82,39 @@ Future pumpIngredientScanDialog( } void main() { + group('IngredientDetails tests', () { + testWidgets('shows brand below name in title when ingredient has a brand', ( + WidgetTester tester, + ) async { + // ingredient3 (Broccoli cake, brand: 'Weightwatchers') + final snapshot = AsyncSnapshot.withData(ConnectionState.done, ingredient3); + + await pumpIngredientDetailsDialog(tester, snapshot: snapshot); + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Broccoli cake'), findsOneWidget); + expect(find.text('Weightwatchers'), findsOneWidget); + }); + + testWidgets('does not show brand in title when ingredient has no brand', ( + WidgetTester tester, + ) async { + // ingredient1 (Water, brand: null) + final snapshot = AsyncSnapshot.withData(ConnectionState.done, ingredient1); + + await pumpIngredientDetailsDialog(tester, snapshot: snapshot); + await tester.tap(find.text('Show Dialog')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Water'), findsOneWidget); + // brand: null must not render as the literal string "null" + expect(find.text('null'), findsNothing); + }); + }); + group('IngredientScanResultDialog tests', () { const testBarcode = '1234567890123'; From a699fd472a441ac8238f2e0a9d9ed58a47b245c8 Mon Sep 17 00:00:00 2001 From: Anja Klipic Date: Sat, 23 May 2026 13:50:50 +0200 Subject: [PATCH 5/5] chore: fix formatting --- lib/models/nutrition/ingredient.g.dart | 49 +++++++++++++------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/lib/models/nutrition/ingredient.g.dart b/lib/models/nutrition/ingredient.g.dart index dee5d2df4..97bae6933 100644 --- a/lib/models/nutrition/ingredient.g.dart +++ b/lib/models/nutrition/ingredient.g.dart @@ -60,31 +60,30 @@ Ingredient _$IngredientFromJson(Map json) { ); } -Map _$IngredientToJson(Ingredient instance) => - { - 'id': instance.id, - 'remote_id': instance.remoteId, - 'source_name': instance.sourceName, - 'source_url': instance.sourceUrl, - 'license_object_url': instance.licenseObjectURl, - 'code': instance.code, - 'name': instance.name, - 'brand': instance.brand, - 'created': instance.created.toIso8601String(), - 'energy': instance.energy, - 'carbohydrates': numToString(instance.carbohydrates), - 'carbohydrates_sugar': numToString(instance.carbohydratesSugar), - 'protein': numToString(instance.protein), - 'fat': numToString(instance.fat), - 'fat_saturated': numToString(instance.fatSaturated), - 'fiber': numToString(instance.fiber), - 'sodium': numToString(instance.sodium), - 'is_vegan': instance.isVegan, - 'is_vegetarian': instance.isVegetarian, - 'nutriscore': _$NutriScoreEnumMap[instance.nutriscore], - 'image': instance.image, - 'thumbnails': instance.thumbnails, - }; +Map _$IngredientToJson(Ingredient instance) => { + 'id': instance.id, + 'remote_id': instance.remoteId, + 'source_name': instance.sourceName, + 'source_url': instance.sourceUrl, + 'license_object_url': instance.licenseObjectURl, + 'code': instance.code, + 'name': instance.name, + 'brand': instance.brand, + 'created': instance.created.toIso8601String(), + 'energy': instance.energy, + 'carbohydrates': numToString(instance.carbohydrates), + 'carbohydrates_sugar': numToString(instance.carbohydratesSugar), + 'protein': numToString(instance.protein), + 'fat': numToString(instance.fat), + 'fat_saturated': numToString(instance.fatSaturated), + 'fiber': numToString(instance.fiber), + 'sodium': numToString(instance.sodium), + 'is_vegan': instance.isVegan, + 'is_vegetarian': instance.isVegetarian, + 'nutriscore': _$NutriScoreEnumMap[instance.nutriscore], + 'image': instance.image, + 'thumbnails': instance.thumbnails, +}; const _$NutriScoreEnumMap = { NutriScore.a: 'a',