Merge pull request #613 from wger-project/ingredient-details

popup ingredient details in typeahead suggestions and recently used
This commit is contained in:
Dieter Plaetinck
2024-07-18 17:30:27 +02:00
committed by GitHub
24 changed files with 3039 additions and 405 deletions

View File

@@ -24,9 +24,27 @@ part 'ingredient.g.dart';
@JsonSerializable()
class Ingredient {
// fields returned by django api that we ignore here:
// uuid, last_updated, last_imported, weight_units, language
// most license fields
@JsonKey(required: true)
final int id;
// some ingredients don't have these 3 fields set. E.g. USDA entries that
// have been removed upstream, or manually added ingredients.
@JsonKey(required: true, name: 'remote_id')
final String? remoteId;
@JsonKey(required: true, name: 'source_name')
final String? sourceName;
@JsonKey(required: true, name: 'source_url')
final String? sourceUrl;
@JsonKey(required: true, name: 'license_object_url')
final String? licenseObjectURl;
/// Barcode of the product
@JsonKey(required: true)
final String? code;
@@ -73,6 +91,10 @@ class Ingredient {
IngredientImage? image;
Ingredient({
required this.remoteId,
required this.sourceName,
required this.sourceUrl,
this.licenseObjectURl,
required this.id,
required this.code,
required this.name,

View File

@@ -11,6 +11,10 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
json,
requiredKeys: const [
'id',
'remote_id',
'source_name',
'source_url',
'license_object_url',
'code',
'name',
'created',
@@ -25,6 +29,10 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
],
);
return Ingredient(
remoteId: json['remote_id'] as String?,
sourceName: json['source_name'] as String?,
sourceUrl: json['source_url'] as String?,
licenseObjectURl: json['license_object_url'] as String?,
id: (json['id'] as num).toInt(),
code: json['code'] as String?,
name: json['name'] as String,
@@ -46,6 +54,10 @@ Ingredient _$IngredientFromJson(Map<String, dynamic> json) {
Map<String, dynamic> _$IngredientToJson(Ingredient instance) =>
<String, dynamic>{
'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(),

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:wger/models/nutrition/nutritional_goals.dart';
class NutritionalValues {
double energy = 0;
double protein = 0;
@@ -114,6 +116,19 @@ class NutritionalValues {
return 'e: $energy, p: $protein, c: $carbohydrates, cS: $carbohydratesSugar, f: $fat, fS: $fatSaturated, fi: $fiber, s: $sodium';
}
NutritionalGoals toGoals() {
return NutritionalGoals(
energy: energy,
protein: protein,
carbohydrates: carbohydrates,
carbohydratesSugar: carbohydratesSugar,
fat: fat,
fatSaturated: fatSaturated,
fiber: fiber,
sodium: sodium,
);
}
@override
//ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hash(

View File

@@ -38,10 +38,9 @@ class NutritionPlansProvider with ChangeNotifier {
static const _nutritionalPlansInfoPath = 'nutritionplaninfo';
static const _mealPath = 'meal';
static const _mealItemPath = 'mealitem';
static const _ingredientPath = 'ingredient';
static const _ingredientInfoPath = 'ingredientinfo';
static const _ingredientSearchPath = 'ingredient/search';
static const _nutritionDiaryPath = 'nutritiondiary';
static const _ingredientImagePath = 'ingredient-image';
final WgerBaseProvider baseProvider;
List<NutritionalPlan> _plans = [];
@@ -131,6 +130,7 @@ class NutritionPlansProvider with ChangeNotifier {
try {
plan = findById(planId);
} on NoSuchEntryException {
// TODO: remove this useless call, because we will fetch all details below
plan = await fetchAndSetPlanSparse(planId);
}
@@ -144,6 +144,7 @@ class NutritionPlansProvider with ChangeNotifier {
final List<MealItem> mealItems = [];
final meal = Meal.fromJson(mealData);
// TODO: we should add these ingredients to the ingredient cache
for (final mealItemData in mealData['meal_items']) {
final mealItem = MealItem.fromJson(mealItemData);
@@ -298,7 +299,7 @@ class NutritionPlansProvider with ChangeNotifier {
// Get ingredient from the server and save to cache
} on StateError {
final data = await baseProvider.fetch(
baseProvider.makeUrl(_ingredientPath, id: ingredientId),
baseProvider.makeUrl(_ingredientInfoPath, id: ingredientId),
);
ingredient = Ingredient.fromJson(data);
_ingredients.add(ingredient);
@@ -370,14 +371,15 @@ class NutritionPlansProvider with ChangeNotifier {
// Send the request
final data = await baseProvider.fetch(
baseProvider.makeUrl(_ingredientPath, query: {'code': code}),
baseProvider.makeUrl(_ingredientInfoPath, query: {'code': code}),
);
if (data['count'] == 0) {
return null;
} else {
return Ingredient.fromJson(data['results'][0]);
}
// TODO we should probably add it to ingredient cache.
// TODO: we could also use the ingredient cache for code searches
return Ingredient.fromJson(data['results'][0]);
}
/// Log meal to nutrition diary

View File

@@ -94,7 +94,7 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
exercisesProvider.fetchAndSetInitialData(),
]);
} catch (e) {
log('fire! fire!');
log('Exception loading base data');
log(e.toString());
}
@@ -109,7 +109,7 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
measurementProvider.fetchAndSetAllCategoriesAndEntries(),
]);
} catch (e) {
log('fire! fire!');
log('Exception loading plans, weight, measurements and gallery');
log(e.toString());
}
@@ -121,7 +121,7 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
await nutritionPlansProvider.fetchAndSetPlanFull(plan.id!);
}
} catch (e) {
log('fire! fire!');
log('Exception loading current nutritional plan');
log(e.toString());
}

View File

@@ -353,7 +353,7 @@ class IngredientFormState extends State<IngredientForm> {
child: Column(
children: [
Text(
'Macros preview',
'Macros preview', // TODO fix l10n
style: Theme.of(context).textTheme.titleMedium,
),
FutureBuilder<Ingredient>(
@@ -430,16 +430,18 @@ class IngredientFormState extends State<IngredientForm> {
itemCount: suggestions.length,
shrinkWrap: true,
itemBuilder: (context, index) {
void select() {
final ingredient = suggestions[index].ingredient;
selectIngredient(
ingredient.id,
ingredient.name,
suggestions[index].amount,
);
}
return Card(
child: ListTile(
onTap: () {
final ingredient = suggestions[index].ingredient;
selectIngredient(
ingredient.id,
ingredient.name,
suggestions[index].amount,
);
},
onTap: select,
title: Text(
'${suggestions[index].ingredient.name} (${suggestions[index].amount.toStringAsFixed(0)}$unit)',
),
@@ -447,7 +449,23 @@ class IngredientFormState extends State<IngredientForm> {
suggestions[index].ingredient.nutritionalValues,
context,
)),
trailing: const Icon(Icons.copy),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showIngredientDetails(
context,
suggestions[index].ingredient.id,
select: select,
);
},
),
const SizedBox(width: 5),
const Icon(Icons.copy),
],
),
),
);
},

View File

@@ -16,12 +16,15 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/models/nutrition/meal.dart';
import 'package:wger/models/nutrition/nutritional_values.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/core/core.dart';
import 'package:wger/widgets/nutrition/ingredient_dialogs.dart';
List<String> getNutritionColumnNames(BuildContext context) => [
AppLocalizations.of(context).energy,
@@ -95,3 +98,15 @@ String getKcalConsumedVsPlanned(Meal meal, BuildContext context) {
return '${consumed.toStringAsFixed(0)} / ${planned.toStringAsFixed(0)} ${loc.kcal}';
}
void showIngredientDetails(BuildContext context, int id, {void Function()? select}) {
showDialog(
context: context,
builder: (context) => FutureBuilder<Ingredient>(
future: Provider.of<NutritionPlansProvider>(context, listen: false).fetchIngredient(id),
builder: (BuildContext context, AsyncSnapshot<Ingredient> snapshot) {
return IngredientDetails(snapshot, select: select);
},
),
);
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:wger/helpers/misc.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/models/nutrition/nutritional_goals.dart';
import 'package:wger/widgets/nutrition/macro_nutrients_table.dart';
Widget ingredientImage(String url, BuildContext context) {
var radius = 100.0;
final height = MediaQuery.sizeOf(context).height;
final width = MediaQuery.sizeOf(context).width;
final smallest = height < width ? height : width;
if (smallest < 400) {
radius = smallest / 4;
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: CircleAvatar(backgroundImage: NetworkImage(url), radius: radius),
);
}
class IngredientDetails extends StatelessWidget {
final AsyncSnapshot<Ingredient> snapshot;
final void Function()? select;
const IngredientDetails(this.snapshot, {super.key, this.select});
@override
Widget build(BuildContext context) {
Ingredient? ingredient;
NutritionalGoals? goals;
String? source;
if (snapshot.hasData) {
ingredient = snapshot.data;
goals = ingredient!.nutritionalValues.toGoals();
source = ingredient.sourceName ?? 'unknown';
}
return AlertDialog(
title: (snapshot.hasData) ? Text(ingredient!.name) : null,
content: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (snapshot.hasError)
Text(
'Ingredient lookup error: ${snapshot.error ?? 'unknown error'}',
style: const TextStyle(color: Colors.red),
),
if (ingredient?.image?.image != null)
ingredientImage(ingredient!.image!.image, context),
if (!snapshot.hasData && !snapshot.hasError) const CircularProgressIndicator(),
if (snapshot.hasData)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 400),
child: MacronutrientsTable(
nutritionalGoals: goals!,
plannedValuesPercentage: goals.energyPercentage(),
showGperKg: false,
),
),
if (snapshot.hasData && ingredient!.licenseObjectURl == null)
Text('Source: ${source!}'),
if (snapshot.hasData && ingredient!.licenseObjectURl != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: InkWell(
child: Text('Source: ${source!}'),
onTap: () => launchURL(ingredient!.licenseObjectURl!, context),
),
),
],
),
),
),
actions: [
if (snapshot.hasData && select != null)
TextButton(
key: const Key('ingredient-details-continue-button'),
child: Text(MaterialLocalizations.of(context).continueButtonLabel),
onPressed: () {
select!();
Navigator.of(context).pop();
},
),
TextButton(
key: const Key('ingredient-details-close-button'),
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}
class IngredientScanResultDialog extends StatelessWidget {
final AsyncSnapshot<Ingredient?> snapshot;
final String barcode;
final Function(int id, String name, num? amount) selectIngredient;
const IngredientScanResultDialog(this.snapshot, this.barcode, this.selectIngredient, {super.key});
@override
Widget build(BuildContext context) {
Ingredient? ingredient;
NutritionalGoals? goals;
String? title;
String? source;
if (snapshot.connectionState == ConnectionState.done) {
ingredient = snapshot.data;
title = ingredient != null
? AppLocalizations.of(context).productFound
: AppLocalizations.of(context).productNotFound;
if (ingredient != null) {
goals = ingredient.nutritionalValues.toGoals();
source = ingredient.sourceName ?? 'unknown';
}
}
return AlertDialog(
key: const Key('ingredient-scan-result-dialog'),
title: title != null ? Text(title) : null,
content: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (snapshot.hasError)
Text(
'Ingredient lookup error: ${snapshot.error ?? 'unknown error'}',
style: const TextStyle(color: Colors.red),
),
if (snapshot.connectionState == ConnectionState.done && ingredient == null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
AppLocalizations.of(context).productNotFoundDescription(barcode),
),
),
if (ingredient != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child:
Text(AppLocalizations.of(context).productFoundDescription(ingredient.name)),
),
if (ingredient?.image?.image != null)
ingredientImage(ingredient!.image!.image, context),
if (snapshot.connectionState != ConnectionState.done && !snapshot.hasError)
const CircularProgressIndicator(),
if (goals != null)
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 400),
child: MacronutrientsTable(
nutritionalGoals: goals,
plannedValuesPercentage: goals.energyPercentage(),
showGperKg: false,
),
),
if (ingredient != null && ingredient.licenseObjectURl == null)
Text('Source: ${source!}'),
if (ingredient?.licenseObjectURl != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: InkWell(
child: Text('Source: ${source!}'),
onTap: () => launchURL(ingredient!.licenseObjectURl!, context),
),
),
],
),
),
),
actions: [
if (ingredient != null) // if barcode matched
TextButton(
key: const Key('ingredient-scan-result-dialog-confirm-button'),
child: Text(MaterialLocalizations.of(context).continueButtonLabel),
onPressed: () {
selectIngredient(ingredient!.id, ingredient.name, null);
Navigator.of(context).pop();
},
),
// if didn't match, or we're still waiting
TextButton(
key: const Key('ingredient-scan-result-dialog-close-button'),
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@@ -7,13 +7,15 @@ class MacronutrientsTable extends StatelessWidget {
super.key,
required this.nutritionalGoals,
required this.plannedValuesPercentage,
required this.nutritionalGoalsGperKg,
this.nutritionalGoalsGperKg,
this.showGperKg = true,
});
static const double tablePadding = 7;
final NutritionalGoals nutritionalGoals;
final NutritionalGoals plannedValuesPercentage;
final NutritionalGoals? nutritionalGoalsGperKg;
final bool showGperKg;
@override
Widget build(BuildContext context) {
@@ -42,7 +44,8 @@ class MacronutrientsTable extends StatelessWidget {
),
Text(goal != null ? valFn(goal.toStringAsFixed(0)) : '', textAlign: TextAlign.right),
Text(pct != null ? pct.toStringAsFixed(1) : '', textAlign: TextAlign.right),
Text(perkg != null ? perkg.toStringAsFixed(1) : '', textAlign: TextAlign.right),
if (showGperKg)
Text(perkg != null ? perkg.toStringAsFixed(1) : '', textAlign: TextAlign.right),
],
);
}
@@ -62,7 +65,7 @@ class MacronutrientsTable extends StatelessWidget {
columnHeader(true, loc.macronutrients),
columnHeader(false, loc.total),
columnHeader(false, loc.percentEnergy),
columnHeader(false, loc.gPerBodyKg),
if (showGperKg) columnHeader(false, loc.gPerBodyKg),
],
),
macroRow(0, false, loc.energy, (NutritionalGoals ng) => ng.energy),

View File

@@ -13,7 +13,7 @@ import 'package:wger/widgets/nutrition/nutrition_tile.dart';
import 'package:wger/widgets/nutrition/widgets.dart';
/// a NutritionTitle showing an ingredient, with its
/// avatar and nutritional values
/// avatar, nutritional values and button to popup its details
class MealItemValuesTile extends StatelessWidget {
final Ingredient ingredient;
final NutritionalValues nutritionalValues;
@@ -29,6 +29,15 @@ class MealItemValuesTile extends StatelessWidget {
return NutritionTile(
leading: IngredientAvatar(ingredient: ingredient),
title: Text(getShortNutritionValues(nutritionalValues, context)),
trailing: IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showIngredientDetails(
context,
ingredient.id,
);
},
),
);
}
}

View File

@@ -31,7 +31,8 @@ import 'package:wger/models/exercises/ingredient_api.dart';
import 'package:wger/models/nutrition/ingredient.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/widgets/core/core.dart';
import 'package:wger/widgets/nutrition/nutrition_tiles.dart';
import 'package:wger/widgets/nutrition/helpers.dart';
import 'package:wger/widgets/nutrition/ingredient_dialogs.dart';
class ScanReader extends StatelessWidget {
const ScanReader();
@@ -159,6 +160,18 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
),
title: Text(suggestion.value),
// subtitle: Text(suggestion.data.id.toString()),
trailing: IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showIngredientDetails(
context,
suggestion.data.id,
select: () {
widget.selectIngredient(suggestion.data.id, suggestion.value, null);
},
);
},
),
);
},
transitionBuilder: (context, animation, child) => FadeTransition(
@@ -193,87 +206,24 @@ class _IngredientTypeaheadState extends State<IngredientTypeahead> {
if (!widget.test!) {
barcode = await readerscan(context);
}
if (barcode.isNotEmpty) {
if (!mounted) {
return;
}
final result = await Provider.of<NutritionPlansProvider>(
context,
listen: false,
).searchIngredientWithCode(barcode);
// TODO: show spinner...
if (!mounted) {
return;
}
if (result != null) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
key: const Key('found-dialog'),
title: Text(AppLocalizations.of(context).productFound),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context).productFoundDescription(result.name)),
MealItemValuesTile(
ingredient: result,
nutritionalValues: result.nutritionalValues,
),
],
),
actions: [
TextButton(
key: const Key('found-dialog-confirm-button'),
child: Text(MaterialLocalizations.of(context).continueButtonLabel),
onPressed: () {
widget.selectIngredient(result.id, result.name, null);
Navigator.of(ctx).pop();
},
),
TextButton(
key: const Key('found-dialog-close-button'),
child: Text(
MaterialLocalizations.of(context).closeButtonLabel,
),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
} else {
//nothing is matching barcode
showDialog(
context: context,
builder: (ctx) => AlertDialog(
key: const Key('notFound-dialog'),
title: Text(AppLocalizations.of(context).productNotFound),
content: Text(
AppLocalizations.of(context).productNotFoundDescription(barcode),
),
actions: [
TextButton(
key: const Key('notFound-dialog-close-button'),
child: Text(
MaterialLocalizations.of(context).closeButtonLabel,
),
onPressed: () {
Navigator.of(ctx).pop();
},
),
],
),
);
}
}
} catch (e) {
if (mounted) {
showErrorDialog(e, context);
}
}
if (!mounted) {
return;
}
showDialog(
context: context,
builder: (context) => FutureBuilder<Ingredient?>(
future: Provider.of<NutritionPlansProvider>(context, listen: false)
.searchIngredientWithCode(barcode),
builder: (BuildContext context, AsyncSnapshot<Ingredient?> snapshot) {
return IngredientScanResultDialog(snapshot, barcode, widget.selectIngredient);
},
),
);
},
);
}

View File

@@ -1,18 +0,0 @@
{
"id": 10065,
"code": "0043647440020",
"name": "'Old Times' Orange Fine Cut Marmalade",
"created": "2020-12-20T01:00:00+01:00",
"last_update": "2022-08-09T10:23:11+02:00",
"energy": 269,
"protein": "0.000",
"carbohydrates": "67.000",
"carbohydrates_sugar": "66.000",
"fat": "0.000",
"fat_saturated": "0.000",
"fiber": null,
"sodium": "0.000",
"license": 5,
"license_author": "Open Food Facts",
"language": 2
}

View File

@@ -1,18 +0,0 @@
{
"id": 58300,
"code": "4071800000992",
"name": "1688 Mehrkorn",
"created": "2020-12-20T01:00:00+02:00",
"last_update": "2022-08-09T18:55:00+02:00",
"energy": 229,
"protein": "7.680",
"carbohydrates": "35.700",
"carbohydrates_sugar": "2.320",
"fat": "4.820",
"fat_saturated": "0.714",
"fiber": "6.960",
"sodium": "0.000",
"license": 5,
"license_author": "Open Food Facts",
"language": 2
}

View File

@@ -1,18 +0,0 @@
{
"id": 59887,
"code": "4311501354155",
"name": "Baked Beans",
"created": "2020-12-20T23:10:54+01:00",
"last_update": "2022-08-09T13:32:41+01:00",
"energy": 86,
"protein": "4.400",
"carbohydrates": "11.000",
"carbohydrates_sugar": "5.300",
"fat": "0.100",
"fat_saturated": "0.000",
"fiber": "5.000",
"sodium": "0.480",
"license": 5,
"license_author": "Open Food Facts",
"language": 1
}

View File

@@ -0,0 +1,56 @@
{
"id": 10065,
"uuid": "cd134608-3fbf-4d54-bf9d-ecc56e4271f2",
"remote_id": "0043647440020",
"source_name": "Open Food Facts",
"source_url": "https://world.openfoodfacts.org/api/v2/product/0043647440020.json",
"code": "0043647440020",
"name": "'Old Times' Orange Fine Cut Marmalade",
"created": "2020-12-20T01:00:00+01:00",
"last_update": "2024-06-07T06:04:05.211180+02:00",
"last_imported": "2020-12-20T14:44:55.854000+01:00",
"energy": 269,
"protein": "0.000",
"carbohydrates": "67.000",
"carbohydrates_sugar": "66.000",
"fat": "0.000",
"fat_saturated": "0.000",
"fiber": null,
"sodium": "0.000",
"weight_units": [],
"language": {
"id": 2,
"short_name": "en",
"full_name": "English",
"full_name_en": "English"
},
"image": {
"id": 7545,
"uuid": "c7a43097-da62-4a35-95f4-fe466e68cbc6",
"ingredient_id": 10065,
"ingredient_uuid": "cd134608-3fbf-4d54-bf9d-ecc56e4271f2",
"image": "https://wger.de/media/ingredients/10065/c7a43097-da62-4a35-95f4-fe466e68cbc6.jpg",
"created": "2023-04-21T20:13:01.136749+02:00",
"last_update": "2023-04-21T20:13:01.151137+02:00",
"size": 43397,
"width": 219,
"height": 400,
"license": 1,
"license_title": "Photo",
"license_object_url": "https://world.openfoodfacts.org/cgi/product_image.pl?code=0043647440020&id=2",
"license_author": "tacinte",
"license_author_url": "https://world.openfoodfacts.org/photographer/tacinte",
"license_derivative_source_url": ""
},
"license": {
"id": 5,
"full_name": "Open Data Commons Open Database License",
"short_name": "ODbL",
"url": "https://opendatacommons.org/licenses/odbl/"
},
"license_title": "'Old Times' Orange Fine Cut Marmalade",
"license_object_url": "",
"license_author": "",
"license_author_url": "",
"license_derivative_source_url": ""
}

View File

@@ -0,0 +1,56 @@
{
"id": 58300,
"uuid": "151a8d44-735e-4f9b-9e22-7dc4c604154a",
"remote_id": "4071800000992",
"source_name": "Open Food Facts",
"source_url": "https://world.openfoodfacts.org/api/v2/product/4071800000992.json",
"code": "4071800000992",
"name": "1688 Mehrkorn",
"created": "2020-12-20T01:00:00+01:00",
"last_update": "2024-06-07T06:06:48.259950+02:00",
"last_imported": "2020-12-20T14:46:58.971000+01:00",
"energy": 229,
"protein": "7.680",
"carbohydrates": "35.700",
"carbohydrates_sugar": "2.320",
"fat": "4.820",
"fat_saturated": "0.714",
"fiber": "6.960",
"sodium": "0.000",
"weight_units": [],
"language": {
"id": 1,
"short_name": "de",
"full_name": "Deutsch",
"full_name_en": "German"
},
"image": {
"id": 10862,
"uuid": "a5b6766f-b606-451c-b806-24eb3a8597ea",
"ingredient_id": 58300,
"ingredient_uuid": "151a8d44-735e-4f9b-9e22-7dc4c604154a",
"image": "https://wger.de/media/ingredients/58300/a5b6766f-b606-451c-b806-24eb3a8597ea.jpg",
"created": "2023-05-08T11:37:13.876341+02:00",
"last_update": "2023-05-08T11:37:13.891457+02:00",
"size": 33617,
"width": 400,
"height": 225,
"license": 1,
"license_title": "Photo",
"license_object_url": "https://world.openfoodfacts.org/cgi/product_image.pl?code=4071800000992&id=15",
"license_author": "kiliweb",
"license_author_url": "https://world.openfoodfacts.org/photographer/kiliweb",
"license_derivative_source_url": ""
},
"license": {
"id": 5,
"full_name": "Open Data Commons Open Database License",
"short_name": "ODbL",
"url": "https://opendatacommons.org/licenses/odbl/"
},
"license_title": "1688 Mehrkorn",
"license_object_url": "",
"license_author": "",
"license_author_url": "",
"license_derivative_source_url": ""
}

View File

@@ -0,0 +1,56 @@
{
"id": 59887,
"uuid": "2efa101b-d6f7-4755-838d-b3bb5c483b02",
"remote_id": "4311501354155",
"source_name": "Open Food Facts",
"source_url": "https://world.openfoodfacts.org/api/v2/product/4311501354155.json",
"code": "4311501354155",
"name": "Baked Beans",
"created": "2020-12-20T01:00:00+01:00",
"last_update": "2024-06-07T06:40:23.364866+02:00",
"last_imported": "2020-12-20T14:47:03.728000+01:00",
"energy": 86,
"protein": "4.400",
"carbohydrates": "11.000",
"carbohydrates_sugar": "5.300",
"fat": "0.600",
"fat_saturated": "0.100",
"fiber": "9.100",
"sodium": "0.260",
"weight_units": [],
"language": {
"id": 1,
"short_name": "de",
"full_name": "Deutsch",
"full_name_en": "German"
},
"image": {
"id": 1718,
"uuid": "493bc593-4813-4bae-b92a-a4e1fbee5e44",
"ingredient_id": 59887,
"ingredient_uuid": "2efa101b-d6f7-4755-838d-b3bb5c483b02",
"image": "https://wger.de/media/ingredients/59887/493bc593-4813-4bae-b92a-a4e1fbee5e44.jpg",
"created": "2023-04-09T01:45:50.874157+02:00",
"last_update": "2023-04-09T01:45:50.911258+02:00",
"size": 27752,
"width": 286,
"height": 400,
"license": 1,
"license_title": "Photo",
"license_object_url": "https://world.openfoodfacts.org/cgi/product_image.pl?code=4311501354155&id=6",
"license_author": "kiliweb",
"license_author_url": "https://world.openfoodfacts.org/photographer/kiliweb",
"license_derivative_source_url": ""
},
"license": {
"id": 5,
"full_name": "Open Data Commons Open Database License",
"short_name": "ODbL",
"url": "https://opendatacommons.org/licenses/odbl/"
},
"license_title": "Baked Beans",
"license_object_url": "https://world.openfoodfacts.org/product/4311501354155/",
"license_author": "",
"license_author_url": "",
"license_derivative_source_url": ""
}

View File

@@ -0,0 +1,63 @@
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 19733,
"uuid": "354c6050-5654-41bc-8823-4fd27b54fb59",
"remote_id": "3068320105222",
"source_name": "Open Food Facts",
"source_url": "https://world.openfoodfacts.org/api/v2/product/3068320105222.json",
"code": "3068320105222",
"name": "Badoit zest citron vert sans sucres",
"created": "2020-12-20T01:00:00+01:00",
"last_update": "2024-06-07T06:39:29.032545+02:00",
"last_imported": "2020-12-20T14:45:31.370000+01:00",
"energy": 1,
"protein": "0.000",
"carbohydrates": "0.000",
"carbohydrates_sugar": "0.000",
"fat": "0.000",
"fat_saturated": "0.000",
"fiber": null,
"sodium": "0.020",
"weight_units": [],
"language": {
"id": 12,
"short_name": "fr",
"full_name": "Fran\u00e7ais",
"full_name_en": "French"
},
"image": {
"id": 26949,
"uuid": "5177e2d1-b03a-42d9-bdf1-2dd3edad8210",
"ingredient_id": 19733,
"ingredient_uuid": "354c6050-5654-41bc-8823-4fd27b54fb59",
"image": "https://wger.de/media/ingredients/19733/5177e2d1-b03a-42d9-bdf1-2dd3edad8210.jpg",
"created": "2023-12-04T10:48:19.019477+01:00",
"last_update": "2023-12-04T10:48:19.062343+01:00",
"size": 13302,
"width": 112,
"height": 400,
"license": 1,
"license_title": "Photo",
"license_object_url": "https://world.openfoodfacts.org/cgi/product_image.pl?code=3068320105222&id=11",
"license_author": "hungergames",
"license_author_url": "https://world.openfoodfacts.org/photographer/hungergames",
"license_derivative_source_url": ""
},
"license": {
"id": 5,
"full_name": "Open Data Commons Open Database License",
"short_name": "ODbL",
"url": "https://opendatacommons.org/licenses/odbl/"
},
"license_title": "Badoit zest citron vert sans sucres",
"license_object_url": "",
"license_author": "",
"license_author_url": "",
"license_derivative_source_url": ""
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 9436,
"code": "0013087245950",
"name": " Gâteau double chocolat ",
"creation_date": "2020-12-20",
"update_date": "2020-12-20",
"energy": 360,
"protein": "5.000",
"carbohydrates": "45.000",
"carbohydrates_sugar": "27.000",
"fat": "18.000",
"fat_saturated": "4.500",
"fiber": "2.000",
"sodium": "0.356",
"license": 5,
"license_author": "Open Food Facts",
"language": 12
}
]
}

View File

@@ -19,7 +19,6 @@ void main() {
const String planInfoUrl = 'nutritionplaninfo';
const String planUrl = 'nutritionplan';
const String diaryUrl = 'nutritiondiary';
const String ingredientUrl = 'ingredient';
final Map<String, dynamic> nutritionalPlanInfoResponse = jsonDecode(
fixture('nutrition/nutritional_plan_info_detail_response.json'),
@@ -31,13 +30,13 @@ void main() {
fixture('nutrition/nutrition_diary_response.json'),
)['results'];
final Map<String, dynamic> ingredient59887Response = jsonDecode(
fixture('nutrition/ingredient_59887_response.json'),
fixture('nutrition/ingredientinfo_59887.json'),
);
final Map<String, dynamic> ingredient10065Response = jsonDecode(
fixture('nutrition/ingredient_10065_response.json'),
fixture('nutrition/ingredientinfo_10065.json'),
);
final Map<String, dynamic> ingredient58300Response = jsonDecode(
fixture('nutrition/ingredient_58300_response.json'),
fixture('nutrition/ingredientinfo_58300.json'),
);
final ingredientList = [

View File

@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:network_image_mock/network_image_mock.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/models/exercises/ingredient_api.dart';
@@ -26,6 +27,9 @@ import 'nutritional_plan_form_test.mocks.dart';
void main() {
final ingredient = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '123456787',
name: 'Water',
@@ -52,20 +56,20 @@ void main() {
var plan1 = NutritionalPlan.empty();
var meal1 = Meal();
final Uri tUriRightCode = Uri.parse('https://localhost/api/v2/ingredient/?code=123');
final Uri tUriEmptyCode = Uri.parse('https://localhost/api/v2/ingredient/?code="%20"');
final Uri tUriBadCode = Uri.parse('https://localhost/api/v2/ingredient/?code=222');
final Uri tUriRightCode = Uri.parse('https://localhost/api/v2/ingredientinfo/?code=123');
final Uri tUriEmptyCode = Uri.parse('https://localhost/api/v2/ingredientinfo/?code="%20"');
final Uri tUriBadCode = Uri.parse('https://localhost/api/v2/ingredientinfo/?code=222');
when(client.get(tUriRightCode, headers: anyNamed('headers'))).thenAnswer(
(_) => Future.value(http.Response(fixture('nutrition/search_ingredient_right_code.json'), 200)),
(_) => Future.value(http.Response(fixture('nutrition/ingredientinfo_right_code.json'), 200)),
);
when(client.get(tUriEmptyCode, headers: anyNamed('headers'))).thenAnswer(
(_) => Future.value(http.Response(fixture('nutrition/search_ingredient_wrong_code.json'), 200)),
(_) => Future.value(http.Response(fixture('nutrition/ingredientinfo_wrong_code.json'), 200)),
);
when(client.get(tUriBadCode, headers: anyNamed('headers'))).thenAnswer(
(_) => Future.value(http.Response(fixture('nutrition/search_ingredient_wrong_code.json'), 200)),
(_) => Future.value(http.Response(fixture('nutrition/ingredientinfo_wrong_code.json'), 200)),
);
setUp(() {
@@ -124,13 +128,15 @@ void main() {
});
group('Test the AlertDialogs for scanning result', () {
// TODO: why do we need to support empty barcodes?
testWidgets('with empty code', (WidgetTester tester) async {
await tester.pumpWidget(createMealItemFormScreen(meal1, '', true));
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsNothing);
});
testWidgets('with correct code', (WidgetTester tester) async {
@@ -139,7 +145,8 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsOneWidget);
});
testWidgets('with incorrect code', (WidgetTester tester) async {
@@ -148,7 +155,8 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('notFound-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')), findsNothing);
});
});
@@ -218,9 +226,9 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
await tester.tap(find.byKey(const Key('found-dialog-confirm-button')));
await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')));
await tester.pumpAndSettle();
expect(formState.ingredientIdController.text, '1');
@@ -232,12 +240,12 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
await tester.tap(find.byKey(const Key('found-dialog-close-button')));
await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-close-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsNothing);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsNothing);
});
});
@@ -261,9 +269,9 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
await tester.tap(find.byKey(const Key('found-dialog-confirm-button')));
await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
@@ -272,15 +280,16 @@ void main() {
expect(find.text('Please enter a valid number'), findsOneWidget);
});
//TODO: isn't this test just a duplicate of the above one? can be removed?
testWidgets('save ingredient with incorrect weight input type', (WidgetTester tester) async {
await tester.pumpWidget(createMealItemFormScreen(meal1, '123', true));
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
await tester.tap(find.byKey(const Key('found-dialog-confirm-button')));
await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
@@ -298,22 +307,22 @@ void main() {
await tester.tap(find.byKey(const Key('scan-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsOneWidget);
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsOneWidget);
await tester.tap(find.byKey(const Key('found-dialog-confirm-button')));
await tester.tap(find.byKey(const Key('ingredient-scan-result-dialog-confirm-button')));
await tester.pumpAndSettle();
expect(formState.ingredientIdController.text, '1');
// once ID and weight are set, it'll fetchIngredient and show macros preview
when(mockNutrition.fetchIngredient(1)).thenAnswer((_) => Future.value(
Ingredient.fromJson(jsonDecode(fixture('nutrition/ingredient_59887_response.json'))),
));
await tester.enterText(find.byKey(const Key('field-weight')), '2');
await tester.pumpAndSettle();
expect(find.byKey(const Key('found-dialog')), findsNothing);
// once ID and weight are set, it'll fetchIngredient and show macros preview and ingredient image
when(mockNutrition.fetchIngredient(1)).thenAnswer((_) => Future.value(
Ingredient.fromJson(jsonDecode(fixture('nutrition/ingredientinfo_59887.json'))),
));
await mockNetworkImagesFor(() => tester.pumpAndSettle());
expect(find.byKey(const Key('ingredient-scan-result-dialog')), findsNothing);
await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)));
await tester.pumpAndSettle();

View File

@@ -26,6 +26,9 @@ import 'package:wger/models/nutrition/meal_item.dart';
import 'package:wger/models/nutrition/nutritional_plan.dart';
final ingredient1 = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '123456787',
name: 'Water',
@@ -40,6 +43,9 @@ final ingredient1 = Ingredient(
sodium: 0.5,
);
final ingredient2 = Ingredient(
remoteId: '2',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/2',
id: 2,
code: '123456788',
name: 'Burger soup',
@@ -54,6 +60,9 @@ final ingredient2 = Ingredient(
sodium: 0,
);
final ingredient3 = Ingredient(
remoteId: '3',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/3',
id: 3,
code: '123456789',
name: 'Broccoli cake',
@@ -68,6 +77,9 @@ final ingredient3 = Ingredient(
sodium: 10,
);
final muesli = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '123456787',
name: 'Müsli',
@@ -82,6 +94,9 @@ final muesli = Ingredient(
sodium: 0.5,
);
final milk = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '123456787',
name: 'Milk',
@@ -96,6 +111,9 @@ final milk = Ingredient(
sodium: 0.5,
);
final apple = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '123456787',
name: 'Apple',
@@ -110,6 +128,9 @@ final apple = Ingredient(
sodium: 0.5,
);
final cake = Ingredient(
remoteId: '1',
sourceName: 'Built-in testdata',
sourceUrl: 'https://example.com/ingredient/1',
id: 1,
code: '111111111',
name: 'Lemon CAke',