From 1bfc103eccf2d446e11a5ca40e645a641ae5d9b8 Mon Sep 17 00:00:00 2001 From: Steven Liu Date: Thu, 15 Jan 2026 17:09:39 +0100 Subject: [PATCH] feat(client/nutrition): updated nutrition UI modelling Uses "idiomatic JavaFX" to model automatic update propagation by object view models. --- .../Ingredient/IngredientViewModel.java | 71 +++++++++++++++ .../IllegalInputFormatException.java | 7 ++ .../scenes/Ingredient/IngredientListCtrl.java | 5 ++ .../nutrition/NutritionDetailsCtrl.java | 88 +++++++++++++++++-- .../scenes/nutrition/NutritionDetails.fxml | 21 ++--- 5 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 client/src/main/java/client/Ingredient/IngredientViewModel.java create mode 100644 client/src/main/java/client/exception/IllegalInputFormatException.java diff --git a/client/src/main/java/client/Ingredient/IngredientViewModel.java b/client/src/main/java/client/Ingredient/IngredientViewModel.java new file mode 100644 index 0000000..90499a8 --- /dev/null +++ b/client/src/main/java/client/Ingredient/IngredientViewModel.java @@ -0,0 +1,71 @@ +package client.Ingredient; + +import commons.Ingredient; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.DoubleBinding; +import javafx.beans.property.*; + +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(); } +} + diff --git a/client/src/main/java/client/exception/IllegalInputFormatException.java b/client/src/main/java/client/exception/IllegalInputFormatException.java new file mode 100644 index 0000000..487b523 --- /dev/null +++ b/client/src/main/java/client/exception/IllegalInputFormatException.java @@ -0,0 +1,7 @@ +package client.exception; + +public class IllegalInputFormatException extends RuntimeException { + public IllegalInputFormatException(String message) { + super(message); + } +} diff --git a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java index 68f5e9a..b63df27 100644 --- a/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java +++ b/client/src/main/java/client/scenes/Ingredient/IngredientListCtrl.java @@ -14,6 +14,7 @@ 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 @@ -21,6 +22,9 @@ import java.util.Optional; public class IngredientListCtrl { private final ServerUtils server; + private final Logger logger = Logger.getLogger(IngredientListCtrl.class.getName()); + @FXML + private ListView ingredientListView; @FXML private NutritionDetailsCtrl nutritionDetailsCtrl; @FXML @@ -52,6 +56,7 @@ public class IngredientListCtrl { if (newValue == null) { return; } + logger.info("Selected ingredient " + newValue.getName() + ", propagating to Nutrition Details controller..."); nutritionDetailsCtrl.setItem(newValue); }); diff --git a/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java index cd8046f..8232b23 100644 --- a/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java +++ b/client/src/main/java/client/scenes/nutrition/NutritionDetailsCtrl.java @@ -1,18 +1,43 @@ 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.event.EventType; 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.IllegalFormatConversionException; +import java.util.IllegalFormatException; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; public class NutritionDetailsCtrl implements LocaleAware { + @FXML + public GridPane nutritionValueContainer; + private boolean visible; private final LocaleManager manager; - private Ingredient ingredient; + private final ServerUtils server; + private final SimpleObjectProperty ingredient = + new SimpleObjectProperty<>(new IngredientViewModel()); + private final Logger logger = Logger.getLogger(this.getClass().getName()); public Label ingredientName; public Label fatInputLabel; @@ -30,10 +55,45 @@ public class NutritionDetailsCtrl implements LocaleAware { @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() { @@ -47,16 +107,30 @@ public class NutritionDetailsCtrl implements LocaleAware { visible = !visible; } public void setItem(Ingredient ingredient) { - this.ingredient = ingredient; - this.ingredientName.setText(ingredient.getName()); - this.fatInputElement.setText(Double.toString(ingredient.getFatPer100g())); - this.proteinInputElement.setText(Double.toString(ingredient.getProteinPer100g())); - this.carbInputElement.setText(Double.toString(ingredient.getCarbsPer100g())); + 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)); + } } diff --git a/client/src/main/resources/client/scenes/nutrition/NutritionDetails.fxml b/client/src/main/resources/client/scenes/nutrition/NutritionDetails.fxml index 43beb7e..542058d 100644 --- a/client/src/main/resources/client/scenes/nutrition/NutritionDetails.fxml +++ b/client/src/main/resources/client/scenes/nutrition/NutritionDetails.fxml @@ -13,18 +13,15 @@ fx:id="nutritionDetails" visible="false">