Merge branch 'client/feature-recipe-scaling' into 'main'

feat(client/scaling): Implements client-side recipe scaling

Closes #70

See merge request cse1105/2025-2026/teams/csep-team-76!70
This commit is contained in:
Zhongheng Liu 2026-01-19 21:12:20 +01:00
commit 83910eca72
4 changed files with 96 additions and 5 deletions

View file

@ -20,11 +20,13 @@ import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
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.Label; import javafx.scene.control.Label;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog; import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ComboBox;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
@ -43,6 +45,8 @@ public class RecipeDetailCtrl implements LocaleAware {
private final ConfigService configService; private final ConfigService configService;
private final WebSocketDataService<Long, Recipe> webSocketDataService; private final WebSocketDataService<Long, Recipe> webSocketDataService;
public Spinner<Double> scaleSpinner;
@FXML @FXML
private IngredientListCtrl ingredientListController; private IngredientListCtrl ingredientListController;
@ -50,6 +54,7 @@ public class RecipeDetailCtrl implements LocaleAware {
private RecipeStepListCtrl stepListController; private RecipeStepListCtrl stepListController;
private Recipe recipe; private Recipe recipe;
private ScalableRecipeView recipeView;
@Inject @Inject
public RecipeDetailCtrl( public RecipeDetailCtrl(
@ -143,12 +148,26 @@ public class RecipeDetailCtrl implements LocaleAware {
} }
this.recipe = recipe; this.recipe = recipe;
this.showName(recipe.getName()); this.showName(recipe.getName());
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
this.langSelector.setValue(recipe.getLocale()); this.langSelector.setValue(recipe.getLocale());
this.refreshFavouriteButton(); this.refreshFavouriteButton();
// If there is a scale
// Prevents issues from first startup
if (scaleSpinner.getValue() != null) {
Double scale = scaleSpinner.getValue();
// 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);
// expose the scaled view to list controllers
this.ingredientListController.refetchFromRecipe(this.recipeView.getScaled());
this.stepListController.refetchFromRecipe(this.recipeView.getScaled());
return;
}
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
} }
/** /**
@ -170,6 +189,8 @@ public class RecipeDetailCtrl implements LocaleAware {
recipeConsumer.accept(selectedRecipe, recipes); recipeConsumer.accept(selectedRecipe, recipes);
try { // propagate changes to server try { // propagate changes to server
// NOTE Scale does not impact the server update (per backlog req.) as the scaled reference is held in
// the scaled view which is only exposed to lists.
server.updateRecipe(selectedRecipe); server.updateRecipe(selectedRecipe);
webSocketDataService.add(selectedRecipe.getId(), recipe -> { webSocketDataService.add(selectedRecipe.getId(), recipe -> {
int idx = getParentRecipeList().getItems().indexOf(selectedRecipe); int idx = getParentRecipeList().getItems().indexOf(selectedRecipe);
@ -364,7 +385,16 @@ public class RecipeDetailCtrl implements LocaleAware {
@Override @Override
public void initializeComponents() { public void initializeComponents() {
initStepsIngredientsList(); initStepsIngredientsList();
// creates a new scale spinner with an arbitrary max scale
scaleSpinner.setValueFactory(new SpinnerValueFactory.DoubleSpinnerValueFactory(0, Double.MAX_VALUE, 1));
scaleSpinner.setEditable(true);
scaleSpinner.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue == null) {
return;
}
// triggers a UI update each time the spinner changes to a different value.
setCurrentlyViewedRecipe(recipe);
});
langSelector.getItems().addAll("en", "nl", "pl", "tok"); langSelector.getItems().addAll("en", "nl", "pl", "tok");
} }
} }

View file

@ -0,0 +1,50 @@
package client.scenes.recipe;
import commons.Recipe;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
public class ScalableRecipeView {
private final ObjectProperty<Recipe> recipe = new SimpleObjectProperty<>();
private final ObjectProperty<Recipe> scaled = new SimpleObjectProperty<>();
private final DoubleProperty scale = new SimpleDoubleProperty();
public ScalableRecipeView(
Recipe recipe,
Double scale
) {
this.recipe.set(recipe);
this.scale.set(scale);
ObjectBinding<Recipe> binding = Bindings.createObjectBinding(
() -> Recipe.getScaled(this.recipe.get(), this.scale.get()),
this.recipe, this.scale);
this.scaled.bind(binding);
}
public double getScale() {
return scale.get();
}
public Recipe getRecipe() {
return recipe.get();
}
public Recipe getScaled() {
return scaled.get();
}
public DoubleProperty scaleProperty() {
return scale;
}
public ObjectProperty<Recipe> scaledProperty() {
return scaled;
}
public ObjectProperty<Recipe> recipeProperty() {
return recipe;
}
}

View file

@ -25,6 +25,8 @@
<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>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" /> <ComboBox fx:id="langSelector" onAction="#changeLanguage" />

View file

@ -191,5 +191,14 @@ public class Recipe {
", preparationSteps=" + preparationSteps + ", preparationSteps=" + preparationSteps +
'}'; '}';
} }
public static Recipe getScaled(Recipe recipe, Double scale) {
List<RecipeIngredient> i = recipe.getIngredients().stream().map(ri -> switch (ri) {
case FormalIngredient f -> f.scaleBy(scale);
case VagueIngredient v -> v;
default -> throw new IllegalStateException("Unexpected value: " + ri);
}).toList();
return new Recipe(recipe.getId(), recipe.getName(), recipe.getLocale(), i, recipe.getPreparationSteps());
}
} }