Merge branch 'client/feature-servings' into 'main'

feat(client/servings): inferring serving size from user input

Closes #72

See merge request cse1105/2025-2026/teams/csep-team-76!74
This commit is contained in:
Zhongheng Liu 2026-01-22 15:24:46 +01:00
commit 23d6d6bbf6
4 changed files with 45 additions and 28 deletions

View file

@ -49,6 +49,8 @@ public class RecipeDetailCtrl implements LocaleAware {
public Spinner<Double> scaleSpinner; public Spinner<Double> scaleSpinner;
public Label inferredKcalLabel; public Label inferredKcalLabel;
public Spinner<Integer> servingsSpinner;
public Label inferredServeSizeLabel;
@FXML @FXML
private IngredientListCtrl ingredientListController; private IngredientListCtrl ingredientListController;
@ -157,7 +159,7 @@ public class RecipeDetailCtrl implements LocaleAware {
// If there is a scale // If there is a scale
// Prevents issues from first startup // Prevents issues from first startup
if (scaleSpinner.getValue() != null) { if (scaleSpinner.getValue() != null && servingsSpinner.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);
@ -167,6 +169,10 @@ public class RecipeDetailCtrl implements LocaleAware {
Double.isNaN(this.recipeView.scaledKcalProperty().get()) ? Double.isNaN(this.recipeView.scaledKcalProperty().get()) ?
0.0 : this.recipeView.scaledKcalProperty().get()) 0.0 : this.recipeView.scaledKcalProperty().get())
, this.recipeView.scaledKcalProperty())); , this.recipeView.scaledKcalProperty()));
recipeView.servingsProperty().set(servingsSpinner.getValue());
inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding(
() -> String.format("Inferred size per serving: %.1f g", recipeView.servingSizeProperty().get()),
recipeView.servingSizeProperty()));
// 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());
@ -402,6 +408,14 @@ public class RecipeDetailCtrl implements LocaleAware {
// triggers a UI update each time the spinner changes to a different value. // triggers a UI update each time the spinner changes to a different value.
setCurrentlyViewedRecipe(recipe); setCurrentlyViewedRecipe(recipe);
}); });
servingsSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1));
servingsSpinner.setEditable(true);
servingsSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll("en", "nl", "pl", "tok"); langSelector.getItems().addAll("en", "nl", "pl", "tok");
} }
} }

View file

@ -4,15 +4,19 @@ import commons.Recipe;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.DoubleProperty; import javafx.beans.property.DoubleProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
public class ScalableRecipeView { 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(); private final DoubleProperty scaledKcal = new SimpleDoubleProperty();
private final IntegerProperty servings = new SimpleIntegerProperty();
private final DoubleProperty servingSize = new SimpleDoubleProperty();
public ScalableRecipeView( public ScalableRecipeView(
Recipe recipe, Recipe recipe,
Double scale Double scale
@ -24,10 +28,10 @@ public class ScalableRecipeView {
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)); this.scaledKcal.bind(Bindings.createDoubleBinding(() -> this.scaled.get().kcal(), this.scaled));
} this.servingSize.bind(Bindings.createDoubleBinding(
() -> this.scaled.get().weight() * ( 1.0 / this.servings.get()),
public double getScale() { this.servings)
return scale.get(); );
} }
public Recipe getRecipe() { public Recipe getRecipe() {
@ -38,23 +42,14 @@ public class ScalableRecipeView {
return scaled.get(); return scaled.get();
} }
public double getScaledKcal() { public DoubleProperty scaledKcalProperty() {
return scaledKcal.get();
}
public DoubleProperty scaleProperty() {
return scale;
}
public ObjectProperty<Recipe> scaledProperty() {
return scaled;
}
public ObjectProperty<Recipe> recipeProperty() {
return recipe;
}
public SimpleDoubleProperty scaledKcalProperty() {
return scaledKcal; return scaledKcal;
} }
public IntegerProperty servingsProperty() {
return servings;
}
public DoubleProperty servingSizeProperty() {
return servingSize;
}
} }

View file

@ -25,11 +25,15 @@
<Button fx:id="removeRecipeButton" mnemonicParsing="false" onAction="#removeSelectedRecipe" text="Remove Recipe" /> <Button fx:id="removeRecipeButton" mnemonicParsing="false" onAction="#removeSelectedRecipe" text="Remove Recipe" />
<Button fx:id="printRecipeButton" mnemonicParsing="false" onAction="#printRecipe" text="Print Recipe" /> <Button fx:id="printRecipeButton" mnemonicParsing="false" onAction="#printRecipe" text="Print Recipe" />
<Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" /> <Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" />
<Label>Scale: </Label>
<Spinner fx:id="scaleSpinner" />
</HBox> </HBox>
<HBox>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" /> <ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<Label>Scale: </Label>
<Spinner fx:id="scaleSpinner" />
<Label>Servings: </Label>
<Spinner fx:id="servingsSpinner" />
</HBox>
<!-- Ingredients --> <!-- Ingredients -->
<fx:include source="RecipeIngredientList.fxml" fx:id="ingredientList" /> <fx:include source="RecipeIngredientList.fxml" fx:id="ingredientList" />
@ -37,5 +41,6 @@
<!-- 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="inferredServeSizeLabel" />
<Label fx:id="inferredKcalLabel" /> <Label fx:id="inferredKcalLabel" />
</VBox> </VBox>

View file

@ -203,7 +203,10 @@ public class Recipe {
final double PER = 100; // Gram final double PER = 100; // Gram
return return
this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() / this.ingredients.stream().mapToDouble(RecipeIngredient::getKcal).sum() /
this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum() * PER; weight() * PER;
}
public double weight() {
return this.ingredients.stream().mapToDouble(RecipeIngredient::getBaseAmount).sum();
} }
} }