diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ed3a1e1a..33fefcd00 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -366,7 +366,7 @@ "@logIngredient": {}, "searchIngredient": "Search ingredient", "@searchIngredient": { - "description": "Label on ingredient search form" + "description": "Label (or Hint text) on ingredient search form" }, "nutritionalPlan": "Nutritional plan", "@nutritionalPlan": {}, @@ -378,6 +378,11 @@ "@noNutritionalPlans": { "description": "Message shown when the user has no nutritional plans" }, + "manageLists": "Manage lists", + "@manageLists": { + "description": "Label for the button to manage ingredient lists" + }, + "clearSearchTerm": "Clear search term (text)", "onlyLogging": "Only track calories", "onlyLoggingHelpText": "Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", "goalMacro": "Macro goals", @@ -494,7 +499,9 @@ "timeStartAhead": "Start time cannot be ahead of end time", "@timeStartAhead": {}, "ingredient": "Ingredient", - "@ingredient": {}, + "@ingredient": { + "description": "Title for the ingredients screen" + }, "energy": "Energy", "@energy": { "description": "Energy in a meal, ingredient etc. e.g. in kJ" @@ -1282,6 +1289,10 @@ "@filterNutriscore": { "description": "Heading for the Nutri-Score slider in the ingredient search filter dialog" }, + "nutriscoreValue": "Nutri-Score:", + "@filterNutriscore": { + "description": "label for Nutri-Score(nutriscore) for.eg, in subtitle of ingredient custom lists" + }, "filterNutriscoreOff": "Off", "@filterNutriscoreOff": { "description": "Label for the first slider stop, which disables the Nutri-Score filter" diff --git a/lib/main.dart b/lib/main.dart index fbbb718cb..4135f9431 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,8 @@ import 'package:wger/screens/form_screen.dart'; import 'package:wger/screens/gallery_screen.dart'; import 'package:wger/screens/gym_mode.dart'; import 'package:wger/screens/home_tabs_screen.dart'; +import 'package:wger/screens/ingredient_detail_screen.dart'; +import 'package:wger/screens/ingredients_screen.dart'; import 'package:wger/screens/log_meal_screen.dart'; import 'package:wger/screens/log_meals_screen.dart'; import 'package:wger/screens/measurement_categories_screen.dart'; @@ -200,6 +202,8 @@ class MainApp extends ConsumerWidget { NutritionalPlansScreen.routeName: (ctx) => const NutritionalPlansScreen(), NutritionalDiaryScreen.routeName: (ctx) => const NutritionalDiaryScreen(), NutritionalPlanScreen.routeName: (ctx) => const NutritionalPlanScreen(), + IngredientDetailScreen.routeName: (ctx) => const IngredientDetailScreen(), + IngredientsScreen.routeName: (ctx) => const IngredientsScreen(), LogMealsScreen.routeName: (ctx) => const LogMealsScreen(), LogMealScreen.routeName: (ctx) => const LogMealScreen(), WeightScreen.routeName: (ctx) => const WeightScreen(), diff --git a/lib/models/nutrition/ingredient_filters.dart b/lib/models/nutrition/ingredient_filters.dart index 8916cd6c4..09ff4ccff 100644 --- a/lib/models/nutrition/ingredient_filters.dart +++ b/lib/models/nutrition/ingredient_filters.dart @@ -12,12 +12,14 @@ class IngredientFilters { /// `nutriscore__lte`. `null` means the filter is off (slider at the /// "No filter" position). final NutriScore? nutriscoreMax; + final String searchTerm; const IngredientFilters({ this.isVegan = false, this.isVegetarian = false, this.searchLanguage = SearchLanguage.currentAndEnglish, this.nutriscoreMax, + this.searchTerm = '', }); /// Locale-aware default filter set. @@ -37,12 +39,14 @@ class IngredientFilters { SearchLanguage? searchLanguage, NutriScore? nutriscoreMax, bool clearNutriscoreMax = false, + String? searchTerm, }) { return IngredientFilters( isVegan: isVegan ?? this.isVegan, isVegetarian: isVegetarian ?? this.isVegetarian, searchLanguage: searchLanguage ?? this.searchLanguage, nutriscoreMax: clearNutriscoreMax ? null : (nutriscoreMax ?? this.nutriscoreMax), + searchTerm: searchTerm ?? this.searchTerm, ); } diff --git a/lib/providers/ingredient_filters_notifier.dart b/lib/providers/ingredient_filters_notifier.dart index 8e7ee4156..baf319b33 100644 --- a/lib/providers/ingredient_filters_notifier.dart +++ b/lib/providers/ingredient_filters_notifier.dart @@ -75,6 +75,32 @@ class IngredientFiltersNotifier extends _$IngredientFiltersNotifier { ); await PreferenceHelper.instance.saveIngredientNutriscoreMax(value); } + + /// Updates the search term in the state without persisting it. + void setSearchTerm(String value) { + final current = _current(); + if (current.searchTerm != value) { + state = AsyncData(current.copyWith(searchTerm: value)); + } + } + + /// Resets all filters to their default values and clears persisted preferences. + Future resetFilters() async { + state = const AsyncData( + IngredientFilters( + isVegan: false, + isVegetarian: false, + searchLanguage: SearchLanguage.current, + searchTerm: '', + ), + ); + + final pref = PreferenceHelper.instance; + await pref.saveIngredientVeganFilter(false); + await pref.saveIngredientVegetarianFilter(false); + await pref.saveIngredientSearchLanguage(SearchLanguage.current); + await pref.saveIngredientNutriscoreMax(null); + } } // Synchronous helper provider to unwrap the AsyncValue produced by `ingredientFiltersProvider` diff --git a/lib/providers/ingredient_filters_notifier.g.dart b/lib/providers/ingredient_filters_notifier.g.dart index 76f5ac067..b6f59e19b 100644 --- a/lib/providers/ingredient_filters_notifier.g.dart +++ b/lib/providers/ingredient_filters_notifier.g.dart @@ -33,7 +33,7 @@ final class IngredientFiltersNotifierProvider IngredientFiltersNotifier create() => IngredientFiltersNotifier(); } -String _$ingredientFiltersNotifierHash() => r'3fb2ae558584d5ea3cce83679b6682ef5b1ba6f2'; +String _$ingredientFiltersNotifierHash() => r'c8e5577795e50643a8fdcf5b44c0a22b21830dfb'; abstract class _$IngredientFiltersNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/providers/ingredient_notifier.dart b/lib/providers/ingredient_notifier.dart index d216ca405..aab5119ef 100644 --- a/lib/providers/ingredient_notifier.dart +++ b/lib/providers/ingredient_notifier.dart @@ -22,17 +22,19 @@ import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wger/models/core/search_options.dart'; import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/providers/ingredient_filters_notifier.dart'; import 'package:wger/providers/network_provider.dart'; import 'ingredient_repository.dart'; part 'ingredient_notifier.g.dart'; -@riverpod +@Riverpod(keepAlive: true) final class IngredientNotifier extends _$IngredientNotifier { final _logger = Logger('IngredientProvider'); @@ -45,37 +47,30 @@ final class IngredientNotifier extends _$IngredientNotifier { final Map> _inflight = {}; @override - FutureOr build() async { - _repo = ref.read(ingredientRepositoryProvider); - return null; + Stream> build() { + ref.keepAlive(); + _logger.finer('Building ingredient stream'); + _repo = ref.watch(ingredientRepositoryProvider); + + return _repo.watchAllDrift(); } /// Fetch ingredient by [id] from the DB (one-shot) and update state. /// If the id is present in the in-memory cache, the cached value is /// returned immediately without a DB read. Simultaneous calls for the - /// same id are deduplicated. The provider `state` is used only as a - /// status signal (loading/done/error) and does not carry the Ingredient - /// itself — consumers should read the data from the notifier's cache. + /// same id are deduplicated. Future fetch(int id) async { _logger.finer('Fetching ingredient id: $id'); // Return cached result if present (could be null meaning 'not found'). if (_cache.containsKey(id)) { - final cached = _cache[id]; - if (ref.mounted) { - state = const AsyncData(null); - } - return cached; + return _cache[id]; } // If a fetch for this id is already ongoing, await it. if (_inflight.containsKey(id)) { _logger.finer('Awaiting in-flight fetch for id: $id'); - final res = await _inflight[id]; - if (ref.mounted) { - state = const AsyncData(null); - } - return res; + return await _inflight[id]; } // Start a new DB read and store the future in _inflight to dedupe. @@ -91,15 +86,8 @@ final class IngredientNotifier extends _$IngredientNotifier { } Future _doFetch(int id) async { - if (ref.mounted) { - state = const AsyncLoading(); - } final item = await _repo.getById(id); _cache[id] = item; // store null as 'not found' as well - if (!ref.mounted) { - return item; - } - state = const AsyncData(null); return item; } @@ -133,3 +121,19 @@ final class IngredientNotifier extends _$IngredientNotifier { ); } } + +/// Trigger ingredient search flow by reacting to filter changes. +final searchedIngredientsProvider = FutureProvider>((ref) async { + final filters = ref.watch(ingredientFiltersSyncProvider); + + // search logic handles the Online/Offline routing + return ref + .read(ingredientProvider.notifier) + .searchIngredient( + filters.searchTerm, + searchLanguage: filters.searchLanguage, + isVegan: filters.isVegan, + isVegetarian: filters.isVegetarian, + nutriscoreMax: filters.nutriscoreMax, + ); +}); diff --git a/lib/providers/ingredient_notifier.g.dart b/lib/providers/ingredient_notifier.g.dart index 510dff73f..a1d306fdf 100644 --- a/lib/providers/ingredient_notifier.g.dart +++ b/lib/providers/ingredient_notifier.g.dart @@ -12,14 +12,15 @@ part of 'ingredient_notifier.dart'; @ProviderFor(IngredientNotifier) final ingredientProvider = IngredientNotifierProvider._(); -final class IngredientNotifierProvider extends $AsyncNotifierProvider { +final class IngredientNotifierProvider + extends $StreamNotifierProvider> { IngredientNotifierProvider._() : super( from: null, argument: null, retry: null, name: r'ingredientProvider', - isAutoDispose: true, + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @@ -32,19 +33,19 @@ final class IngredientNotifierProvider extends $AsyncNotifierProvider IngredientNotifier(); } -String _$ingredientNotifierHash() => r'40e9fa3376a25289b04a86f9303e20de77cab1d8'; +String _$ingredientNotifierHash() => r'd94756d249a15e604640f2e9a5c1802a7d4f9711'; -abstract class _$IngredientNotifier extends $AsyncNotifier { - FutureOr build(); +abstract class _$IngredientNotifier extends $StreamNotifier> { + Stream> build(); @$mustCallSuper @override void runBuild() { - final ref = this.ref as $Ref, void>; + final ref = this.ref as $Ref>, List>; final element = ref.element as $ClassProviderElement< - AnyNotifier, void>, - AsyncValue, + AnyNotifier>, List>, + AsyncValue>, Object?, Object? >; diff --git a/lib/providers/ingredient_repository.dart b/lib/providers/ingredient_repository.dart index 1a52cfd6c..6191985fd 100644 --- a/lib/providers/ingredient_repository.dart +++ b/lib/providers/ingredient_repository.dart @@ -62,6 +62,18 @@ class IngredientRepository { }); } + /// Watches all locally synced ingredients. + /// + /// Only ingredients that are used in a nutritional plan or log are + /// synced to the device for offline storage in the Drift database. + Stream> watchAllDrift() { + _logger.finer('Watching all synced ingredients'); + final query = _baseJoinedQuery()..orderBy([OrderingTerm(expression: _db.ingredientTable.name)]); + return query.watch().map((rows) { + return _hydrate(rows); + }); + } + /// Read a single ingredient by [id] once from the DB Future getById(int id) async { _logger.finer('Reading ingredient $id'); diff --git a/lib/screens/ingredient_detail_screen.dart b/lib/screens/ingredient_detail_screen.dart new file mode 100644 index 000000000..f42a61a7d --- /dev/null +++ b/lib/screens/ingredient_detail_screen.dart @@ -0,0 +1,126 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/helpers/misc.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/widgets/core/wger_image.dart'; +import 'package:wger/widgets/nutrition/ingredient_dialogs.dart'; +import 'package:wger/widgets/nutrition/macro_nutrients_table.dart'; + +class IngredientDetailScreen extends ConsumerWidget { + static const routeName = '/ingredient-detail'; + + const IngredientDetailScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ingredient = ModalRoute.of(context)!.settings.arguments as Ingredient; + final goals = ingredient.nutritionalValues.toGoals(); + final source = ingredient.sourceName ?? 'unknown'; + + const placeholder = Image( + image: AssetImage('assets/images/placeholder.png'), + color: Color.fromRGBO(255, 255, 255, 0.3), + colorBlendMode: BlendMode.modulate, + semanticLabel: 'Placeholder', + ); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 250.0, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + title: Text( + ingredient.name, + style: const TextStyle( + shadows: [Shadow(color: Colors.black45, blurRadius: 4)], + ), + ), + background: WgerImage( + mediaPath: ingredient.image?.image, + fit: BoxFit.cover, + errorWidget: placeholder, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // Dietary Info (Vegan, Veg, NutriScore) + Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: DietaryInfoSection(ingredient: ingredient), + ), + ), + const SizedBox(height: 16), + + Text( + AppLocalizations.of(context).macronutrients, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + + // Macronutrients Table + Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: MacronutrientsTable( + nutritionalGoals: goals, + plannedValuesPercentage: goals.energyPercentage(), + showGperKg: false, + ), + ), + ), + const SizedBox(height: 24), + + Divider(color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 8), + if (ingredient.licenseObjectURl == null) + Text('Source: $source', style: Theme.of(context).textTheme.bodySmall) + else + InkWell( + onTap: () => launchURL(ingredient.licenseObjectURl!, context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'Source: $source', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ), + const SizedBox(height: 40), + ]), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/ingredients_screen.dart b/lib/screens/ingredients_screen.dart new file mode 100644 index 000000000..7135be758 --- /dev/null +++ b/lib/screens/ingredients_screen.dart @@ -0,0 +1,96 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/core/wide_screen_wrapper.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/providers/ingredient_filters_notifier.dart'; +import 'package:wger/providers/ingredient_notifier.dart'; +import 'package:wger/widgets/core/progress_indicator.dart'; +import 'package:wger/widgets/nutrition/ingredient_filter_row.dart'; +import 'package:wger/widgets/nutrition/ingredient_list_tile.dart'; + +class IngredientsScreen extends ConsumerStatefulWidget { + const IngredientsScreen({super.key}); + + static const routeName = '/ingredients'; + + @override + ConsumerState createState() => _IngredientsScreenState(); +} + +class _IngredientsScreenState extends ConsumerState { + @override + Widget build(BuildContext context) { + // With no search term the overview shows the full locally-synced list + // (reactive Drift stream); once the user types, it switches to the + // online/offline search results. + final hasSearchTerm = ref.watch( + ingredientFiltersSyncProvider.select((f) => f.searchTerm.isNotEmpty), + ); + final AsyncValue> ingredientsAsync = hasSearchTerm + ? ref.watch(searchedIngredientsProvider) + : ref.watch(ingredientProvider); + final i18n = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + title: Text( + '${i18n.ingredient}' + 's', + ), + ), + body: WidescreenWrapper( + child: Column( + children: [ + const IngredientFilterRow(), + Expanded( + child: ingredientsAsync.when( + data: (list) => _IngredientsList( + ingredientList: list, + ), + + loading: () => const Center(child: CenteredProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ), + ); + } +} + +class _IngredientsList extends StatelessWidget { + const _IngredientsList({required this.ingredientList}); + + final List ingredientList; + + @override + Widget build(BuildContext context) { + return ListView.separated( + separatorBuilder: (context, index) { + return const Divider(thickness: 1); + }, + itemCount: ingredientList.length, + itemBuilder: (context, index) => IngredientListTile(ingredient: ingredientList[index]), + ); + } +} diff --git a/lib/screens/nutritional_plans_screen.dart b/lib/screens/nutritional_plans_screen.dart index 23ff56570..e38c4ff5c 100644 --- a/lib/screens/nutritional_plans_screen.dart +++ b/lib/screens/nutritional_plans_screen.dart @@ -21,10 +21,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wger/core/wide_screen_wrapper.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/screens/form_screen.dart'; -import 'package:wger/widgets/core/app_bar.dart'; +import 'package:wger/screens/ingredients_screen.dart'; import 'package:wger/widgets/nutrition/forms.dart'; import 'package:wger/widgets/nutrition/nutritional_plans_list.dart'; +enum _NutritionalPlansAppBarOptions { + list, +} + class NutritionalPlansScreen extends ConsumerWidget { const NutritionalPlansScreen(); @@ -33,7 +37,28 @@ class NutritionalPlansScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Scaffold( - appBar: EmptyAppBar(AppLocalizations.of(context).nutritionalPlans), + appBar: AppBar( + title: Text(AppLocalizations.of(context).nutritionalPlans), + actions: [ + PopupMenuButton( + itemBuilder: (context) { + return [ + const PopupMenuItem<_NutritionalPlansAppBarOptions>( + value: _NutritionalPlansAppBarOptions.list, + child: Text('ingredient list'), // TODO: i10n + ), + ]; + }, + onSelected: (value) { + switch (value) { + case _NutritionalPlansAppBarOptions.list: + Navigator.of(context).pushNamed(IngredientsScreen.routeName); + break; + } + }, + ), + ], + ), floatingActionButton: FloatingActionButton( onPressed: () { Navigator.pushNamed( diff --git a/lib/widgets/nutrition/ingredient_detail.dart b/lib/widgets/nutrition/ingredient_detail.dart new file mode 100644 index 000000000..6fb751ec4 --- /dev/null +++ b/lib/widgets/nutrition/ingredient_detail.dart @@ -0,0 +1,161 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/theme/theme.dart'; +import 'package:wger/widgets/nutrition/ingredient_images.dart'; + +class IngredientDetail extends StatelessWidget { + final Ingredient _ingredient; + static const PADDING = 9.0; + + const IngredientDetail(this._ingredient, {super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.all(PADDING), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image + ...getImage(), + const SizedBox(height: 8), + + // Dietary Tags & Nutri-Score + getBadges(context), + const SizedBox(height: 16), + + // Nutritional Info per 100g + ...getNutritionInfo(context), + + const SizedBox(height: 20), + ], + ), + ), + ); + } + + List getImage() { + final List out = []; + if (_ingredient.image != null) { + out.add( + Center( + child: IngredientImageWidget( + image: _ingredient.image, + height: 250, + ), + ), + ); + out.add(const SizedBox(height: PADDING)); + } + return out; + } + + Widget getBadges(BuildContext context) { + final List out = []; + + if (_ingredient.isVegan == true) { + out.add(_buildColoredChip('Vegan', wgerTertiaryColor, Colors.white)); + } else if (_ingredient.isVegetarian == true) { + out.add(_buildColoredChip('Vegetarian', wgerPrimaryColorLight, wgerPrimaryColor)); + } + + if (_ingredient.nutriscore != null) { + out.add( + _buildColoredChip( + 'Nutri-Score: ${_ingredient.nutriscore!.name.toUpperCase()}', + wgerPrimaryColor.withAlpha(200), + Colors.white, + ), + ); + } + + return Wrap(spacing: 8, runSpacing: 8, children: out); + } + + Widget _buildColoredChip(String label, Color bg, Color text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle(color: text, fontWeight: FontWeight.bold, fontSize: 12), + ), + ); + } + + List getNutritionInfo(BuildContext context) { + final List out = []; + final macros = _ingredient.nutritionalValues; + + out.add( + Text( + 'Nutritional Values (per 100g)', + style: Theme.of(context).textTheme.headlineSmall, + ), + ); + out.add(const SizedBox(height: PADDING)); + + // Build the macro list leveraging the model's getters + out.add(_buildNutritionRow('Energy', '${macros.energy} kJ')); + out.add(_buildNutritionRow('Protein', '${macros.protein} g')); + out.add(_buildNutritionRow('Carbohydrates', '${macros.carbohydrates} g')); + if (macros.carbohydratesSugar > 0) { + out.add(_buildNutritionRow(' ↳ Sugars', '${macros.carbohydratesSugar} g', isSubItem: true)); + } + out.add(_buildNutritionRow('Fat', '${macros.fat} g')); + if (macros.fatSaturated > 0) { + out.add(_buildNutritionRow(' ↳ Saturated', '${macros.fatSaturated} g', isSubItem: true)); + } + out.add(_buildNutritionRow('Fiber', '${macros.fiber} g')); + out.add(_buildNutritionRow('Sodium', '${macros.sodium} g')); + + out.add(const SizedBox(height: PADDING)); + return out; + } + + Widget _buildNutritionRow(String label, String value, {bool isSubItem = false}) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: isSubItem ? 16.0 : 0.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: isSubItem ? Colors.grey[600] : null, + fontWeight: isSubItem ? FontWeight.normal : FontWeight.w500, + ), + ), + Text(value), + ], + ), + ); + } +} diff --git a/lib/widgets/nutrition/ingredient_dialogs.dart b/lib/widgets/nutrition/ingredient_dialogs.dart index 0d9f9ba38..1d49f3bf5 100644 --- a/lib/widgets/nutrition/ingredient_dialogs.dart +++ b/lib/widgets/nutrition/ingredient_dialogs.dart @@ -118,7 +118,7 @@ class IngredientDetails extends StatelessWidget { ), ), const SizedBox(height: 12), - _DietaryInfoSection(ingredient: ingredient), + DietaryInfoSection(ingredient: ingredient), if (ingredient.licenseObjectURl == null) Text('Source: $source') else @@ -283,10 +283,10 @@ class IngredientScanResultDialog extends StatelessWidget { ); } -class _DietaryInfoSection extends StatelessWidget { +class DietaryInfoSection extends StatelessWidget { final Ingredient ingredient; - const _DietaryInfoSection({required this.ingredient}); + const DietaryInfoSection({required this.ingredient}); @override Widget build(BuildContext context) { diff --git a/lib/widgets/nutrition/ingredient_filter_row.dart b/lib/widgets/nutrition/ingredient_filter_row.dart new file mode 100644 index 000000000..1c8689fda --- /dev/null +++ b/lib/widgets/nutrition/ingredient_filter_row.dart @@ -0,0 +1,108 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/ingredient_filters_notifier.dart'; +import 'package:wger/widgets/nutrition/ingredient_filter_dialog.dart'; + +class IngredientFilterRow extends ConsumerStatefulWidget { + const IngredientFilterRow({super.key}); + + @override + _IngredientFilterRowState createState() => _IngredientFilterRowState(); +} + +class _IngredientFilterRowState extends ConsumerState { + late final TextEditingController _ingredientNameController; + + @override + void initState() { + super.initState(); + + final initialSearch = ref.read(ingredientFiltersSyncProvider).searchTerm; + + _ingredientNameController = TextEditingController(text: initialSearch) + ..addListener(() { + final text = _ingredientNameController.text; + final currentFilters = ref.read(ingredientFiltersSyncProvider); + if (currentFilters.searchTerm != text) { + ref.read(ingredientFiltersProvider.notifier).setSearchTerm(text); + } + }); + } + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + final currentFilters = ref.watch(ingredientFiltersSyncProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 15), + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _ingredientNameController, + decoration: InputDecoration( + hintText: '${i18n.searchIngredient}...', + prefixIcon: const Icon(Icons.search), + contentPadding: const EdgeInsets.symmetric(horizontal: 10), + border: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.black), + ), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (currentFilters.searchTerm.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: i18n.clearSearchTerm, + onPressed: () { + ref.read(ingredientFiltersProvider.notifier).setSearchTerm(''); + _ingredientNameController.clear(); + }, + ), + ], + ), + ), + ), + ), + Row( + children: [ + IconButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const IngredientFilterDialog(), + ), + icon: const Icon(Icons.filter_alt), + ), + ], + ), + ], + ), + ); + } + + @override + void dispose() { + _ingredientNameController.dispose(); + super.dispose(); + } +} diff --git a/lib/widgets/nutrition/ingredient_images.dart b/lib/widgets/nutrition/ingredient_images.dart new file mode 100644 index 000000000..9aa5cd1aa --- /dev/null +++ b/lib/widgets/nutrition/ingredient_images.dart @@ -0,0 +1,51 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/models/nutrition/ingredient_image.dart'; +import 'package:wger/widgets/core/wger_image.dart'; + +/// Renders an ingredient image using WgerImage for disk + memory caching, falling back to +/// a tinted asset placeholder when [image] is null or the network load +/// fails. Optional [height] constrains the rendered box; width is always +/// derived from the parent. +class IngredientImageWidget extends StatelessWidget { + const IngredientImageWidget({super.key, this.image, this.height}); + + final IngredientImage? image; + final double? height; + + static const _placeholder = Image( + image: AssetImage('assets/images/placeholder.png'), + color: Color.fromRGBO(255, 255, 255, 0.3), + colorBlendMode: BlendMode.modulate, + semanticLabel: 'Placeholder', + ); + + @override + Widget build(BuildContext context) { + return WgerImage( + mediaPath: image?.image, + height: height, + // No width — let the parent decide, matching the previous Image.network + // behaviour where the natural aspect ratio drove sizing. + fit: BoxFit.contain, + errorWidget: _placeholder, + ); + } +} diff --git a/lib/widgets/nutrition/ingredient_list_tile.dart b/lib/widgets/nutrition/ingredient_list_tile.dart new file mode 100644 index 000000000..dd7055def --- /dev/null +++ b/lib/widgets/nutrition/ingredient_list_tile.dart @@ -0,0 +1,64 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (c) 2025 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:flutter/material.dart'; +import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/screens/ingredient_detail_screen.dart'; +import 'package:wger/widgets/nutrition/ingredient_images.dart'; + +class IngredientListTile extends StatelessWidget { + const IngredientListTile({super.key, required this.ingredient}); + + final Ingredient ingredient; + + @override + Widget build(BuildContext context) { + const double IMG_SIZE = 60; + final String macros = + 'P: ${ingredient.protein}g / C: ${ingredient.carbohydrates}g / F: ${ingredient.fat}g'; + + return ListTile( + leading: SizedBox( + height: IMG_SIZE, + width: IMG_SIZE, + child: CircleAvatar( + backgroundColor: const Color(0x00ffffff), + child: ClipOval( + child: SizedBox( + height: IMG_SIZE, + width: IMG_SIZE, + child: IngredientImageWidget(image: ingredient.image), + ), + ), + ), + ), + title: Text(ingredient.name, overflow: TextOverflow.ellipsis, maxLines: 2), + subtitle: Text( + '${ingredient.energy} kJ • $macros', + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Navigator.pushNamed( + context, + IngredientDetailScreen.routeName, + arguments: ingredient, + ); + }, + ); + } +} diff --git a/test/nutrition/ingredient_notifier_test.dart b/test/nutrition/ingredient_notifier_test.dart index 9b87acf4b..5957830bb 100644 --- a/test/nutrition/ingredient_notifier_test.dart +++ b/test/nutrition/ingredient_notifier_test.dart @@ -36,6 +36,10 @@ void main() { setUp(() { mockRepo = MockIngredientRepository(); + // build() subscribes to this stream. The fetch/searchIngredient tests + // only need _repo, which build() assigns synchronously, so the stream + // contents are irrelevant here. + when(mockRepo.watchAllDrift()).thenAnswer((_) => Stream.value(const [])); }); ProviderContainer makeContainer({bool isOnline = true}) { @@ -69,8 +73,6 @@ void main() { final apple = makeIngredient(1, 'Apple'); when(mockRepo.getById(1)).thenAnswer((_) async => apple); final container = makeContainer(); - // build() must complete before we can call methods on the notifier. - await container.read(ingredientProvider.future); final result = await container.read(ingredientProvider.notifier).fetch(1); @@ -81,7 +83,6 @@ void main() { test('returns null and caches when the repo returns null', () async { when(mockRepo.getById(99)).thenAnswer((_) async => null); final container = makeContainer(); - await container.read(ingredientProvider.future); final notifier = container.read(ingredientProvider.notifier); expect(await notifier.fetch(99), isNull); @@ -94,7 +95,6 @@ void main() { final apple = makeIngredient(1, 'Apple'); when(mockRepo.getById(1)).thenAnswer((_) async => apple); final container = makeContainer(); - await container.read(ingredientProvider.future); final notifier = container.read(ingredientProvider.notifier); await notifier.fetch(1); @@ -109,7 +109,6 @@ void main() { final completer = Completer(); when(mockRepo.getById(1)).thenAnswer((_) => completer.future); final container = makeContainer(); - await container.read(ingredientProvider.future); final notifier = container.read(ingredientProvider.notifier); final f1 = notifier.fetch(1); @@ -136,8 +135,6 @@ void main() { ), ).thenAnswer((_) async => [makeIngredient(1, 'Apple')]); final container = makeContainer(isOnline: true); - await container.read(ingredientProvider.future); - final result = await container .read(ingredientProvider.notifier) .searchIngredient( @@ -180,8 +177,6 @@ void main() { ), ).thenAnswer((_) async => [makeIngredient(2, 'Tofu')]); final container = makeContainer(isOnline: false); - await container.read(ingredientProvider.future); - final result = await container .read(ingredientProvider.notifier) .searchIngredient( diff --git a/test/nutrition/ingredient_notifier_test.mocks.dart b/test/nutrition/ingredient_notifier_test.mocks.dart index 1d19bfae1..7d1bcf084 100644 --- a/test/nutrition/ingredient_notifier_test.mocks.dart +++ b/test/nutrition/ingredient_notifier_test.mocks.dart @@ -41,6 +41,14 @@ class MockIngredientRepository extends _i1.Mock implements _i2.IngredientReposit ) as _i3.Stream<_i4.Ingredient?>); + @override + _i3.Stream> watchAllDrift() => + (super.noSuchMethod( + Invocation.method(#watchAllDrift, []), + returnValue: _i3.Stream>.empty(), + ) + as _i3.Stream>); + @override _i3.Future<_i4.Ingredient?> getById(int? id) => (super.noSuchMethod(