feat(client/nutrition): updated nutrition UI modelling

Uses "idiomatic JavaFX" to model automatic update propagation by object
view models.
This commit is contained in:
Zhongheng Liu 2026-01-15 17:09:39 +01:00
commit 1bfc103ecc
Signed by: steven
GPG key ID: F69B980899C1C09D
5 changed files with 173 additions and 19 deletions

View file

@ -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(); }
}

View file

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

View file

@ -14,6 +14,7 @@ import javafx.stage.Stage;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger;
//TODO and check for capital letter milk and MILK are seen as different //TODO and check for capital letter milk and MILK are seen as different
@ -21,6 +22,9 @@ import java.util.Optional;
public class IngredientListCtrl { public class IngredientListCtrl {
private final ServerUtils server; private final ServerUtils server;
private final Logger logger = Logger.getLogger(IngredientListCtrl.class.getName());
@FXML
private ListView<Ingredient> ingredientListView;
@FXML @FXML
private NutritionDetailsCtrl nutritionDetailsCtrl; private NutritionDetailsCtrl nutritionDetailsCtrl;
@FXML @FXML
@ -52,6 +56,7 @@ public class IngredientListCtrl {
if (newValue == null) { if (newValue == null) {
return; return;
} }
logger.info("Selected ingredient " + newValue.getName() + ", propagating to Nutrition Details controller...");
nutritionDetailsCtrl.setItem(newValue); nutritionDetailsCtrl.setItem(newValue);
}); });

View file

@ -1,18 +1,43 @@
package client.scenes.nutrition; package client.scenes.nutrition;
import client.Ingredient.IngredientViewModel;
import client.exception.IllegalInputFormatException;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils;
import com.google.inject.Inject; import com.google.inject.Inject;
import commons.Ingredient; 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.fxml.FXML;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.TextField; 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.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 { public class NutritionDetailsCtrl implements LocaleAware {
@FXML
public GridPane nutritionValueContainer;
private boolean visible; private boolean visible;
private final LocaleManager manager; private final LocaleManager manager;
private Ingredient ingredient; 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 ingredientName;
public Label fatInputLabel; public Label fatInputLabel;
@ -30,10 +55,45 @@ public class NutritionDetailsCtrl implements LocaleAware {
@Inject @Inject
public NutritionDetailsCtrl( public NutritionDetailsCtrl(
LocaleManager manager LocaleManager manager,
ServerUtils server
) { ) {
this.manager = manager; 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 @Override
public void updateText() { public void updateText() {
@ -47,16 +107,30 @@ public class NutritionDetailsCtrl implements LocaleAware {
visible = !visible; visible = !visible;
} }
public void setItem(Ingredient ingredient) { public void setItem(Ingredient ingredient) {
this.ingredient = ingredient; this.ingredient.get().updateFrom(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()));
setVisible(true); 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 @Override
public LocaleManager getLocaleManager() { public LocaleManager getLocaleManager() {
return manager; return manager;
} }
public void handleNutritionSaveClick(ActionEvent actionEvent) throws IOException, InterruptedException {
Ingredient newIngredient = updateIngredient();
this.ingredient.get().updateFrom(server.updateIngredient(newIngredient));
}
} }

View file

@ -13,18 +13,15 @@
fx:id="nutritionDetails" fx:id="nutritionDetails"
visible="false"> visible="false">
<Label fx:id="ingredientName" /> <Label fx:id="ingredientName" />
<HBox> <GridPane fx:id="nutritionValueContainer">
<Label fx:id="fatInputLabel">Fat: </Label> <Label GridPane.columnIndex="0" GridPane.rowIndex="0" fx:id="fatInputLabel">Fat: </Label>
<TextField fx:id="fatInputElement" /> <TextField GridPane.columnIndex="1" GridPane.rowIndex="0" fx:id="fatInputElement" />
</HBox> <Label GridPane.columnIndex="0" GridPane.rowIndex="1" fx:id="proteinInputLabel">Protein: </Label>
<HBox> <TextField GridPane.columnIndex="1" GridPane.rowIndex="1" fx:id="proteinInputElement" />
<Label fx:id="proteinInputLabel">Protein: </Label> <Label GridPane.columnIndex="0" GridPane.rowIndex="2" fx:id="carbInputLabel">Carbohydrates: </Label>
<TextField fx:id="proteinInputElement" /> <TextField GridPane.columnIndex="1" GridPane.rowIndex="2" fx:id="carbInputElement" />
</HBox> </GridPane>
<HBox> <Button onAction="#handleNutritionSaveClick">Save values</Button>
<Label fx:id="carbInputLabel">Carbohydrates: </Label>
<TextField fx:id="carbInputElement" />
</HBox>
<Label fx:id="estimatedKcalLabel">Estimated: 0kcal</Label> <Label fx:id="estimatedKcalLabel">Estimated: 0kcal</Label>
<Label fx:id="usageLabel">Not used in any recipes</Label> <Label fx:id="usageLabel">Not used in any recipes</Label>
</VBox> </VBox>