Merge branch 'client/feature-kcal-eval' into 'main'

feat(client/kcal): kcal/100g per recipe evaluation

Closes #71

See merge request cse1105/2025-2026/teams/csep-team-76!73
This commit is contained in:
Zhongheng Liu 2026-01-22 14:40:38 +01:00
commit 46ba82bc0d
8 changed files with 53 additions and 5 deletions

View file

@ -66,7 +66,7 @@ public class NutritionDetailsCtrl implements LocaleAware {
this.proteinInputElement.textProperty().bindBidirectional(vm.proteinProperty(), new NumberStringConverter()); this.proteinInputElement.textProperty().bindBidirectional(vm.proteinProperty(), new NumberStringConverter());
this.carbInputElement.textProperty().bindBidirectional(vm.carbsProperty(), new NumberStringConverter()); this.carbInputElement.textProperty().bindBidirectional(vm.carbsProperty(), new NumberStringConverter());
this.estimatedKcalLabel.textProperty().bind(Bindings.createStringBinding( this.estimatedKcalLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("Estimated energy value: %.1f", vm.getKcal()), vm.kcalProperty() () -> String.format("Estimated energy value: %.1f kcal/100g", vm.getKcal()), vm.kcalProperty()
)); ));
}); });
this.nutritionValueContainer.addEventHandler(KeyEvent.KEY_RELEASED, event -> { this.nutritionValueContainer.addEventHandler(KeyEvent.KEY_RELEASED, event -> {

View file

@ -18,6 +18,8 @@ import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
import javafx.beans.binding.Bindings;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
@ -46,6 +48,7 @@ public class RecipeDetailCtrl implements LocaleAware {
private final WebSocketDataService<Long, Recipe> webSocketDataService; private final WebSocketDataService<Long, Recipe> webSocketDataService;
public Spinner<Double> scaleSpinner; public Spinner<Double> scaleSpinner;
public Label inferredKcalLabel;
@FXML @FXML
private IngredientListCtrl ingredientListController; private IngredientListCtrl ingredientListController;
@ -156,10 +159,14 @@ public class RecipeDetailCtrl implements LocaleAware {
// Prevents issues from first startup // Prevents issues from first startup
if (scaleSpinner.getValue() != null) { if (scaleSpinner.getValue() != null) {
Double scale = scaleSpinner.getValue(); Double scale = scaleSpinner.getValue();
// see impl. creates a scaled context for the recipe such that its non-scaled value is kept as a reference. // see impl. creates a scaled context for the recipe such that its non-scaled value is kept as a reference.
this.recipeView = new ScalableRecipeView(recipe, scale); this.recipeView = new ScalableRecipeView(recipe, scale);
// TODO i18n
inferredKcalLabel.textProperty().bind(Bindings.createStringBinding(() ->
String.format("Inferred %.1f kcal/100g for this recipe",
Double.isNaN(this.recipeView.scaledKcalProperty().get()) ?
0.0 : this.recipeView.scaledKcalProperty().get())
, this.recipeView.scaledKcalProperty()));
// expose the scaled view to list controllers // expose the scaled view to list controllers
this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled()); this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled());
this.stepListController.refetchFromRecipe(this.recipeView.getScaled()); this.stepListController.refetchFromRecipe(this.recipeView.getScaled());
@ -386,7 +393,7 @@ public class RecipeDetailCtrl implements LocaleAware {
public void initializeComponents() { public void initializeComponents() {
initStepsIngredientsList(); initStepsIngredientsList();
// creates a new scale spinner with an arbitrary max scale // creates a new scale spinner with an arbitrary max scale
scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, Double.MAX_VALUE, 1)); scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(1, Double.MAX_VALUE, 1));
scaleSpinner.setEditable(true); scaleSpinner.setEditable(true);
scaleSpinner.valueProperty().addListener((observable, oldValue, newValue) -> { scaleSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) { if (newValue == null) {

View file

@ -12,6 +12,7 @@ public class ScalableRecipeView {
private final ObjectProperty<Recipe> recipe = new SimpleObjectProperty<>(); private final ObjectProperty<Recipe> recipe = new SimpleObjectProperty<>();
private final ObjectProperty<Recipe> scaled = new SimpleObjectProperty<>(); private final ObjectProperty<Recipe> scaled = new SimpleObjectProperty<>();
private final DoubleProperty scale = new SimpleDoubleProperty(); private final DoubleProperty scale = new SimpleDoubleProperty();
private final SimpleDoubleProperty scaledKcal = new SimpleDoubleProperty();
public ScalableRecipeView( public ScalableRecipeView(
Recipe recipe, Recipe recipe,
Double scale Double scale
@ -22,6 +23,7 @@ public class ScalableRecipeView {
() -> Recipe.getScaled(this.recipe.get(), this.scale.get()), () -> Recipe.getScaled(this.recipe.get(), this.scale.get()),
this.recipe, this.scale); this.recipe, this.scale);
this.scaled.bind(binding); this.scaled.bind(binding);
this.scaledKcal.bind(Bindings.createDoubleBinding(() -> this.scaled.get().kcal(), this.scaled));
} }
public double getScale() { public double getScale() {
@ -36,6 +38,10 @@ public class ScalableRecipeView {
return scaled.get(); return scaled.get();
} }
public double getScaledKcal() {
return scaledKcal.get();
}
public DoubleProperty scaleProperty() { public DoubleProperty scaleProperty() {
return scale; return scale;
} }
@ -47,4 +53,8 @@ public class ScalableRecipeView {
public ObjectProperty<Recipe> recipeProperty() { public ObjectProperty<Recipe> recipeProperty() {
return recipe; return recipe;
} }
public SimpleDoubleProperty scaledKcalProperty() {
return scaledKcal;
}
} }

View file

@ -37,4 +37,5 @@
<!-- Preparation --> <!-- Preparation -->
<fx:include source="RecipeStepList.fxml" fx:id="stepList" <fx:include source="RecipeStepList.fxml" fx:id="stepList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" /> VBox.vgrow="ALWAYS" maxWidth="Infinity" />
<Label fx:id="inferredKcalLabel" />
</VBox> </VBox>

View file

@ -76,4 +76,15 @@ public class FormalIngredient extends RecipeIngredient implements Scalable<Forma
public int hashCode() { public int hashCode() {
return Objects.hash(super.hashCode(), amount, unitSuffix); return Objects.hash(super.hashCode(), amount, unitSuffix);
} }
@Override
public double getKcal() {
final double PER_GRAMS = 100;
return ingredient.kcalPer100g() * amountInBaseUnit() / PER_GRAMS;
}
@Override
public double getBaseAmount() {
return amountInBaseUnit();
}
} }

View file

@ -198,7 +198,12 @@ public class Recipe {
default -> throw new IllegalStateException("Unexpected value: " + ri); default -> throw new IllegalStateException("Unexpected value: " + ri);
}).toList(); }).toList();
return new Recipe(recipe.getId(), recipe.getName(), recipe.getLocale(), i, recipe.getPreparationSteps()); return new Recipe(recipe.getId(), recipe.getName(), recipe.getLocale(), i, recipe.getPreparationSteps());
}
public double kcal() {
final double PER = 100; // Gram
return
this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() /
this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum() * PER;
} }
} }

View file

@ -1,5 +1,6 @@
package commons; package commons;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@ -92,4 +93,8 @@ public abstract class RecipeIngredient {
public int hashCode() { public int hashCode() {
return Objects.hash(id, ingredient); return Objects.hash(id, ingredient);
} }
@JsonIgnore
public abstract double getKcal();
@JsonIgnore
public abstract double getBaseAmount();
} }

View file

@ -50,4 +50,13 @@ public class VagueIngredient extends RecipeIngredient {
public int hashCode() { public int hashCode() {
return Objects.hashCode(description); return Objects.hashCode(description);
} }
@Override
public double getKcal() {
return 0;
}
@Override
public double getBaseAmount() {
return 0;
}
} }