Merge branch 'client/feature-ingredient-nutrition-view' into 'main'

feat(client/nutrition): Nutrition & Ingredient list view

Closes #54 and #56

See merge request cse1105/2025-2026/teams/csep-team-76!54
This commit is contained in:
Aysegul Aydinlik 2026-01-16 16:12:17 +01:00
commit 1abbeee1ae
15 changed files with 403 additions and 110 deletions

View file

@ -111,14 +111,13 @@ public class IngredientController {
public static class IngredientUsageResponse {
private long ingredientId;
private long usedInRecipes;
public long getIngredientId() {
return ingredientId;
}
public void setIngredientId(long ingredientId) {
this.ingredientId = ingredientId;
}
// public long getIngredientId() {
// return ingredientId;
// }
//
// public void setIngredientId(long ingredientId) {
// this.ingredientId = ingredientId;
// }
public long getUsedInRecipes() {
return usedInRecipes;

View file

@ -0,0 +1,78 @@
package client.Ingredient;
import commons.Ingredient;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.LongProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.ReadOnlyDoubleWrapper;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
public class IngredientViewModel {
private final LongProperty id = new SimpleLongProperty();
private final StringProperty name = new SimpleStringProperty();
private final DoubleProperty protein = new SimpleDoubleProperty();
private final DoubleProperty fat = new SimpleDoubleProperty();
private final DoubleProperty carbs = new SimpleDoubleProperty();
private final ReadOnlyDoubleWrapper kcal = new ReadOnlyDoubleWrapper();
public IngredientViewModel() {
DoubleBinding computeKcal = Bindings.createDoubleBinding(
() -> toIngredient().kcalPer100g(),
protein, carbs, fat
);
// bind the read-only wrapper to the binding
kcal.bind(computeKcal);
}
public IngredientViewModel(Ingredient ing) {
updateFrom(ing);
}
public void updateFrom(Ingredient ing) {
if (ing == null) return;
id.set(ing.getId());
name.set(ing.getName());
protein.set(ing.getProteinPer100g());
fat.set(ing.getFatPer100g());
carbs.set(ing.getCarbsPer100g());
}
// property getters
public LongProperty idProperty() {
return id;
}
public StringProperty nameProperty() {
return name;
}
public DoubleProperty proteinProperty() {
return protein;
}
public DoubleProperty fatProperty() {
return fat;
}
public DoubleProperty carbsProperty() {
return carbs;
}
public Ingredient toIngredient() {
Ingredient i = new Ingredient();
i.setId(id.get());
i.setName(name.get());
i.setProteinPer100g(protein.get());
i.setFatPer100g(fat.get());
i.setCarbsPer100g(carbs.get());
return i;
}
public ReadOnlyDoubleProperty kcalProperty() {
return kcal.getReadOnlyProperty(); }
public double getKcal() {
return kcal.get(); }
}

View file

@ -17,6 +17,8 @@ package client;
import client.scenes.FoodpalApplicationCtrl;
import client.scenes.SearchBarCtrl;
import client.scenes.nutrition.NutritionDetailsCtrl;
import client.scenes.nutrition.NutritionViewCtrl;
import client.scenes.recipe.IngredientListCtrl;
import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.ConfigService;
@ -47,7 +49,8 @@ public class MyModule implements Module {
binder.bind(LocaleManager.class).in(Scopes.SINGLETON);
binder.bind(ServerUtils.class).in(Scopes.SINGLETON);
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
binder.bind(NutritionDetailsCtrl.class).in(Scopes.SINGLETON);
binder.bind(NutritionViewCtrl.class).in(Scopes.SINGLETON);
binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json")));
binder.bind(new TypeLiteral<WebSocketDataService<Long, Recipe>>() {}).toInstance(
new WebSocketDataService<>()

View file

@ -0,0 +1,7 @@
package client.exception;
public class IllegalInputFormatException extends RuntimeException {
public IllegalInputFormatException(String message) {
super(message);
}
}

View file

@ -10,7 +10,7 @@ import java.util.logging.Logger;
import java.util.stream.Collectors;
import client.exception.InvalidModificationException;
import client.scenes.recipe.IngredientsPopupCtrl;
import client.scenes.nutrition.NutritionViewCtrl;
import client.scenes.recipe.RecipeDetailCtrl;
import client.utils.Config;
@ -524,14 +524,14 @@ public class FoodpalApplicationCtrl implements LocaleAware {
private void openIngredientsPopup() {
try {
var pair = client.UI.getFXML().load(
IngredientsPopupCtrl.class,
"client", "scenes", "recipe", "IngredientsPopup.fxml"
NutritionViewCtrl.class,
"client", "scenes", "nutrition", "NutritionView.fxml"
);
var root = pair.getValue();
var stage = new javafx.stage.Stage();
stage.setTitle("Ingredients");
stage.setTitle("Nutrition values view");
stage.initModality(javafx.stage.Modality.APPLICATION_MODAL);
stage.setScene(new javafx.scene.Scene(root));
stage.showAndWait();

View file

@ -0,0 +1,151 @@
package client.scenes.Ingredient;
import client.scenes.nutrition.NutritionDetailsCtrl;
import client.utils.ServerUtils;
import commons.Ingredient;
import jakarta.inject.Inject;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextInputDialog;
import javafx.stage.Stage;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
//TODO and check for capital letter milk and MILK are seen as different
public class IngredientListCtrl {
private final ServerUtils server;
private final Logger logger = Logger.getLogger(IngredientListCtrl.class.getName());
@FXML
private ListView<Ingredient> ingredientListView;
@FXML
private NutritionDetailsCtrl nutritionDetailsCtrl;
@Inject
public IngredientListCtrl(
ServerUtils server,
NutritionDetailsCtrl nutritionDetailsCtrl
) {
this.server = server;
this.nutritionDetailsCtrl = nutritionDetailsCtrl;
}
@FXML
public void initialize() {
ingredientListView.setCellFactory(list -> new ListCell<>() {
@Override
protected void updateItem(Ingredient item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
} else {
setText(item.getName());
}
}
});
ingredientListView.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
logger.info("Selected ingredient " + newValue.getName() + ", propagating to Nutrition Details controller...");
nutritionDetailsCtrl.setItem(newValue);
});
refresh();
}
@FXML
private void addIngredient() {
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Add Ingredient");
dialog.setHeaderText("Create a new ingredient");
dialog.setContentText("Name:");
Optional<String> result = dialog.showAndWait();
if (result.isEmpty()) {
return;
}
String name = result.get().trim();
if (name.isEmpty()) {
showError("Ingredient name cannot be empty.");
return;
}
try {
server.createIngredient(name); // calls POST /api/ingredients
refresh(); // reload list from server
} catch (IOException | InterruptedException e) {
showError("Failed to create ingredient: " + e.getMessage());
}
}
@FXML
private void refresh() {
try {
List<Ingredient> ingredients = server.getIngredients();
ingredientListView.getItems().setAll(ingredients);
} catch (IOException | InterruptedException e) {
showError("Failed to load ingredients: " + e.getMessage());
}
}
@FXML
private void deleteSelected() {
Ingredient selected = ingredientListView.getSelectionModel().getSelectedItem();
if (selected == null) {
showError("No ingredient selected.");
return;
}
try {
long usageCount = server.getIngredientUsage(selected.getId());
if (usageCount > 0) {
boolean proceed = confirmDeleteUsed(selected.getName(), usageCount);
if (!proceed) {
return;
}
}
server.deleteIngredient(selected.getId());
refresh();
} catch (IOException | InterruptedException e) {
showError("Failed to delete ingredient: " + e.getMessage());
}
}
@FXML
private void close() {
Stage stage = (Stage) ingredientListView.getScene().getWindow();
stage.close();
}
private boolean confirmDeleteUsed(String name, long usedInRecipes) {
Alert alert = new Alert(Alert.AlertType.WARNING);
alert.setTitle("Warning");
alert.setHeaderText("Ingredient in use");
alert.setContentText("Ingredient '" + name + "' is used in " + usedInRecipes
+ " recipe(s). Delete anyway?");
var delete = new javafx.scene.control.ButtonType("Delete Anyway");
var cancel = new javafx.scene.control.ButtonType("Cancel");
alert.getButtonTypes().setAll(delete, cancel);
return alert.showAndWait().orElse(cancel) == delete;
}
private void showError(String msg) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText(null);
alert.setContentText(msg);
alert.showAndWait();
}
}

View file

@ -1,13 +1,38 @@
package client.scenes.nutrition;
import client.Ingredient.IngredientViewModel;
import client.exception.IllegalInputFormatException;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
import com.google.inject.Inject;
import commons.Ingredient;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.util.converter.NumberStringConverter;
import java.io.IOException;
import java.util.logging.Logger;
public class NutritionDetailsCtrl implements LocaleAware {
@FXML
public GridPane nutritionValueContainer;
private boolean visible;
private final LocaleManager manager;
private final ServerUtils server;
private final SimpleObjectProperty<IngredientViewModel> ingredient =
new SimpleObjectProperty<>(new IngredientViewModel());
private final Logger logger = Logger.getLogger(this.getClass().getName());
public Label ingredientName;
public Label fatInputLabel;
@ -20,19 +45,87 @@ public class NutritionDetailsCtrl implements LocaleAware {
public TextField proteinInputElement;
public TextField carbInputElement;
@FXML
public VBox nutritionDetails;
@Inject
public NutritionDetailsCtrl(
LocaleManager manager
LocaleManager manager,
ServerUtils server
) {
this.manager = manager;
this.server = server;
}
@Override
public void initializeComponents() {
Platform.runLater(() -> {
IngredientViewModel vm = this.ingredient.get();
this.ingredientName.textProperty().bind(vm.nameProperty());
this.fatInputElement.textProperty().bindBidirectional(vm.fatProperty(), new NumberStringConverter());
this.proteinInputElement.textProperty().bindBidirectional(vm.proteinProperty(), new NumberStringConverter());
this.carbInputElement.textProperty().bindBidirectional(vm.carbsProperty(), new NumberStringConverter());
this.estimatedKcalLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("Estimated energy value: %.1f", vm.getKcal()), vm.kcalProperty()
));
});
this.nutritionValueContainer.addEventHandler(KeyEvent.KEY_RELEASED, event -> {
if (event.getCode() != KeyCode.ENTER) {
return;
}
try {
Ingredient newIngredient = server.updateIngredient(updateIngredient());
Platform.runLater(() -> {
this.ingredient.get().updateFrom(newIngredient);
logger.info("Updated ingredient to " + newIngredient);
});
} catch (IllegalInputFormatException e) {
e.printStackTrace();
} catch (IOException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
@Override
public void updateText() {
}
public void setVisible(boolean isVisible) {
nutritionDetails.setVisible(isVisible);
}
public void toggleVisible() {
nutritionDetails.setVisible(!visible);
visible = !visible;
}
public void setItem(Ingredient ingredient) {
this.ingredient.get().updateFrom(ingredient);
setVisible(true);
}
private Ingredient updateIngredient() throws IllegalInputFormatException {
Ingredient current = this.ingredient.get().toIngredient();
try {
double f = Double.parseDouble(this.fatInputElement.getText());
double p = Double.parseDouble(this.proteinInputElement.getText());
double c = Double.parseDouble(this.carbInputElement.getText());
current.setCarbsPer100g(c);
current.setProteinPer100g(p);
current.setFatPer100g(f);
return current;
} catch (NumberFormatException e) {
throw new IllegalInputFormatException("Invalid F/P/C value");
}
}
@Override
public LocaleManager getLocaleManager() {
return manager;
}
public void handleNutritionSaveClick(ActionEvent actionEvent) throws IOException, InterruptedException {
Ingredient newIngredient = updateIngredient();
this.ingredient.get().updateFrom(server.updateIngredient(newIngredient));
}
}

View file

@ -1,71 +1,10 @@
package client.scenes.nutrition;
import client.scenes.FoodpalApplicationCtrl;
import com.google.inject.Inject;
import commons.Ingredient;
import commons.Recipe;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.control.ListView;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class NutritionViewCtrl {
private ObservableList<Recipe> recipes;
private HashMap<Ingredient, Integer> ingredientStats;
public ListView<Ingredient> nutritionIngredientsView;
private final NutritionDetailsCtrl nutritionDetailsCtrl;
// TODO into Ingredient class definition
// FIXME MOST LIKELY CURRENTLY BROKEN. TO BE FIXED.
/**
* Comedically verbose function to count unique appearances of an ingredient by name in each recipe.
* For each recipe:
* 1. Collect unique ingredients that appeared in that recipe.
* 2. For each unique ingredient in said recipe:
* 1. Initialize the appearance for that ingredient to 0.
* 2. For each recipe in list:
* 1. If the name of the ingredient exists in the recipe list, increment the statistic by 1.
* 2. Else maintain the same value for that statistic.
* @param recipeList The recipe list
*/
private void updateIngredientStats(
List<Recipe> recipeList
) {
recipeList.forEach(recipe -> {
Set<Ingredient> uniqueIngredients = new HashSet<>(
recipe.getIngredients().stream().map(
ingredient -> ingredient.ingredient).toList());
nutritionIngredientsView.getItems().setAll(uniqueIngredients);
uniqueIngredients.forEach(ingredient -> {
ingredientStats.put(ingredient, 0);
recipeList.forEach(r ->
ingredientStats.put(
ingredient,
ingredientStats.get(ingredient) + (
(r.getIngredients().contains(ingredient))
? 1 : 0
)
)
);
});
});
}
@Inject
public NutritionViewCtrl(
FoodpalApplicationCtrl foodpalApplicationCtrl,
NutritionDetailsCtrl nutritionDetailsCtrl
) {
this.recipes = foodpalApplicationCtrl.recipeList.getItems();
this.recipes.addListener((ListChangeListener<? super Recipe>) _ -> {
updateIngredientStats(this.recipes);
});
this.nutritionDetailsCtrl = nutritionDetailsCtrl;
this.nutritionIngredientsView.selectionModelProperty().addListener((observable, oldValue, newValue) -> {
});
}
}

View file

@ -216,9 +216,13 @@ public class ServerUtils {
updateRecipe(recipe);
}
// how many ingredients are getting used in recipes
/**
* Gets the amount of recipes this ingredient is being used in.
* @param ingredientId The queried ingredient's ID.
* @return The amount of recipes the ingredient is used in.
* @throws IOException if server query failed.
* @throws InterruptedException if operation is interrupted.
*/
public long getIngredientUsage(long ingredientId) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients/" + ingredientId + "/usage"))
@ -292,6 +296,22 @@ public class ServerUtils {
return objectMapper.readValue(response.body(), Ingredient.class);
}
public Ingredient updateIngredient(Ingredient newIngredient) throws IOException, InterruptedException {
logger.info("PATCH ingredient with id: " + newIngredient.getId());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients/" + newIngredient.getId()))
.header("Content-Type", "application/json")
.method(
"PATCH",
HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(newIngredient)))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != statusOK) {
throw new IOException("Failed to update ingredient with id: " + newIngredient.getId() + " body: " + response.body());
}
return objectMapper.readValue(response.body(), Ingredient.class);
}

View file

@ -8,7 +8,7 @@
<?import javafx.scene.layout.VBox?>
<VBox xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="client.scenes.recipe.IngredientsPopupCtrl"
fx:controller="client.scenes.Ingredient.IngredientListCtrl"
spacing="10" prefWidth="420" prefHeight="520">
<padding>

View file

@ -7,26 +7,21 @@
<?import javafx.scene.layout.*?>
<?import javafx.scene.shape.Line?>
<AnchorPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="client.scenes.nutrition.NutritionDetailsCtrl"
prefHeight="400.0" prefWidth="600.0">
<VBox visible="false">
<Label fx:id="ingredientName" />
<HBox>
<Label fx:id="fatInputLabel">Fat: </Label>
<TextField fx:id="fatInputElement" />
</HBox>
<HBox>
<Label fx:id="proteinInputLabel">Protein: </Label>
<TextField fx:id="proteinInputElement" />
</HBox>
<HBox>
<Label fx:id="carbInputLabel">Carbohydrates: </Label>
<TextField fx:id="carbInputElement" />
</HBox>
<Label fx:id="estimatedKcalLabel">Estimated: 0kcal</Label>
<Label fx:id="usageLabel">Not used in any recipes</Label>
</VBox>
</AnchorPane>
<VBox xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="client.scenes.nutrition.NutritionDetailsCtrl"
fx:id="nutritionDetails"
visible="false">
<Label fx:id="ingredientName" />
<GridPane fx:id="nutritionValueContainer">
<Label GridPane.columnIndex="0" GridPane.rowIndex="0" fx:id="fatInputLabel">Fat: </Label>
<TextField GridPane.columnIndex="1" GridPane.rowIndex="0" fx:id="fatInputElement" />
<Label GridPane.columnIndex="0" GridPane.rowIndex="1" fx:id="proteinInputLabel">Protein: </Label>
<TextField GridPane.columnIndex="1" GridPane.rowIndex="1" fx:id="proteinInputElement" />
<Label GridPane.columnIndex="0" GridPane.rowIndex="2" fx:id="carbInputLabel">Carbohydrates: </Label>
<TextField GridPane.columnIndex="1" GridPane.rowIndex="2" fx:id="carbInputElement" />
</GridPane>
<Button onAction="#handleNutritionSaveClick">Save values</Button>
<Label fx:id="estimatedKcalLabel">Estimated: 0kcal</Label>
<Label fx:id="usageLabel">Not used in any recipes</Label>
</VBox>

View file

@ -11,9 +11,7 @@
fx:controller="client.scenes.nutrition.NutritionViewCtrl"
prefHeight="400.0" prefWidth="600.0">
<SplitPane>
<ListView fx:id="nutritionIngredientsView" />
<AnchorPane>
<fx:include source="NutritionDetails.fxml" />
</AnchorPane>
<fx:include source="IngredientList.fxml" />
<fx:include source="NutritionDetails.fxml" />
</SplitPane>
</AnchorPane>

View file

@ -30,8 +30,7 @@
<ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<!-- Ingredients -->
<fx:include source="IngredientList.fxml" fx:id="ingredientList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" />
<fx:include source="RecipeIngredientList.fxml" fx:id="ingredientList" />
<!-- Preparation -->
<fx:include source="RecipeStepList.fxml" fx:id="stepList"