/* * This file is part of wger Workout Manager . * Copyright (c) 2026 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 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/core/locator.dart'; import 'package:wger/database/ingredients/ingredients_database.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/models/nutrition/ingredient.dart'; import 'package:wger/models/nutrition/ingredient_image.dart'; import 'package:wger/models/nutrition/log.dart'; import 'package:wger/models/nutrition/meal.dart'; import 'package:wger/models/nutrition/meal_item.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/base_provider.dart'; class NutritionPlansProvider with ChangeNotifier { final _logger = Logger('NutritionPlansProvider'); static const _nutritionalPlansPath = 'nutritionplan'; static const _nutritionalPlansInfoPath = 'nutritionplaninfo'; static const _mealPath = 'meal'; static const _mealItemPath = 'mealitem'; static const _ingredientInfoPath = 'ingredientinfo'; static const _nutritionDiaryPath = 'nutritiondiary'; final WgerBaseProvider baseProvider; late IngredientDatabase database; List _plans = []; List ingredients = []; NutritionPlansProvider( this.baseProvider, List entries, { IngredientDatabase? database, }) : _plans = entries { this.database = database ?? locator(); } List get items { return [..._plans]; } /// Clears all lists void clear() { _plans = []; ingredients = []; } /// Returns the current active nutritional plan. /// A plan is considered active if: /// - Its start date is before now /// - Its end date is after now or not set /// If multiple plans match these criteria, the one with the most recent creation date is returned. NutritionalPlan? get currentPlan { final now = DateTime.now(); return _plans .where( (plan) => plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now)), ) .toList() .sorted((a, b) => b.creationDate.compareTo(a.creationDate)) .firstOrNull; } NutritionalPlan findById(int id) { return _plans.firstWhere( (plan) => plan.id == id, orElse: () => throw const NoSuchEntryException(), ); } Meal? findMealById(int id) { for (final plan in _plans) { try { final meal = plan.meals.firstWhere((plan) => plan.id == id); return meal; } on StateError {} } return null; } /// Fetches and sets all plans sparsely, i.e. only with the data on the plan /// object itself and no child attributes Future fetchAndSetAllPlansSparse() async { final data = await baseProvider.fetchPaginated( baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': API_MAX_PAGE_SIZE}), ); _plans = []; for (final planData in data) { final plan = NutritionalPlan.fromJson(planData); _plans.add(plan); _plans.sort((a, b) => b.creationDate.compareTo(a.creationDate)); } notifyListeners(); } /// Fetches and sets all plans fully, i.e. with all corresponding child objects Future fetchAndSetAllPlansFull() async { final data = await baseProvider.fetchPaginated( baseProvider.makeUrl(_nutritionalPlansPath, query: {'limit': API_MAX_PAGE_SIZE}), ); await Future.wait(data.map((e) => fetchAndSetPlanFull(e['id'])).toList()); } /// Fetches and sets the given nutritional plan /// /// This method only loads the data on the nutritional plan object itself, /// no meals, etc. Future fetchAndSetPlanSparse(int planId) async { final url = baseProvider.makeUrl(_nutritionalPlansPath, id: planId); final planData = await baseProvider.fetch(url); final plan = NutritionalPlan.fromJson(planData); _plans.add(plan); _plans.sort((a, b) => b.creationDate.compareTo(a.creationDate)); notifyListeners(); return plan; } /// Fetches a plan fully, i.e. with all corresponding child objects Future fetchAndSetPlanFull(int planId) async { _logger.fine('Fetching full nutritional plan $planId'); NutritionalPlan plan; try { plan = findById(planId); } on NoSuchEntryException { // TODO: remove this useless call, because we will fetch all details below plan = await fetchAndSetPlanSparse(planId); } // Plan final url = baseProvider.makeUrl(_nutritionalPlansInfoPath, id: planId); final fullPlanData = await baseProvider.fetch(url); // Meals final List meals = []; for (final mealData in fullPlanData['meals']) { final List 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); final ingredient = Ingredient.fromJson(mealItemData['ingredient_obj']); if (mealItemData['image'] != null) { final image = IngredientImage.fromJson(mealItemData['image']); ingredient.image = image; } mealItem.ingredient = ingredient; mealItems.add(mealItem); } meal.mealItems = mealItems; meals.add(meal); } plan.meals = meals; // Logs await fetchAndSetLogs(plan); for (final meal in meals) { meal.diaryEntries = plan.diaryEntries.where((e) => e.mealId == meal.id).toList(); } // ... and done notifyListeners(); return plan; } Future addPlan(NutritionalPlan planData) async { final data = await baseProvider.post( planData.toJson(), baseProvider.makeUrl(_nutritionalPlansPath), ); final plan = NutritionalPlan.fromJson(data); _plans.add(plan); _plans.sort((a, b) => b.creationDate.compareTo(a.creationDate)); notifyListeners(); return plan; } Future editPlan(NutritionalPlan plan) async { await baseProvider.patch( plan.toJson(), baseProvider.makeUrl(_nutritionalPlansPath, id: plan.id), ); notifyListeners(); } Future deletePlan(int id) async { final existingPlanIndex = _plans.indexWhere((element) => element.id == id); final existingPlan = _plans[existingPlanIndex]; _plans.removeAt(existingPlanIndex); notifyListeners(); final response = await baseProvider.deleteRequest(_nutritionalPlansPath, id); if (response.statusCode >= 400) { _plans.insert(existingPlanIndex, existingPlan); notifyListeners(); throw WgerHttpException(response); } //existingPlan = null; } /// Adds a meal to a plan Future addMeal(Meal meal, int planId) async { final plan = findById(planId); final data = await baseProvider.post(meal.toJson(), baseProvider.makeUrl(_mealPath)); meal = Meal.fromJson(data); plan.meals.add(meal); notifyListeners(); return meal; } /// Edits an existing meal Future editMeal(Meal meal) async { final data = await baseProvider.patch( meal.toJson(), baseProvider.makeUrl(_mealPath, id: meal.id), ); meal = Meal.fromJson(data); notifyListeners(); return meal; } /// Deletes a meal Future deleteMeal(Meal meal) async { // Get the meal final plan = findById(meal.planId); final mealIndex = plan.meals.indexWhere((e) => e.id == meal.id); final existingMeal = plan.meals[mealIndex]; plan.meals.removeAt(mealIndex); notifyListeners(); // Try to delete final response = await baseProvider.deleteRequest(_mealPath, meal.id!); if (response.statusCode >= 400) { plan.meals.insert(mealIndex, existingMeal); notifyListeners(); throw WgerHttpException(response); } } /// Adds a meal item to a meal Future addMealItem(MealItem mealItem, Meal meal) async { final data = await baseProvider.post(mealItem.toJson(), baseProvider.makeUrl(_mealItemPath)); mealItem = MealItem.fromJson(data); mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); meal.mealItems.add(mealItem); notifyListeners(); return mealItem; } /// Deletes a meal Future deleteMealItem(MealItem mealItem) async { // Get the meal final meal = findMealById(mealItem.mealId)!; final mealItemIndex = meal.mealItems.indexWhere((e) => e.id == mealItem.id); final existingMealItem = meal.mealItems[mealItemIndex]; meal.mealItems.removeAt(mealItemIndex); notifyListeners(); // Try to delete final response = await baseProvider.deleteRequest(_mealItemPath, mealItem.id!); if (response.statusCode >= 400) { meal.mealItems.insert(mealItemIndex, existingMealItem); notifyListeners(); throw WgerHttpException(response); } } Future clearIngredientCache() async { ingredients = []; await database.deleteEverything(); } /// Saves an ingredient to the cache Future cacheIngredient(Ingredient ingredient, {IngredientDatabase? database}) async { database ??= this.database; if (!ingredients.any((e) => e.id == ingredient.id)) { ingredients.add(ingredient); } final ingredientDb = await (database.select( database.ingredients, )..where((e) => e.id.equals(ingredient.id))).getSingleOrNull(); if (ingredientDb == null) { final data = ingredient.toJson(); try { await database .into(database.ingredients) .insert( IngredientsCompanion.insert( id: ingredient.id, data: jsonEncode(data), lastFetched: DateTime.now(), ), ); _logger.finer("Saved ingredient '${ingredient.name}' to db cache"); } catch (e) { _logger.finer("Error caching ingredient '${ingredient.name}': $e"); } } } /// Fetch and return an ingredient /// /// If the ingredient is not known locally, it is fetched from the server Future fetchIngredient(int ingredientId, {IngredientDatabase? database}) async { database ??= this.database; Ingredient ingredient; try { ingredient = ingredients.firstWhere((e) => e.id == ingredientId); } on StateError { final ingredientDb = await (database.select( database.ingredients, )..where((e) => e.id.equals(ingredientId))).getSingleOrNull(); // Try to fetch from local db if (ingredientDb != null) { ingredient = Ingredient.fromJson(jsonDecode(ingredientDb.data)); ingredients.add(ingredient); _logger.info("Loaded ingredient '${ingredient.name}' from db cache"); // Prune old entries if (DateTime.now().isAfter( ingredientDb.lastFetched.add(const Duration(days: DAYS_TO_CACHE)), )) { (database.delete(database.ingredients)..where((i) => i.id.equals(ingredientId))).go(); } } else { _logger.info("Fetching ingredient ID $ingredientId from server"); final data = await baseProvider.fetch( baseProvider.makeUrl(_ingredientInfoPath, id: ingredientId), ); ingredient = Ingredient.fromJson(data); // Cache the ingredient await cacheIngredient(ingredient, database: database); } } return ingredient; } /// Loads the available ingredients from the local cache Future fetchIngredientsFromCache() async { final ingredientDb = await database.select(database.ingredients).get(); _logger.info('Read ${ingredientDb.length} ingredients from db cache'); if (ingredientDb.isNotEmpty) { ingredients = ingredientDb.map((e) => Ingredient.fromJson(jsonDecode(e.data))).toList(); } } /// Searches for an ingredient Future> searchIngredient( String name, { String languageCode = 'en', bool searchEnglish = false, }) async { if (name.length <= 1) { return []; } final languages = [languageCode]; if (searchEnglish && languageCode != LANGUAGE_SHORT_ENGLISH) { languages.add(LANGUAGE_SHORT_ENGLISH); } // Send the request _logger.info('Fetching ingredients from server'); final response = await baseProvider.fetch( baseProvider.makeUrl( _ingredientInfoPath, query: { 'name__search': name, 'language__code': languages.join(','), 'limit': API_RESULTS_PAGE_SIZE, }, ), timeout: const Duration(seconds: 10), ); return (response['results'] as List) .map((ingredientData) => Ingredient.fromJson(ingredientData as Map)) .toList(); } /// Searches for an ingredient with bar code Future searchIngredientWithBarcode(String barcode) async { if (barcode.isEmpty) { return null; } // Send the request final data = await baseProvider.fetch( baseProvider.makeUrl(_ingredientInfoPath, query: {'code': barcode}), ); if (data['count'] == 0) { return null; } // TODO we should probably add it to ingredient cache. return Ingredient.fromJson(data['results'][0]); } /// Log meal to nutrition diary Future logMealToDiary(Meal meal, DateTime mealDateTime) async { for (final item in meal.mealItems) { final plan = findById(meal.planId); final Log log = Log.fromMealItem(item, plan.id!, meal.id, mealDateTime); final data = await baseProvider.post(log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath)); log.id = data['id']; plan.diaryEntries.add(log); } notifyListeners(); } /// Log custom ingredient to nutrition diary Future logIngredientToDiary(MealItem mealItem, int planId, [DateTime? dateTime]) async { final plan = findById(planId); mealItem.ingredient = await fetchIngredient(mealItem.ingredientId); final log = Log.fromMealItem(mealItem, plan.id!, null, dateTime); final data = await baseProvider.post(log.toJson(), baseProvider.makeUrl(_nutritionDiaryPath)); log.id = data['id']; plan.diaryEntries.add(log); notifyListeners(); } /// Deletes a log entry Future deleteLog(int logId, int planId) async { await baseProvider.deleteRequest(_nutritionDiaryPath, logId); final plan = findById(planId); plan.diaryEntries.removeWhere((element) => element.id == logId); notifyListeners(); } /// Load nutrition diary entries for plan Future fetchAndSetLogs(NutritionalPlan plan) async { final data = await baseProvider.fetchPaginated( baseProvider.makeUrl( _nutritionDiaryPath, query: {'plan': plan.id?.toString(), 'limit': API_MAX_PAGE_SIZE, 'ordering': 'datetime'}, ), ); plan.diaryEntries = []; for (final logData in data) { final log = Log.fromJson(logData); final ingredient = await fetchIngredient(log.ingredientId); log.ingredient = ingredient; plan.diaryEntries.add(log); } notifyListeners(); } }