Merge branch 'AddOverview' into 'main'

last points of 4.5

Closes #83 and #81

See merge request cse1105/2025-2026/teams/csep-team-76!92
This commit is contained in:
Zhongheng Liu 2026-01-23 15:13:42 +01:00
commit 3e8442e517
6 changed files with 419 additions and 22 deletions

View file

@ -24,6 +24,8 @@ import java.util.function.Consumer;
import javafx.beans.binding.Bindings; import javafx.beans.binding.Bindings;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
import javafx.scene.control.Label; import javafx.scene.control.Label;
@ -37,6 +39,8 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.DirectoryChooser; import javafx.stage.DirectoryChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
/** /**
* Controller for the recipe detail view. * Controller for the recipe detail view.
@ -132,7 +136,6 @@ public class RecipeDetailCtrl implements LocaleAware {
* *
* @throws IOException Upon invalid recipe response. * @throws IOException Upon invalid recipe response.
* @throws InterruptedException Upon request interruption. * @throws InterruptedException Upon request interruption.
*
* @see FoodpalApplicationCtrl#refresh() * @see FoodpalApplicationCtrl#refresh()
*/ */
private void refresh() throws IOException, InterruptedException { private void refresh() throws IOException, InterruptedException {
@ -171,9 +174,9 @@ public class RecipeDetailCtrl implements LocaleAware {
this.recipeView = new ScalableRecipeView(recipe, scale); this.recipeView = new ScalableRecipeView(recipe, scale);
// TODO i18n // TODO i18n
inferredKcalLabel.textProperty().bind(Bindings.createStringBinding(() -> inferredKcalLabel.textProperty().bind(Bindings.createStringBinding(() ->
String.format("Inferred %.1f kcal/100g for this recipe", String.format("Inferred %.1f kcal/100g for this recipe",
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()); recipeView.servingsProperty().set(servingsSpinner.getValue());
inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding( inferredServeSizeLabel.textProperty().bind(Bindings.createStringBinding(
@ -197,7 +200,7 @@ public class RecipeDetailCtrl implements LocaleAware {
* @param recipeConsumer The helper function to use when updating the recipe - * @param recipeConsumer The helper function to use when updating the recipe -
* how to update it * how to update it
* @return The created callback to use in a setUpdateCallback or related * @return The created callback to use in a setUpdateCallback or related
* function * function
*/ */
private <T> Consumer<T> createUpdateRecipeCallback(BiConsumer<Recipe, T> recipeConsumer) { private <T> Consumer<T> createUpdateRecipeCallback(BiConsumer<Recipe, T> recipeConsumer) {
return recipes -> { return recipes -> {
@ -337,6 +340,7 @@ public class RecipeDetailCtrl implements LocaleAware {
PrintExportService.exportToFile(recipeText, dirPath, filename); PrintExportService.exportToFile(recipeText, dirPath, filename);
} }
} }
/** /**
* Toggles the favourite status of the currently viewed recipe in the * Toggles the favourite status of the currently viewed recipe in the
* application configuration and writes the changes to disk. * application configuration and writes the changes to disk.
@ -425,12 +429,36 @@ public class RecipeDetailCtrl implements LocaleAware {
langSelector.getItems().addAll(Config.languages); langSelector.getItems().addAll(Config.languages);
} }
@FXML
public void handleAddAllToShoppingList(ActionEvent actionEvent) { public void handleAddAllToShoppingList(ActionEvent actionEvent) {
System.out.println("handleAddAllToShoppingList"); Recipe ingredientSource = (recipeView != null) ? recipeView.getScaled() : recipe;
// TODO BACKLOG Add overview screen
recipe.getIngredients().stream() var ingredients = ingredientSource.getIngredients().stream()
.filter(x -> x.getClass().equals(FormalIngredient.class)) .map(ri -> {
.map(FormalIngredient.class::cast) if (ri instanceof FormalIngredient fi) {
.forEach(x -> shoppingListService.putIngredient(x, recipe)); return fi;
}
return new FormalIngredient(
ri.getIngredient(),
1,
""
);
})
.toList();
var pair = client.UI.getFXML().load(
client.scenes.shopping.AddOverviewCtrl.class,
"client", "scenes", "shopping", "AddOverview.fxml"
);
var ctrl = pair.getKey();
ctrl.setContext(recipe, ingredients);
Stage stage = new Stage();
stage.setTitle("Add to shopping list");
stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner(((Node) actionEvent.getSource()).getScene().getWindow());
stage.setScene(new Scene(pair.getValue()));
stage.showAndWait();
} }
} }

View file

@ -0,0 +1,244 @@
package client.scenes.shopping;
import client.service.ShoppingListService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import commons.FormalIngredient;
import commons.Ingredient;
import commons.Recipe;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.stage.Stage;
import javafx.util.converter.DoubleStringConverter;
import java.util.List;
import java.util.Optional;
public class AddOverviewCtrl implements LocaleAware {
private final ShoppingListService shoppingListService;
private final LocaleManager localeManager;
private String sourceRecipeName;
private final ObservableList<AddOverviewRow> rows = FXCollections.observableArrayList();
@FXML
private TableView<AddOverviewRow> overviewTable;
@FXML
private TableColumn<AddOverviewRow, String> nameColumn;
@FXML
private TableColumn<AddOverviewRow, Double> amountColumn;
@FXML
private TableColumn<AddOverviewRow, String> unitColumn;
@FXML
public void initialize() {
initializeComponents();
}
private static final List<String> DEFAULT_UNITS =
List.of("", "g", "kg", "ml", "l", "tbsp");
@Inject
public AddOverviewCtrl(ShoppingListService shoppingListService, LocaleManager localeManager) {
this.shoppingListService = shoppingListService;
this.localeManager = localeManager;
}
@Override
public void initializeComponents() {
overviewTable.setEditable(true);
nameColumn.setCellValueFactory(c -> c.getValue().nameProperty());
amountColumn.setCellValueFactory(c -> c.getValue().amountProperty().asObject());
unitColumn.setCellValueFactory(c -> c.getValue().unitProperty());
nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
nameColumn.setOnEditCommit(e -> e.getRowValue().setName(e.getNewValue()));
amountColumn.setCellFactory(TextFieldTableCell.forTableColumn(new DoubleStringConverter()));
amountColumn.setOnEditCommit(e -> e.getRowValue().setAmount(
e.getNewValue() == null ? 0.0 : e.getNewValue()
));
var unitOptions = FXCollections.observableArrayList(DEFAULT_UNITS);
unitColumn.setCellFactory(ComboBoxTableCell.forTableColumn(unitOptions));
unitColumn.setOnEditCommit(e -> e.getRowValue().setUnit(e.getNewValue()));
overviewTable.setItems(rows);
}
@Override
public void updateText() {
}
@Override
public LocaleManager getLocaleManager() {
return localeManager;
}
public void setContext(Recipe recipe, List<FormalIngredient> ingredients) {
this.sourceRecipeName = recipe == null ? null : recipe.getName();
rows.clear();
for (FormalIngredient fi : ingredients) {
rows.add(AddOverviewRow.fromFormalIngredient(fi));
}
}
@FXML
private void handleAddRow() {
TextInputDialog nameDialog = new TextInputDialog();
nameDialog.setTitle("Add item");
nameDialog.setHeaderText("Add an ingredient");
nameDialog.setContentText("Name:");
Optional<String> nameOpt = nameDialog.showAndWait();
if (nameOpt.isEmpty() || nameOpt.get().isBlank()) {
return;
}
TextInputDialog amountDialog = new TextInputDialog("0");
amountDialog.setTitle("Add item");
amountDialog.setHeaderText("Amount");
amountDialog.setContentText("Amount (number):");
double amount = 0.0;
Optional<String> amountOpt = amountDialog.showAndWait();
if (amountOpt.isPresent()) {
try {
amount = Double.parseDouble(amountOpt.get().trim());
} catch (NumberFormatException ignored) {
amount = 0.0;
}
}
rows.add(AddOverviewRow.arbitrary(nameOpt.get().trim(), amount, ""));
overviewTable.getSelectionModel().selectLast();
overviewTable.scrollTo(rows.size() - 1);
}
@FXML
private void handleRemoveSelected() {
AddOverviewRow selected = overviewTable.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
}
rows.remove(selected);
}
@FXML
private void handleConfirm() {
for (AddOverviewRow row : rows) {
FormalIngredient fi = row.toFormalIngredient();
if (sourceRecipeName == null || sourceRecipeName.isBlank()) {
shoppingListService.putIngredient(fi);
} else {
shoppingListService.putIngredient(fi, sourceRecipeName);
}
}
closeWindow();
}
@FXML
private void handleCancel() {
closeWindow();
}
private void closeWindow() {
Stage stage = (Stage) overviewTable.getScene().getWindow();
stage.close();
}
public static class AddOverviewRow {
private Ingredient backingIngredient;
private final StringProperty name = new SimpleStringProperty("");
private final DoubleProperty amount = new SimpleDoubleProperty(0.0);
private final StringProperty unit = new SimpleStringProperty("");
public static AddOverviewRow fromFormalIngredient(FormalIngredient fi) {
AddOverviewRow r = new AddOverviewRow();
r.backingIngredient = fi.getIngredient();
r.name.set(fi.getIngredient().getName());
r.amount.set(fi.getAmount());
r.unit.set(fi.getUnitSuffix() == null ? "" : fi.getUnitSuffix());
return r;
}
public static AddOverviewRow arbitrary(String name, double amount, String unit) {
AddOverviewRow r = new AddOverviewRow();
r.backingIngredient = null;
r.name.set(name == null ? "" : name);
r.amount.set(amount);
r.unit.set(unit == null ? "" : unit);
return r;
}
public FormalIngredient toFormalIngredient() {
Ingredient ing = backingIngredient;
if (ing == null) {
ing = new Ingredient(getName(), 0.0, 0.0, 0.0);
} else {
ing.setName(getName());
}
return new FormalIngredient(ing, getAmount(), getUnit());
}
public StringProperty nameProperty() {
return name;
}
public DoubleProperty amountProperty() {
return amount;
}
public StringProperty unitProperty() {
return unit;
}
public String getName() {
return name.get();
}
public void setName(String name) {
this.name.set(name == null ? "" : name);
}
public double getAmount() {
return amount.get();
}
public void setAmount(double amount) {
this.amount.set(amount);
}
public String getUnit() {
return unit.get();
}
public void setUnit(String unit) {
this.unit.set(unit == null ? "" : unit);
}
}
}

View file

@ -4,6 +4,7 @@ import client.UI;
import client.service.ShoppingListService; import client.service.ShoppingListService;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.PrintExportService;
import com.google.inject.Inject; import com.google.inject.Inject;
import commons.FormalIngredient; import commons.FormalIngredient;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
@ -11,11 +12,14 @@ import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.stage.FileChooser;
import javafx.stage.Modality; import javafx.stage.Modality;
import javafx.stage.Stage; import javafx.stage.Stage;
import javafx.util.Pair; import javafx.util.Pair;
import java.io.File;
import java.util.Optional; import java.util.Optional;
public class ShoppingListCtrl implements LocaleAware { public class ShoppingListCtrl implements LocaleAware {
@ -47,10 +51,9 @@ public class ShoppingListCtrl implements LocaleAware {
public void initializeComponents() { public void initializeComponents() {
this.shoppingListView.setEditable(true); this.shoppingListView.setEditable(true);
this.shoppingListView.setCellFactory(l -> new ShoppingListCell()); this.shoppingListView.setCellFactory(l -> new ShoppingListCell());
this.shoppingListView.getItems().setAll( this.shoppingListView.setItems(this.shopping.getViewModel().getListItems());
this.shopping.getItems()
);
} }
private void refreshList() { private void refreshList() {
this.shoppingListView.getItems().setAll( this.shoppingListView.getItems().setAll(
this.shopping.getItems() this.shopping.getItems()
@ -63,14 +66,14 @@ public class ShoppingListCtrl implements LocaleAware {
"client", "scenes", "shopping", "ShoppingListItemAddModal.fxml"); "client", "scenes", "shopping", "ShoppingListItemAddModal.fxml");
root.getKey().setNewValueConsumer(fi -> { root.getKey().setNewValueConsumer(fi -> {
this.shopping.putIngredient(fi); this.shopping.putIngredient(fi);
refreshList();
}); });
stage.setScene(new Scene(root.getValue())); stage.setScene(new Scene(root.getValue()));
stage.setTitle("My modal window"); stage.setTitle("My modal window");
stage.initModality(Modality.WINDOW_MODAL); stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner( stage.initOwner(
((Node)actionEvent.getSource()).getScene().getWindow() ); ((Node)actionEvent.getSource()).getScene().getWindow() );
stage.show(); stage.showAndWait();
} }
public void handleRemoveItem(ActionEvent actionEvent) { public void handleRemoveItem(ActionEvent actionEvent) {
@ -78,4 +81,39 @@ public class ShoppingListCtrl implements LocaleAware {
this.shopping.getItems().remove(x); this.shopping.getItems().remove(x);
refreshList(); refreshList();
} }
public void handleReset(ActionEvent actionEvent) {
shopping.reset();
}
public void handlePrint(ActionEvent actionEvent) {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Shopping List");
fileChooser.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Text Files", "*.txt")
);
fileChooser.setInitialFileName("shopping-list.txt");
File file = fileChooser.showSaveDialog(
((Node) actionEvent.getSource()).getScene().getWindow()
);
if (file == null) {
return;
}
try {
PrintExportService.exportToFile(
shopping.makePrintable(),
file.getParentFile().toPath(),
file.getName()
);
} catch (Exception e) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText("Failed to save shopping list");
alert.setContentText(e.getMessage());
alert.showAndWait();
}
}
} }

View file

@ -37,20 +37,45 @@ public class ShoppingListServiceImpl extends ShoppingListService {
@Override @Override
public void putArbitraryItem(String name) { public void putArbitraryItem(String name) {
if (name == null || name.isBlank()) {
return;
}
var ingredient = new commons.Ingredient(name.trim(), 0.0, 0.0, 0.0);
var fi = new commons.FormalIngredient(ingredient, 0.0, "");
getViewModel().getListItems().add(new Pair<>(fi, Optional.empty()));
} }
@Override @Override
public FormalIngredient purgeIngredient(Long id) { public FormalIngredient purgeIngredient(Long id) {
if (id == null) {
return null;
}
for (var item : getViewModel().getListItems()) {
FormalIngredient fi = item.getKey();
if (fi != null && fi.getId() != null && fi.getId().equals(id)) {
getViewModel().getListItems().remove(item);
return fi;
}
}
return null; return null;
} }
@Override @Override
public FormalIngredient purgeIngredient(String ingredientName) { public FormalIngredient purgeIngredient(String ingredientName) {
FormalIngredient fi = getViewModel().getListItems().stream() if (ingredientName == null) {
.filter(i -> return null;
i.getKey().getIngredient().getName().equals(ingredientName)) }
.findFirst().orElseThrow(NullPointerException::new).getKey();
for (var item : getViewModel().getListItems()) {
FormalIngredient fi = item.getKey();
if (fi != null
&& fi.getIngredient() != null
&& ingredientName.equals(fi.getIngredient().getName())) {
getViewModel().getListItems().remove(item);
return fi;
}
}
return null; return null;
} }
@ -66,6 +91,34 @@ public class ShoppingListServiceImpl extends ShoppingListService {
@Override @Override
public String makePrintable() { public String makePrintable() {
return "TODO"; StringBuilder sb = new StringBuilder();
for (var item : getViewModel().getListItems()) {
FormalIngredient ingredient = item.getKey();
Optional<String> source = item.getValue();
if (ingredient == null || ingredient.getIngredient() == null) {
continue;
}
sb.append(ingredient.getIngredient().getName());
if (ingredient.getAmount() > 0) {
sb.append(" - ")
.append(ingredient.getAmount());
if (ingredient.getUnitSuffix() != null && !ingredient.getUnitSuffix().isBlank()) {
sb.append(ingredient.getUnitSuffix());
}
}
source.ifPresent(recipe ->
sb.append(" (").append(recipe).append(")")
);
sb.append(System.lineSeparator());
}
return sb.toString();
} }
} }

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<AnchorPane xmlns="http://javafx.com/javafx/25"
xmlns:fx="http://javafx.com/fxml/1"
fx:controller="client.scenes.shopping.AddOverviewCtrl"
prefHeight="420.0" prefWidth="720.0">
<VBox spacing="10.0" AnchorPane.topAnchor="10.0" AnchorPane.leftAnchor="10.0"
AnchorPane.rightAnchor="10.0" AnchorPane.bottomAnchor="10.0">
<Label text="Review ingredients before adding" />
<TableView fx:id="overviewTable" editable="true" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="nameColumn" text="Ingredient" prefWidth="360.0"/>
<TableColumn fx:id="amountColumn" text="Amount" prefWidth="140.0"/>
<TableColumn fx:id="unitColumn" text="Unit" prefWidth="140.0"/>
</columns>
</TableView>
<HBox spacing="10.0">
<Button text="Add item" onAction="#handleAddRow"/>
<Button text="Remove selected" onAction="#handleRemoveSelected"/>
<Pane HBox.hgrow="ALWAYS"/>
<Button text="Cancel" onAction="#handleCancel"/>
<Button text="Confirm &amp; Add" onAction="#handleConfirm"/>
</HBox>
</VBox>
</AnchorPane>

View file

@ -18,6 +18,8 @@
<HBox> <HBox>
<Button onAction="#handleAddItem">Add</Button> <Button onAction="#handleAddItem">Add</Button>
<Button onAction="#handleRemoveItem">Delete</Button> <Button onAction="#handleRemoveItem">Delete</Button>
<Button onAction="#handlePrint">Print</Button>
<Button onAction="#handleReset">Reset</Button>
</HBox> </HBox>
</VBox> </VBox>
</TitledPane> </TitledPane>