Merge branch 'main' into 'fix/wiring_print_button_with_functionality'

# Conflicts: fixed import merge conflict
#   client/src/main/java/client/scenes/recipe/RecipeDetailCtrl.java
This commit is contained in:
Rithvik Sriram 2026-01-14 22:15:53 +01:00
commit 6caf4eaa61
35 changed files with 1310 additions and 203 deletions

View file

@ -1,18 +1,62 @@
# CSEP Template Project
# CSEP FoodPal Application (Team 76)
This repository contains the template for the CSE project. Please extend this README.md with sufficient instructions that will illustrate for your TA and the course staff how they can run your project.
## Usage
To run the template project from the command line, you either need to have [Maven](https://maven.apache.org/install.html) installed on your local system (`mvn`) or you need to use the Maven wrapper (`mvnw`). You can then execute
The project uses Java 25. Make sure you have the correct Java version installed.
mvn -pl server -am spring-boot:run
### Client
to run the server and
The client needs to be launched **after** a server is already running, see Usage.Server section:
```
mvn -pl client -am javafx:run
```
to run the client. Please note that the server needs to be running, before you can start the client.
### Server
Get the template project running from the command line first to ensure you have the required tools on your sytem.
By default, the server listens to the port `8080`.
[TODO(1)]:: Configurable port.
Once it is working, you can try importing the project into your favorite IDE. Especially the client is a bit more tricky to set up there due to the dependency on a JavaFX SDK.
To help you get started, you can find additional instructions in the corresponding README of the client project.
```
mvn -pl server -am spring-boot:run
```
## Features
- Recipe tracking in an intuitive GUI.
- Ability to input arbitrary amounts of an ingredient in a recipe. The design is very human. See Manual.Ingredients for more usage details.
- Native localization in more than 2, and less than 4 languages.
- Configurable via JSON, See Manual.Configuration.
## Manual
### Configuration
The configuration is with JSON, read from `config.json` in the working directory. We illustrate an example configuration below.
```json
{
"language": "en",
"serverUrl": "http://localhost:8080",
"favourites": [
1,
],
"shoppingList": [
"Ingredient A",
],
}
```
#### Options
- `language: string`
- One of \[en, nl, pl\] (as of Jan 11 2026)
- `serverUrl: string`
- The host that the FoodPal server runs on, see configuration example.
- `favourites: [number]`
- The list of recipe IDs that the user has marked as favourite.
- `shoppingList: [string]`
- The list of ingredients that the user has in their shopping list.
### Ingredients
- To input a **formal** ingredient, you write the numeric amount in the first input box, then the Unit in the selection dropdown, and then write the name of the ingredient, e.g. salt, apples, etc. should it be not visible already on the platform.
- To input an **informal** ingredient, describe the amount in the first input box, like "some of", or "a sprinkle of", then select "<NONE>" in the unit selection box, and write the name of your ingredient or pick from one of the availables from the dropdown.

View file

@ -0,0 +1,131 @@
package client.Ingredient;
import commons.Ingredient;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Alert.AlertType;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.ListView;
import javafx.scene.control.Button;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
public class IngredientController {
@FXML
private ListView<Ingredient> ingredientListView;
@FXML
private Button deleteButton;
private final RestTemplate restTemplate = new RestTemplate(); // Simplified REST client
@FXML
private void handleDeleteIngredient(ActionEvent event) {
// Get selected ingredient
Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem();
if (selectedIngredient == null) {
showError("No ingredient selected", "Please select an ingredient to delete.");
return;
}
// Check if the ingredient is used in any recipe
checkIngredientUsage(selectedIngredient);
}
private void checkIngredientUsage(Ingredient ingredient) {
String url = "http://localhost:8080/api/ingredients/" + ingredient.getId() + "/usage";
ResponseEntity<IngredientUsageResponse> response = restTemplate.getForEntity(url, IngredientUsageResponse.class);
if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
showError("Ingredient not found", "The ingredient does not exist.");
return;
}
IngredientUsageResponse usageResponse = response.getBody();
long usedInRecipes = usageResponse != null ? usageResponse.getUsedInRecipes() : 0;
if (usedInRecipes > 0) {
// If ingredient is in use, show warning to the user
showWarningDialog(ingredient, usedInRecipes);
} else {
// delete if not in use
deleteIngredient(ingredient);
}
}
private void showWarningDialog(Ingredient ingredient, long usedInRecipes) {
Alert alert = new Alert(AlertType.WARNING);
alert.setTitle("Warning");
alert.setHeaderText("Ingredient in Use");
alert.setContentText("The ingredient '" + ingredient.getName() + "' is used in " + usedInRecipes + " recipe(s). Are you sure you want to delete it?");
ButtonType deleteButton = new ButtonType("Delete Anyway");
ButtonType cancelButton = new ButtonType("Cancel");
alert.getButtonTypes().setAll(deleteButton, cancelButton);
alert.showAndWait().ifPresent(response -> {
if (response == deleteButton) {
// delete if the user confirms
deleteIngredient(ingredient);
}
});
}
private void deleteIngredient(Ingredient ingredient) {
String url = "http://localhost:8080/api/ingredients/" + ingredient.getId();
restTemplate.delete(url);
showConfirmation("Deletion Successful", "The ingredient '" + ingredient.getName() + "' has been successfully deleted.");
// refresh
refreshIngredientList();
}
private void showError(String title, String message) {
Alert alert = new Alert(AlertType.ERROR);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void showConfirmation(String title, String message) {
Alert alert = new Alert(AlertType.INFORMATION);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void refreshIngredientList() {
// Refresh
ingredientListView.getItems().clear();
}
// Inner class for usage response
public static class IngredientUsageResponse {
private long ingredientId;
private long usedInRecipes;
public long getIngredientId() {
return ingredientId;
}
public void setIngredientId(long ingredientId) {
this.ingredientId = ingredientId;
}
public long getUsedInRecipes() {
return usedInRecipes;
}
public void setUsedInRecipes(long usedInRecipes) {
this.usedInRecipes = usedInRecipes;
}
}
}

View file

@ -26,4 +26,8 @@ public class UI extends Application {
var mainCtrl = INJECTOR.getInstance(MainCtrl.class);
mainCtrl.setup(primaryStage, foodpal);
}
public static MyFXML getFXML() {
return FXML;
}
}

View file

@ -10,6 +10,7 @@ import java.util.logging.Logger;
import java.util.stream.Collectors;
import client.exception.InvalidModificationException;
import client.scenes.recipe.IngredientsPopupCtrl;
import client.scenes.recipe.RecipeDetailCtrl;
import client.utils.Config;
@ -20,6 +21,7 @@ import client.utils.LocaleManager;
import client.utils.ServerUtils;
import client.utils.WebSocketDataService;
import client.utils.WebSocketUtils;
import commons.Ingredient;
import commons.Recipe;
import commons.ws.Topics;
@ -36,17 +38,20 @@ import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleButton;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
public class FoodpalApplicationCtrl implements LocaleAware {
private final ServerUtils server;
private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager;
private final WebSocketDataService<Long, Recipe> dataService;
private final Logger logger = Logger.getLogger(FoodpalApplicationCtrl.class.getName());
@FXML
private RecipeDetailCtrl recipeDetailController;
@ -60,6 +65,9 @@ public class FoodpalApplicationCtrl implements LocaleAware {
@FXML
public ListView<Recipe> recipeList;
@FXML
private ListView<Ingredient> ingredientListView;
@FXML
private Button addRecipeButton;
@ -93,6 +101,7 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.configService = configService;
this.dataService = recipeDataService;
setupDataService();
logger.info("WebSocket processor initialized.");
initializeWebSocket();
@ -268,7 +277,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
public void refresh() {
List<Recipe> recipes;
try {
recipes = server.getRecipesFiltered(searchBarController.getFilter());
recipes = server.getRecipesFiltered(
searchBarController.getFilter(),
this.configService.getConfig().getRecipeLanguages()
);
} catch (IOException | InterruptedException e) {
recipes = Collections.emptyList();
String msg = "Failed to load recipes: " + e.getMessage();
@ -400,6 +412,139 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.recipeDetailController.refreshFavouriteButton();
this.recipeDetailController.setVisible(!recipeList.getItems().isEmpty());
}
//Delete Ingredient button click
@FXML
private void handleDeleteIngredient() {
// Get selected ingredient
Ingredient selectedIngredient = ingredientListView.getSelectionModel().getSelectedItem();
if (selectedIngredient == null) {
// Show an error message if no ingredient is selected
showError("No ingredient selected", "Please select an ingredient to delete.");
return;
}
// Check if the ingredient is used in any recipe
checkIngredientUsage(selectedIngredient);
}
// Check if ingredient is used in any recipe before deleting
private void checkIngredientUsage(Ingredient ingredient) {
try {
long usageCount = server.getIngredientUsage(ingredient.getId()); // Check ingredient usage via ServerUtils
if (usageCount > 0) {
// If ingredient is used, show a warning dialog
showWarningDialog(ingredient, usageCount);
} else {
// If not used, delete
deleteIngredient(ingredient);
}
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to check ingredient usage: " + e.getMessage());
}
}
private void deleteIngredient(Ingredient ingredient) {
try {
server.deleteIngredient(ingredient.getId()); // Call ServerUtils to delete the ingredient
showConfirmation("Success", "Ingredient '" + ingredient.getName() + "' has been deleted.");
refreshIngredientList(); // refresh the ingredient list
} catch (IOException | InterruptedException e) {
showError("Error", "Failed to delete ingredient: " + e.getMessage());
}
}
private void showWarningDialog(Ingredient ingredient, long usedInRecipes) {
Alert alert = new Alert(Alert.AlertType.WARNING);
}
private void showError(String title, String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void showConfirmation(String title, String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle(title);
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
}
private void refreshIngredientList() {
// Refresh
ingredientListView.getItems().clear();
}
@FXML
private void handleAddIngredient() {
//ask the user for the ingredient name
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Add Ingredient");
dialog.setHeaderText("Enter the ingredient name:");
dialog.setContentText("Ingredient:");
// Wait for the user to enter a value
Optional<String> result = dialog.showAndWait();
result.ifPresent(name -> {
// Create a new Ingredient object
Ingredient newIngredient = new Ingredient();
newIngredient.setName(name);
// Add the new ingredient to the ListView
ingredientListView.getItems().add(newIngredient);
});
}
// display ingredient name
@FXML
private void initializeIngredients() {
ingredientListView.setCellFactory(list -> new ListCell<Ingredient>() {
@Override
protected void updateItem(Ingredient item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
} else {
setText(item.getName()); // Display the ingredient name in the ListView
}
}
});
}
@FXML
private void openIngredientsPopup() {
try {
var pair = client.UI.getFXML().load(
IngredientsPopupCtrl.class,
"client", "scenes", "recipe", "IngredientsPopup.fxml"
);
var root = pair.getValue();
var stage = new javafx.stage.Stage();
stage.setTitle("Ingredients");
stage.initModality(javafx.stage.Modality.APPLICATION_MODAL);
stage.setScene(new javafx.scene.Scene(root));
stage.showAndWait();
} catch (Exception e) {
var alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText("Failed to open ingredients popup");
alert.setContentText(e.getMessage());
alert.showAndWait();
e.printStackTrace();
}
}
}
@ -410,3 +555,8 @@ public class FoodpalApplicationCtrl implements LocaleAware {

View file

@ -0,0 +1,87 @@
package client.scenes;
import client.utils.ConfigService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import com.google.inject.Inject;
import javafx.fxml.FXML;
import javafx.scene.control.CheckMenuItem;
import javafx.scene.control.MenuButton;
import java.util.ArrayList;
import java.util.List;
public class LanguageFilterCtrl implements LocaleAware {
private final LocaleManager manager;
private final ConfigService configService;
private final FoodpalApplicationCtrl appCtrl;
@Inject
public LanguageFilterCtrl(LocaleManager manager, ConfigService configService, FoodpalApplicationCtrl appCtrl) {
this.manager = manager;
this.configService = configService;
this.appCtrl = appCtrl;
}
@FXML
MenuButton langFilterMenu;
List<String> selectedLanguages = new ArrayList<>();
private String getSelectedLanguagesDisplay() {
String joined = String.join(", ", selectedLanguages);
if (joined.isEmpty()) {
return "none";
} else {
return joined;
}
}
private void updateMenuButtonDisplay() {
langFilterMenu.setText(getLocaleString("menu.label.selected-langs") + ": " + this.getSelectedLanguagesDisplay());
}
@Override
public void updateText() {
this.updateMenuButtonDisplay();
}
@Override
public LocaleManager getLocaleManager() {
return this.manager;
}
public void initializeComponents() {
var items = this.langFilterMenu.getItems();
final List<String> languages = List.of("en", "nl", "pl");
this.selectedLanguages = this.configService.getConfig().getRecipeLanguages();
this.updateMenuButtonDisplay();
items.clear();
languages.forEach(lang -> {
CheckMenuItem it = new CheckMenuItem(lang);
if (selectedLanguages.contains(it.getText())) {
it.setSelected(true);
}
it.selectedProperty().addListener((observable, _, value) -> {
if (value) {
selectedLanguages.add(it.getText());
} else {
selectedLanguages.remove(it.getText());
}
configService.save();
selectedLanguages.sort(String::compareTo);
appCtrl.refresh();
this.updateMenuButtonDisplay();
});
items.add(it);
});
}
}

View file

@ -1,5 +1,6 @@
package client.scenes;
import client.utils.ConfigService;
import client.utils.LocaleAware;
import client.utils.LocaleManager;
import client.utils.ServerUtils;
@ -28,6 +29,7 @@ public class SearchBarCtrl implements LocaleAware {
private final LocaleManager localeManager;
private final ServerUtils serverUtils;
private final ConfigService configService;
private Consumer<List<Recipe>> onSearchCallback;
@ -41,9 +43,10 @@ public class SearchBarCtrl implements LocaleAware {
private Task<List<Recipe>> currentSearchTask = null;
@Inject
public SearchBarCtrl(LocaleManager localeManager, ServerUtils serverUtils) {
public SearchBarCtrl(LocaleManager localeManager, ServerUtils serverUtils, ConfigService configService) {
this.localeManager = localeManager;
this.serverUtils = serverUtils;
this.configService = configService;
}
@FXML
@ -96,7 +99,7 @@ public class SearchBarCtrl implements LocaleAware {
currentSearchTask = new Task<>() {
@Override
protected List<Recipe> call() throws IOException, InterruptedException {
return serverUtils.getRecipesFiltered(filter);
return serverUtils.getRecipesFiltered(filter, configService.getConfig().getRecipeLanguages());
}
};

View file

@ -75,6 +75,10 @@ public class IngredientListCell extends OrderedEditableListCell<RecipeIngredient
String name = nameInput.getText();
if (unit == null || !unit.isFormal()) {
String desc = amountInput.getText();
if (desc.isEmpty() || name.isEmpty()) {
// TODO printError() integration
return; // The user is forced to kindly try again until something valid comes up.
}
Ingredient newIngredient = new Ingredient(name, 0., 0., 0.);
commitEdit(new VagueIngredient(newIngredient, desc));
return;

View file

@ -0,0 +1,136 @@
package client.scenes.recipe;
import client.utils.ServerUtils;
import commons.Ingredient;
import jakarta.inject.Inject;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.control.TextInputDialog;
import javafx.stage.Stage;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
//TODO and check for capital letter milk and MILK are seen as different
public class IngredientsPopupCtrl {
private final ServerUtils server;
@FXML
private ListView<Ingredient> ingredientListView;
@Inject
public IngredientsPopupCtrl(ServerUtils server) {
this.server = server;
}
@FXML
public void initialize() {
ingredientListView.setCellFactory(list -> new ListCell<>() {
@Override
protected void updateItem(Ingredient item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
} else {
setText(item.getName());
}
}
});
refresh();
}
@FXML
private void addIngredient() {
TextInputDialog dialog = new TextInputDialog();
dialog.setTitle("Add Ingredient");
dialog.setHeaderText("Create a new ingredient");
dialog.setContentText("Name:");
Optional<String> result = dialog.showAndWait();
if (result.isEmpty()) {
return;
}
String name = result.get().trim();
if (name.isEmpty()) {
showError("Ingredient name cannot be empty.");
return;
}
try {
server.createIngredient(name); // calls POST /api/ingredients
refresh(); // reload list from server
} catch (IOException | InterruptedException e) {
showError("Failed to create ingredient: " + e.getMessage());
}
}
@FXML
private void refresh() {
try {
List<Ingredient> ingredients = server.getIngredients();
ingredientListView.getItems().setAll(ingredients);
} catch (IOException | InterruptedException e) {
showError("Failed to load ingredients: " + e.getMessage());
}
}
@FXML
private void deleteSelected() {
Ingredient selected = ingredientListView.getSelectionModel().getSelectedItem();
if (selected == null) {
showError("No ingredient selected.");
return;
}
try {
long usageCount = server.getIngredientUsage(selected.getId());
if (usageCount > 0) {
boolean proceed = confirmDeleteUsed(selected.getName(), usageCount);
if (!proceed) {
return;
}
}
server.deleteIngredient(selected.getId());
refresh();
} catch (IOException | InterruptedException e) {
showError("Failed to delete ingredient: " + e.getMessage());
}
}
@FXML
private void close() {
Stage stage = (Stage) ingredientListView.getScene().getWindow();
stage.close();
}
private boolean confirmDeleteUsed(String name, long usedInRecipes) {
Alert alert = new Alert(Alert.AlertType.WARNING);
alert.setTitle("Warning");
alert.setHeaderText("Ingredient in use");
alert.setContentText("Ingredient '" + name + "' is used in " + usedInRecipes
+ " recipe(s). Delete anyway?");
var delete = new javafx.scene.control.ButtonType("Delete Anyway");
var cancel = new javafx.scene.control.ButtonType("Cancel");
alert.getButtonTypes().setAll(delete, cancel);
return alert.showAndWait().orElse(cancel) == delete;
}
private void showError(String msg) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setHeaderText(null);
alert.setContentText(msg);
alert.showAndWait();
}
}

View file

@ -24,6 +24,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ComboBox;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
@ -82,6 +83,9 @@ public class RecipeDetailCtrl implements LocaleAware {
@FXML
private Button favouriteButton;
@FXML
private ComboBox<String> langSelector;
private ListView<Recipe> getParentRecipeList() {
return this.appCtrl.recipeList;
}
@ -143,6 +147,7 @@ public class RecipeDetailCtrl implements LocaleAware {
this.showName(recipe.getName());
this.ingredientListController.refetchFromRecipe(recipe);
this.stepListController.refetchFromRecipe(recipe);
this.langSelector.setValue(recipe.getLocale());
this.refreshFavouriteButton();
}
@ -331,6 +336,20 @@ public class RecipeDetailCtrl implements LocaleAware {
editableTitleArea.getChildren().add(nameLabel);
}
/**
* Switch the recipe's language.
*/
@FXML
void changeLanguage() {
recipe.setLocale(this.langSelector.getValue());
try {
server.updateRecipe(this.recipe);
} catch (IOException | InterruptedException e) {
throw new UpdateException("Error occurred when updating recipe locale!");
}
}
@Override
public void updateText() {
editRecipeTitleButton.setText(getLocaleString("menu.button.edit"));
@ -346,5 +365,7 @@ public class RecipeDetailCtrl implements LocaleAware {
@Override
public void initializeComponents() {
initStepsIngredientsList();
langSelector.getItems().addAll("en", "nl", "pl");
}
}

View file

@ -4,15 +4,18 @@ import java.util.ArrayList;
import java.util.List;
public class Config {
private String language = "en";
private List<String> recipeLanguages = new ArrayList<>();
private String serverUrl = "http://localhost:8080";
private List<Long> favourites = new ArrayList<>();
private List<String> shoppingList = new ArrayList<>();
public Config() {
this.language = "en";
this.serverUrl = "http://localhost:8080";
this.favourites = new ArrayList<>();
this.shoppingList = new ArrayList<>();
}
public String getLanguage() {
@ -66,5 +69,28 @@ public class Config {
public void removeFavourite(long recipeId) {
getFavourites().remove(recipeId);
}
/**
* Get a list of languages that should filter the displayed recipes.
*
* @return The desired languages the user would like to see.
*/
public List<String> getRecipeLanguages() {
return this.recipeLanguages;
}
/**
* Add a language to the list of filtering languages.
*/
public void addRecipeLanguage(String lang) {
this.recipeLanguages.add(lang);
}
/**
* Remove a language from the list of filtering languages.
*/
public void removeRecipeLanguage(String lang) {
this.recipeLanguages.remove(lang);
}
}

View file

@ -26,6 +26,7 @@ public class DefaultValueFactory {
return new Recipe(
null,
"Untitled recipe",
"en",
List.of(),
List.of());
}

View file

@ -3,6 +3,7 @@ package client.utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject;
import commons.Ingredient;
import commons.Recipe;
import commons.RecipeIngredient;
import jakarta.ws.rs.ProcessingException;
@ -39,9 +40,16 @@ public class ServerUtils {
* Gets all the recipes from the backend.
* @return a JSON string with all the recipes
*/
public List<Recipe> getRecipes() throws IOException, InterruptedException {
public List<Recipe> getRecipes(List<String> locales) throws IOException, InterruptedException {
String uri =
SERVER +
"/recipes" +
"?locales=" +
String.join(",", locales);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/recipes"))
.uri(URI.create(uri))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
@ -56,9 +64,9 @@ public class ServerUtils {
return list; // JSON string-> List<Recipe> (Jackson)
}
public List<Recipe> getRecipesFiltered(String filter) throws IOException, InterruptedException {
public List<Recipe> getRecipesFiltered(String filter, List<String> locales) throws IOException, InterruptedException {
// TODO: implement filtering on server side
return this.getRecipes();
return this.getRecipes(locales);
}
/**
@ -87,7 +95,7 @@ public class ServerUtils {
*/
public Recipe addRecipe(Recipe newRecipe) throws IOException, InterruptedException {
//Make sure the name of the newRecipe is unique
List<Recipe> allRecipes = getRecipes();
List<Recipe> allRecipes = getRecipes(List.of());
newRecipe.setId(null); // otherwise the id is the same as the original, and that's wrong
// now that each recipeIngredient has its own ID in the database,
// we set that to null too to force a new persist value on the server
@ -207,4 +215,84 @@ public class ServerUtils {
updateRecipe(recipe);
}
// how many ingredients are getting used in recipes
public long getIngredientUsage(long ingredientId) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients/" + ingredientId + "/usage"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != statusOK) {
throw new IOException("Failed to get usage for ingredient with id: " + ingredientId
+ " body: " + response.body());
}
record IngredientUsageResponse(long ingredientId, long usedInRecipes) {}
IngredientUsageResponse usage =
objectMapper.readValue(response.body(), IngredientUsageResponse.class);
return usage.usedInRecipes();
}
public void deleteIngredient(long ingredientId) throws IOException, InterruptedException {
// Send delete request to remove the ingredient
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients/" + ingredientId))
.DELETE()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != statusOK) {
throw new IOException("Failed to delete ingredient with id: " + ingredientId + " body: " + response.body());
}
logger.info("Successfully deleted ingredient with id: " + ingredientId);
}
//retrieves the list of ingredients saved to backend
public List<Ingredient> getIngredients() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != statusOK) {
throw new IOException("Failed to fetch ingredients. Server responds with: " + response.body());
}
return objectMapper.readValue(response.body(), new com.fasterxml.jackson.core.type.TypeReference<List<Ingredient>>() {});
}
//creates new ingredients in the ingredient list
public Ingredient createIngredient(String name) throws IOException, InterruptedException {
Ingredient ingredient = new Ingredient(name, 0.0, 0.0, 0.0);
String json = objectMapper.writeValueAsString(ingredient);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(SERVER + "/ingredients"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != statusOK) {
throw new IOException("Failed to create ingredient. Server responds with: " + response.body());
}
return objectMapper.readValue(response.body(), Ingredient.class);
}
}

View file

@ -44,9 +44,11 @@
<Label fx:id="recipesLabel" text="Recipes">
<font>
<Font name="System Bold" size="15.0" />
</font></Label>
</font>
</Label>
<fx:include source="SearchBar.fxml" fx:id="searchBar" />
<fx:include source="LanguageFilter.fxml" fx:id="langFilter" />
<ListView fx:id="recipeList" />
@ -56,6 +58,11 @@
<Button fx:id="cloneRecipeButton" mnemonicParsing="false" onAction="#cloneRecipe" text="Clone" />
<ToggleButton fx:id="favouritesOnlyToggle" text="Favourites" onAction="#toggleFavouritesView" />
</HBox>
<Button fx:id="manageIngredientsButton"
onAction="#openIngredientsPopup"
text="Ingredients..." />
</VBox>
</left>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.MenuButton?>
<MenuButton
mnemonicParsing="false"
text="Languages"
xmlns:fx="http://javafx.com/fxml/1"
xmlns="http://javafx.com/javafx/25"
fx:controller="client.scenes.LanguageFilterCtrl"
fx:id="langFilterMenu"
/>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ButtonBar?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.layout.VBox?>
<VBox xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="client.scenes.recipe.IngredientsPopupCtrl"
spacing="10" prefWidth="420" prefHeight="520">
<padding>
<Insets top="12" right="12" bottom="12" left="12"/>
</padding>
<Label text="Ingredients" style="-fx-font-size: 18px; -fx-font-weight: bold;"/>
<ListView fx:id="ingredientListView" VBox.vgrow="ALWAYS"/>
<ButtonBar>
<buttons>
<Button text="Add" onAction="#addIngredient"/>
<Button text="Refresh" onAction="#refresh"/>
<Button text="Delete" onAction="#deleteSelected"/>
<Button text="Close" onAction="#close"/>
</buttons>
</ButtonBar>
</VBox>

View file

@ -27,6 +27,8 @@
<Button fx:id="favouriteButton" onAction="#toggleFavourite" text="☆" />
</HBox>
<ComboBox fx:id="langSelector" onAction="#changeLanguage" />
<!-- Ingredients -->
<fx:include source="IngredientList.fxml" fx:id="ingredientList"
VBox.vgrow="ALWAYS" maxWidth="Infinity" />

View file

@ -25,6 +25,8 @@ menu.button.edit=Edit
menu.button.clone=Clone
menu.button.print=Print recipe
menu.label.selected-langs=Languages
lang.en.display=English
lang.nl.display=Dutch
lang.pl.display=Polish

View file

@ -26,6 +26,9 @@ menu.button.clone=Clone
menu.button.print=Print recipe
menu.search=Search...
menu.label.selected-langs=Languages
lang.en.display=English
lang.nl.display=Nederlands
lang.pl.display=Polski

View file

@ -25,6 +25,8 @@ menu.button.edit=Bewerken
menu.button.clone=Dupliceren
menu.button.print=Recept afdrukken
menu.label.selected-langs=Talen
menu.search=Zoeken...
lang.en.display=English
lang.nl.display=Nederlands

View file

@ -26,6 +26,9 @@ menu.button.clone=Duplikuj
menu.button.print=Drukuj przepis
menu.search=Szukaj...
menu.label.selected-langs=J?zyki
lang.en.display=English
lang.nl.display=Nederlands
lang.pl.display=Polski

View file

@ -49,7 +49,7 @@ class ServerUtilsTest {
void tearDown() throws IOException, InterruptedException {
// Not applicable in pipeline testing
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
dv.getRecipes().stream().map(Recipe::getId).forEach(id -> {
dv.getRecipes(List.of()).stream().map(Recipe::getId).forEach(id -> {
try {
dv.deleteRecipe(id);
} catch (Exception ex) {
@ -77,7 +77,7 @@ class ServerUtilsTest {
@Test
void getAllRecipesTest() throws IOException, InterruptedException {
List<Recipe> recipes = dv.getRecipes();
List<Recipe> recipes = dv.getRecipes(List.of());
assertNotNull(recipes, "The list should not be null");
assertTrue(recipes.size() >= 0, "The list should be 0 (when no recipes), or more");

View file

@ -1,96 +1,120 @@
package client.scenes;
import client.utils.Config;
import client.utils.ConfigService;
import client.utils.ServerUtils;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class ConfigServiceTest {
static ServerUtils dv = new ServerUtils();
@TempDir
Path tempDir;
private static final long TEST_ID_A = 23412L;
private static final long TEST_ID_B = 25412L;
private Path configPath;
private ConfigService configService;
@BeforeEach
public void setup(){
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
void setUp() throws IOException {
configPath = tempDir.resolve("configServiceTest.json");
configService = new ConfigService(configPath);
}
@Test
public void constructorTest(){
assertEquals(configPath, configService.getConfigPath());
assertNotNull(configService.getMapper());
assertNotNull(configService.getConfig());
}
/*
Tests if the config file loads properly with a prewritten json file
*/
@Test
public void configServiceFileLoadTest(@TempDir Path tempDir) throws IOException {
Path configPath = tempDir.resolve("config.json");
public void constructorFileDNETest(){
Path nonExistentPath = tempDir.resolve("DNE.json");
ConfigService newService = new ConfigService(nonExistentPath);
assertNotNull(newService.getConfig());
assertEquals("en", newService.getConfig().getLanguage());
assertEquals("http://localhost:8080", newService.getConfig().getServerUrl());
assertTrue(newService.getConfig().getFavourites().isEmpty());
assertTrue(newService.getConfig().getShoppingList().isEmpty());
}
@Test
public void validJsonLoadTest() throws IOException {
String json = """
{
"language": "de",
"serverUrl": "http://exmple12.com",
"favourites": [23412, 25412],
"serverUrl": "http://example.com:8080",
"favourites": [123, 456],
"shoppingList": ["milk", "butter"]
}
""";
Files.writeString(configPath, json); //writes into path
ConfigService configService = new ConfigService(configPath);//initiates configservice
Config config = configService.getConfig(); //checks
Files.writeString(configPath, json);
ConfigService loadedService = new ConfigService(configPath);
Config loadedConfig = loadedService.getConfig();
assertEquals("de", config.getLanguage());
assertEquals("http://exmple12.com", config.getServerUrl());
List<Long> x = new ArrayList<>();
x.add(TEST_ID_A);
x.add(TEST_ID_B);
List<String> y = new ArrayList<>();
y.add("milk");
y.add("butter");
assertEquals(x , config.getFavourites());
assertEquals(y , config.getShoppingList());
assertEquals("de", loadedConfig.getLanguage());
assertEquals("http://example.com:8080", loadedConfig.getServerUrl());
assertEquals(2, loadedConfig.getFavourites().size());
assertTrue(loadedConfig.getFavourites().contains(123L));
assertTrue(loadedConfig.getFavourites().contains(456L));
assertEquals(2, loadedConfig.getShoppingList().size());
assertTrue(loadedConfig.getShoppingList().contains("milk"));
assertTrue(loadedConfig.getShoppingList().contains("butter"));
}
/*
Tests if the save method saves changes to the config file.
*/
@Test
public void configSaveTest(@TempDir Path tempDir) throws IOException {
Path configPath = tempDir.resolve("config.json");
ConfigService configService = new ConfigService(configPath);
public void invalidJsonLoadTest() throws IOException {
Files.writeString(configPath, "{ invalid json text");
Config config = configService.getConfig();
assertThrows(RuntimeException.class, () -> {
ConfigService corruptedService = new ConfigService(configPath);
corruptedService.getConfig();
});
}
@Test
public void safeTest(){
Config realConfig = new Config();
realConfig.setLanguage("de");
realConfig.setServerUrl("http://example.com:8080");
realConfig.addFavourite(1L);
realConfig.addFavourite(2L);
realConfig.setShoppingList(List.of("milk", "bread"));
config.setLanguage("fr");
config.setServerUrl("www.domain1.com");
configService.setConfig(realConfig);
configService.save();
String jsonTest = Files.readString(configPath);
assertTrue(jsonTest.contains("\"language\":\"fr\""));
assertTrue(jsonTest.contains("\"serverUrl\":\"www.domain1.com\""));
ConfigService loadedService = new ConfigService(configPath);
Config loadedConfig = loadedService.getConfig();
assertEquals("de", loadedConfig.getLanguage());
assertEquals("http://example.com:8080", loadedConfig.getServerUrl());
assertEquals(2, loadedConfig.getFavourites().size());
assertTrue(loadedConfig.getFavourites().contains(1L));
assertTrue(loadedConfig.getFavourites().contains(2L));
assertEquals(2, loadedConfig.getShoppingList().size());
assertTrue(loadedConfig.getShoppingList().contains("milk"));
assertTrue(loadedConfig.getShoppingList().contains("bread"));
}
@Test
public void multipleTimesSaveTest(){
Config realConfig = new Config();
configService.setConfig(realConfig);
configService.save();
realConfig.setLanguage("de");
configService.save();
realConfig.setServerUrl("http://example.com:8080");
configService.save();
ConfigService loadedService = new ConfigService(configPath);
Config loadedConfig = loadedService.getConfig();
assertEquals("de", loadedConfig.getLanguage());
assertEquals("http://example.com:8080", loadedConfig.getServerUrl());
}
}

View file

@ -1,8 +1,6 @@
package client.scenes;
import client.utils.Config;
import client.utils.ServerUtils;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -11,42 +9,64 @@ import java.util.ArrayList;
import static org.junit.jupiter.api.Assertions.*;
public class ConfigTest {
static ServerUtils dv = new ServerUtils();
private Config config;
private static final long FAV_LIST_ID_1 = 1234L;
private static final long FAV_LIST_ID_2 = 1235L;
@BeforeEach
public void setup(){
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
void setUp(){
config = new Config();
config.setLanguage("nl");
config.setServerUrl("http://localhost:8081");
}
@Test
public void configDefaultValueTest(){
Config config = new Config();
assertEquals("en", config.getLanguage());
assertEquals("http://localhost:8080", config.getServerUrl());
assertEquals("nl", config.getLanguage());
assertEquals("http://localhost:8081", config.getServerUrl());
assertTrue(config.getFavourites().isEmpty());
assertTrue(config.getShoppingList().isEmpty());
}
@Test
public void configGetterTest(){
Config config = new Config();
config.setLanguage("nl");
config.setServerUrl("http://localhost:8081");
ArrayList<String> x = new ArrayList<>();
x.add("Lava Cake");
x.add("Brownie");
ArrayList<Long> y = new ArrayList<>();
y.add(FAV_LIST_ID_1);
y.add(FAV_LIST_ID_2);
config.setFavourites(y);
assertEquals(config.getFavourites(), y);
config.setShoppingList(x);
assertEquals(config.getShoppingList(), x);
}
@Test
public void isFavTest(){
assertFalse(config.isFavourite(FAV_LIST_ID_1)); //not yet fav
ArrayList<Long> y = new ArrayList<>();
y.add(FAV_LIST_ID_1);
config.setFavourites(y);
assertTrue(config.isFavourite(FAV_LIST_ID_1));
}
@Test
public void removeFavTest(){
ArrayList<Long> y = new ArrayList<>();
y.add(FAV_LIST_ID_1);
config.setFavourites(y);
assertTrue(config.isFavourite(FAV_LIST_ID_1));
config.removeFavourite(FAV_LIST_ID_1);
assertFalse(config.isFavourite(FAV_LIST_ID_1));
}
}

View file

@ -2,11 +2,9 @@ package client.scenes;
import client.utils.DefaultValueFactory;
import client.utils.PrintExportService;
import client.utils.ServerUtils;
import commons.Recipe;
import commons.RecipeIngredient;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@ -19,22 +17,20 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class PrintExportTest {
static ServerUtils dv = new ServerUtils();
@BeforeEach
public void setup(){
Assumptions.assumeTrue(dv.isServerAvailable(), "Server not available");
}
@TempDir
Path tempDir;
@Test
public void buildRecipeTextTest(){
List<RecipeIngredient> ingredients = new ArrayList<>();
ingredients.add(DefaultValueFactory.getDefaultVagueIngredient("Banana"));
ingredients.add(DefaultValueFactory.getDefaultVagueIngredient("Bread"));
final long testRecipeId = 1234L;
List<String> preparationSteps = new ArrayList<>();
preparationSteps.add("Mix Ingredients");
preparationSteps.add("Heat in Oven");
Recipe recipe1 = new Recipe(testRecipeId, "Banana Bread", ingredients, preparationSteps);
Recipe recipe1 = new Recipe(testRecipeId, "Banana Bread", "en", ingredients, preparationSteps);
assertEquals("""
Title: Banana Bread
@ -44,11 +40,8 @@ public class PrintExportTest {
1: Mix Ingredients
2: Heat in Oven
""", PrintExportService.buildRecipeText(recipe1));
}
@TempDir
Path tempDir;
@Test
public void validateFolderWithValidFolderTest(){
assertDoesNotThrow(() -> PrintExportService.validateFolder(tempDir));
@ -168,4 +161,27 @@ public class PrintExportTest {
}
@Test
public void succesExportTest() throws IOException {
String data = "recipe data";
String fileName = "succes.txt";
Path filePath = tempDir.resolve(fileName);
PrintExportService.exportToFile(data,tempDir,fileName);
assertTrue(Files.exists(filePath));
assertEquals(data, Files.readString(filePath));
}
@Test
public void failExportTest(){
String data = "recipe data";
String fileName = "succes.txt";
Path filePath = tempDir.resolve("fail/failDir");
IllegalArgumentException i = assertThrows(IllegalArgumentException.class,
()->PrintExportService.exportToFile(data,filePath,fileName));
assertEquals("Folder does not exist", i.getMessage());
}
}

View file

@ -20,13 +20,12 @@ public class Ingredient {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public long id;
public Long id;
// FIXME Dec 22 2025::temporarily made this not a unique constraint because of weird JPA behaviour
@Column(name = "name", nullable = false, unique = false)
public String name;
@Column(name = "protein", nullable = false)
public double proteinPer100g;
@ -62,13 +61,15 @@ public class Ingredient {
this.carbsPer100g = carbsPer100g;
}
public double kcalPer100g() {
return proteinPer100g * KCAL_PER_GRAM_PROTEIN
+ carbsPer100g * KCAL_PER_GRAM_CARBS
+ fatPer100g * KCAL_PER_GRAM_FAT;
}
public void setId(long id) {
public void setId(Long id) {
this.id = id;
}
@ -84,7 +85,7 @@ public class Ingredient {
return carbsPer100g;
}
public long getId() {
public Long getId() {
return id;
}
@ -92,6 +93,10 @@ public class Ingredient {
return name;
}
public void setName(String name){
this.name = name;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;

View file

@ -49,6 +49,10 @@ public class Recipe {
@Column(name = "name", nullable = false, unique = true)
private String name;
// Locale in which the recipe was created.
@Column(name = "locale", nullable = false)
private String locale = "en";
// Creates another table named recipe_ingredients which stores:
// recipe_ingredients(recipe_id -> recipes(id), ingredient).
// Example recipe_ingredients table:
@ -95,11 +99,15 @@ public class Recipe {
this.name = name;
}
// TODO: Replace String with Embeddable Ingredient Class for ingredients
public Recipe(Long id, String name, List<RecipeIngredient> ingredients, List<String> preparationSteps) {
public Recipe(Long id,
String name,
String locale,
List<RecipeIngredient> ingredients,
List<String> preparationSteps) {
// Not used by JPA/Spring
this.id = id;
this.name = name;
this.locale = locale;
this.ingredients = ingredients;
this.preparationSteps = preparationSteps;
}
@ -120,6 +128,14 @@ public class Recipe {
this.name = name;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
// TODO: Replace String with Embeddable Ingredient Class
public List<RecipeIngredient> getIngredients() {
// Disallow modifying the returned list.
@ -159,7 +175,7 @@ public class Recipe {
@Override
public String toString() {
return "Recipe " + id +
" - " + name +
" - " + name + " (" + locale + ")" +
": " + ingredients.size() + " ingredients / " +
preparationSteps.size() + " steps";
}
@ -170,6 +186,7 @@ public class Recipe {
return "Recipe{" +
"id=" + id +
", name='" + name + '\'' +
", locale='" + locale + "'" +
", ingredients=" + ingredients +
", preparationSteps=" + preparationSteps +
'}';

View file

@ -3,6 +3,7 @@ package commons;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class IngredientTest {
@ -25,7 +26,7 @@ class IngredientTest {
assertEquals(ZERO, sugar.proteinPer100g);
assertEquals(ZERO, sugar.fatPer100g);
assertEquals(HUNDRED, sugar.carbsPer100g);
assertEquals(0L, sugar.id);
assertNull(sugar.id);
}
@Test

146
docs/agenda/agenda-07.md Normal file
View file

@ -0,0 +1,146 @@
# Week 7 meeting agenda
| Key | Value |
| ------------ |----------------------------------------------------------------------------------------------------------|
| Date | Jan 9th 2026 |
| Time | 16:45 |
| Location | DW PC Hall 2 |
| Chair | Mei Chang van der Werff |
| Minute Taker | Aysegul Aydinlik |
| Attendees | Natalia Cholewa,Oskar Rasienski, Rithvik Sriram, Aysegul Aydinlik, Steven Liu, Mei Chang van der Werff |
## Table of contents
### Opening
1. (1 min) Introduction by chair
> Meeting starts at 16:48
2. (1 min) Any additions to the agenda?
> None
3. (1-2 min) TA announcements?
> - Technology feedback has been graded
> - Implemented features to be graded this weekend
> - For the final evaluation we need to have the basic requirements otherwise it won't get checked at all
> - We can and should update read.me
> - We get to see our LOC next week
### Feedback/assignments
4. (3 min) Go over TA feedback given (technology)
- Do we have any questions about the feedback?
> **Technology feedback**
> - Server and business logic and controller logic should be seperated
> - Avoid hardcoding; move logic into server utilities where appropriate
5. (3 min) Go over the implemented feature assignment due today
> - 4.1 Printing not finished
> - Meaningfull additions:
> - Extra language support
> - Text-to-voice functionality
### Current progress
6. How far are we in terms of completion?
* (5 min) Progress check in terms of feature/issue completion
- What have we completed/going to complete this week?
> - 4.1 not finished
> - 4.2 done
> - 4.3 requires significant work
- Who implemented what, this week?
> - Natalia: refactored server-side, recipe language handling
> - Steven: refactoring, fixing application compatibility with intelliJ
> - Aysegul: Ingredient list view + ingredient usage
> - Oskar: Refactoring
> - Rithvik: Fixed recipe detail UI sizing, implemented server changed, printing
> - Mei Chang: Client-side testing, hardcoded value fixes, refactoring
* (3 min) Small Showcase of the product
> - small showcase of the favourite button
### Planning
7. (15 min) Planning week 8
- What needs to be done? (provisionary tasks below)
- Handle Backend-Server code delegation
- How do we do it?
- Who does what?
* (2 min) Designate teams.
* (1 min) Who is the next chair and minute taker?
> **Planning week 8**
> - resolve unfinished issues from previous weeks
> - improve server utilities and dependency injection
> - No shopping list implementation planned
> - Nutrition logic should be implemented
>
> **SERVER**
>
> Mei Chang and Aysegul
>
> **BACKEND**
>
> Oskar, Natalia, Rithvik, Steven
>
> **Task Distribution**
>
> Aysegul: Server Testing + Additional server tasks
>
> Mei Chang: Nutrition logic & serving logic + server testing
>
> Oskar: Figuring out Mockito
>
> Rithvik: Printing functionality
>
> Steven: UI nutrition + dropdowns ingredients
>
> Natalia: Refactoring
Next chair and minute taker
> Aysegul will be the next Chair
>
> Natalia will be the next Minute taker
### Closing
8. (2 min) Questions?
> -Emphasis on separation, refactoring, and maintainability
9. (2 min) Summarize everything
> - Focus next week: finishing 4.1 and 4.3
>
> - Server refactoring and nutrition logic are priorities
>
> - 2 members must still fulfill server LOC requirements
>
> - README must clearly reflect extensions and improvementsa
10. (1 min) TA remarks
> TA will check implemented Features on sunday
>
> meeting ends at 17:32
>
> actual meeting time: 44 minutes
Estimated meeting time: ~40 minutes
Provisionary tasks (updated)
4.1
- Connect the printing functionality to the button, rn the button does nothing
4.2
-removing the refresh button and method, since it's automatic? (optional)
- fix: not being able to make a empty title for recipe name
4.3
- Implement fats/protein/carbs/kcal
- Being able to select ingredients from a dropdown menu
- Actually receive the delete recipe warning
- Serving implementation
- Informal unit fix (Steven)
4.4
- Make the search bar functionality
- Get the favourite warning message
4.5 (old)
Assuming that we're going to start with the shopping cart
- Create shopping list
- reset shopping list (backend)
- Issues/task that weren't finished this week

View file

@ -1,74 +0,0 @@
# Week 7 meeting agenda
| Key | Value |
| ------------ |-----------------------------------------------------------------------------------------|
| Date | Jan 9th 2026 |
| Time | 14:45 |
| Location | DW PC Hall 2 |
| Chair | Mei Chang van der Werff |
| Minute Taker | Aysegul Aydinlik |
| Attendees | Natalia Cholewa, Rithvik Sriram, Aysegul Aydinlik, Steven Liu, Mei Chang van der Werff |
## Table of contents
### Opening
1. (1 min) Introduction by chair
2. (1 min) Any additions to the agenda?
3. (1-2 min) TA announcements?
### Feedback/assignments
4. (3 min) Go over TA feedback given (technology)
- Do we have any questions about the feedback?
5. (3 min) Go over the implemented feature assignment due today
### Current progress
6. How far are we in terms of completion?
* (5 min) Progress check in terms of feature/issue completion
- What have we completed/going to complete this week?
- Who implemented what, this week?
* (3 min) Small Showcase of the product
### Planning
7. (15 min) Planning week 8
- What needs to be done? (provisionary tasks below)
- Handle Backend-Server code delegation
- How do we do it?
- Who does what?
* (2 min) Designate teams.
* (1 min) Who is the next chair and minute taker?
### Closing
8. (2 min) Questions?
9. (2 min) Summarize everything
10. (1 min) TA remarks
Estimated meeting time: ~40 minutes
Provisionary tasks (updated)
4.1
- Connect the printing functionality to the button, rn the button does nothing
4.2
-removing the refresh button and method, since it's automatic? (optional)
- fix: not being able to make a empty title for recipe name
4.3
- Implement fats/protein/carbs/kcal
- Being able to select ingredients from a dropdown menu
- Actually receive the delete recipe warning
- Serving implementation
- Informal unit fix (Steven)
4.4
- Make the search bar functionality
- Get the favourite warning message
4.5 (old)
Assuming that we're going to start with the shopping cart
- Create shopping list
- reset shopping list (backend)
- Issues/task that weren't finished this week

73
docs/feedback/week-07.md Normal file
View file

@ -0,0 +1,73 @@
# Meeting feedback
**Quick Summary Scale**
Insufficient/Sufficient/Good/Excellent
#### Agenda
Feedback: **Excellent**
- The agenda was uploaded on time
- The agenda is formatted according to the template.
- The individual points are clear.
- I appreciate that you used separate sections for your ideas (e.g. assignments, current progress and planning).
#### Performance of the Previous Minute Taker
Feedback: **Excellent**
- The notes have been merged into the agenda file.
- Notes are clear and grouped based on the agenda point. The formatting makes the notes clearer.
- The agreements are clear and realistic.
- I like that you included what everyone completed up to that point.
- Everyone was assigned to a task. I understand you do a proper task distribution separate from the TA meeting.
- I also appreciated that you added all questions that arose during the meeting.
#### Chair performance
Feedback: **Excellent**
- I liked how you started the meeting and checked in with everyone.
- You followed all points from the agenda and everyone followed along.
- You initiated all points and ensured everyone was heard, including members who joined online.
- I appreciated that you asked everyone for feedback or other remarks.
- You also pointed out what the team should focus more in the following week (e.g fixing bugs)
- Time estimates seemed accurate.
#### Attitude & Relation
Feedback: **Excellent**
- It was a bit difficult to engage everyone that was also online, but you did a great job overall.
- Everyone took ownership of the meeting.
- The atmosphere was constructive and positive.
- All ideas were listened to and considered by all team members.
- Everyone was active in the meeting and sharing ideas.
#### Potentially Shippable Product
Feedback: **Excellent**
- The team presented the current state of the application.
- The basic requirements are completed and most extensions show good progress. Make sure to not miss any requirements for the extensions you already started.
- Progress has been made compared to the last meeting.
- If you maintain the same workflow, I believe you can complete all extensions.
#### Work Contribution/Distribution in the Team
Feedback: **Excellent**
- Everyone explained what they completed since the last meeting.
- It is good that you are aware of the current issues and you are working on them.
- Everyone reached their goal. Well done!
- Everyone contributes to the project.

View file

@ -6,6 +6,7 @@ import commons.ws.Topics;
import commons.ws.messages.CreateIngredientMessage;
import commons.ws.messages.DeleteIngredientMessage;
import commons.ws.messages.UpdateIngredientMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.DeleteMapping;
@ -115,6 +116,8 @@ public class IngredientController {
/**
* Update an existing ingredient by its ID.
* Maps to <code>PATCH /api/ingredients/{id}</code>
@ -166,18 +169,26 @@ public class IngredientController {
*/
@PostMapping("/ingredients")
public ResponseEntity<Ingredient> createIngredient(@RequestBody Ingredient ingredient) {
if (ingredient.name == null || ingredient.name.isEmpty()) {
if (ingredient == null
|| ingredient.name == null
|| ingredient.name.trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
return ingredientService.create(ingredient)
.map(saved -> {
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new CreateIngredientMessage(saved));
messagingTemplate.convertAndSend(
Topics.INGREDIENTS,
new CreateIngredientMessage(saved)
);
return ResponseEntity.ok(saved);
})
.orElseGet(() -> ResponseEntity.badRequest().build());
.orElseGet(() -> ResponseEntity.status
(HttpStatus.CONFLICT).build()
);
}
/**
* Delete an ingredient by its ID.
* Maps to <code>DELETE /api/ingredients/{id}</code>
@ -194,13 +205,24 @@ public class IngredientController {
*/
@DeleteMapping("/ingredients/{id}")
public ResponseEntity<Boolean> deleteIngredient(@PathVariable Long id) {
//check if the ingredient is used in any recipe
long usageCount = ingredientService.countUsage(id);
if (usageCount > 0) {
// If used in recipes, return a warning response (HTTP 400)
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(false); // Ingredient in use, don't delete
}
// delete if the ingredient is not in use
if (!ingredientService.delete(id)) {
return ResponseEntity.notFound().build();
}
messagingTemplate.convertAndSend(Topics.INGREDIENTS, new DeleteIngredientMessage(id));
return ResponseEntity.ok(true);
return ResponseEntity.ok(true); // deleted~
}
public record IngredientUsageResponse(Long ingredientId, long usedInRecipes){}
}

View file

@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import server.database.RecipeRepository;
import server.service.RecipeService;
import java.util.List;
@ -29,9 +30,11 @@ import java.util.logging.Logger;
@RestController
@RequestMapping("/api")
public class RecipeController {
private static final Logger logger = Logger.getLogger(RecipeController.class.getName());
private final SimpMessagingTemplate messagingTemplate;
private final RecipeService recipeService;
private SimpMessagingTemplate messagingTemplate;
private RecipeService recipeService;
private RecipeRepository recipeRepository;
public RecipeController(RecipeService recipeService, SimpMessagingTemplate messagingTemplate) {
this.recipeService = recipeService;
@ -56,19 +59,35 @@ public class RecipeController {
}
/**
* Mapping for <code>GET /recipes(?limit=)</code>
* Mapping for <code>GET /recipes(?limit=)(&locales=)</code>
* <p>
* If the limit parameter is unspecified, return all recipes in the repository.
* @param limit Integer limit of items you want to get
* @return The list of recipes
*/
@GetMapping("/recipes")
public ResponseEntity<List<Recipe>> getRecipes(@RequestParam Optional<Integer> limit) {
public ResponseEntity<List<Recipe>> getRecipes(
@RequestParam Optional<List<String>> locales,
@RequestParam Optional<Integer> limit
) {
logger.info("GET /recipes called.");
return ResponseEntity.ok(
// TODO: maybe refactor this. this is horrid and evil and nightmare
var recipes = locales
.map(loc -> {
return limit.map(lim -> {
return recipeService.findAllWithLocales(loc, lim);
})
.orElseGet(() -> {
return recipeService.findAllWithLocales(loc);
});
})
.orElseGet(
// Choose the right overload. One has a limit, other doesn't.
limit.map(recipeService::findAll).orElseGet(recipeService::findAll)
);
() -> limit.map(recipeService::findAll).orElseGet(recipeService::findAll));
return ResponseEntity.ok(recipes);
}
/**
@ -128,4 +147,42 @@ public class RecipeController {
messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id)); // Send to WS.
return ResponseEntity.ok(true);
}
/**
* Performs a search based on a case-insensitive partial match on
* Recipe name and limits the result to a set amount of results.
* @param search - name of the recipe to be searched for.
* @param limit - limit of the results queried for.
* @param lang - stores the info of the language of the user to provide server support/
* @return - returns a ResponseEntity with a List of Recipes and an HTTP 200 ok status.
*/
public ResponseEntity<List<Recipe>> getRecipes(
@RequestParam Optional<String> search,
@RequestParam Optional<Integer> limit,
@RequestParam Optional<String> lang){
List<Recipe> recipes = recipeRepository.findAll();
List<Recipe> finalRecipes = recipes;
recipes = search
.filter(s -> !s.trim().isEmpty()) // filters recipes if the string is not empty by doing a lowercase search
.map(s -> {
String lowercaseSearch = s.toLowerCase();
return finalRecipes.stream()
.filter(recipe ->
recipe.getName().toLowerCase().contains(lowercaseSearch)
)
.toList();
})
.orElse(recipes);
recipes = limit // filters based on limit if provided
.filter(l -> l < finalRecipes.size())
.map(l -> finalRecipes.stream().limit(l).toList())
.orElse(recipes);
return ResponseEntity.ok(recipes);
}
}

View file

@ -19,6 +19,15 @@ import org.springframework.data.jpa.repository.JpaRepository;
import commons.Recipe;
import java.util.Collection;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
boolean existsByName(String name);
List<Recipe> findAllByLocaleIsIn(Collection<String> locales);
Page<Recipe> findAllByLocaleIsIn(Collection<String> locales, Pageable pageable);
}

View file

@ -1,5 +1,6 @@
package server.service;
import commons.Ingredient;
import commons.Recipe;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@ -7,6 +8,7 @@ import server.database.IngredientRepository;
import server.database.RecipeIngredientRepository;
import server.database.RecipeRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@ -36,6 +38,14 @@ public class RecipeService {
return recipeRepository.findAll(PageRequest.of(0, limit)).toList();
}
public List<Recipe> findAllWithLocales(Collection<String> locales) {
return recipeRepository.findAllByLocaleIsIn(locales);
}
public List<Recipe> findAllWithLocales(Collection<String> locales, int limit) {
return recipeRepository.findAllByLocaleIsIn(locales, PageRequest.of(0, limit)).toList();
}
/**
* Creates a new recipe. Returns empty if the recipe with the same name already exists.
* @param recipe Recipe to be saved in the db.
@ -79,8 +89,11 @@ public class RecipeService {
recipeIngredient.setIngredient(
ingredientRepository
.findByName(recipeIngredient.getIngredient().name)
.orElseGet(() -> ingredientRepository.save(recipeIngredient.getIngredient())
))
.orElseGet(() -> {
Ingredient i = recipeIngredient.getIngredient();
i.setId(null);
return ingredientRepository.save(i);
}))
);
recipeIngredientRepository.saveAll(recipe.getIngredients());
return recipeRepository.save(recipe);

View file

@ -64,7 +64,7 @@ public class RecipeControllerTest {
public void setup(TestInfo info) {
recipes = LongStream
.range(0, NUM_RECIPES)
.mapToObj(x -> new Recipe(null, "Recipe " + x, List.of(), List.of()))
.mapToObj(x -> new Recipe(null, "Recipe " + x, "en", List.of(), List.of()))
.toList();
controller = new RecipeController(
recipeService,
@ -107,7 +107,7 @@ public class RecipeControllerTest {
@Tag("test-from-init-data")
public void getManyRecipes() {
// The number of recipes returned is the same as the entire input list
assertEquals(recipes.size(), controller.getRecipes(Optional.empty()).getBody().size());
assertEquals(recipes.size(), controller.getRecipes(Optional.empty(), Optional.empty()).getBody().size());
}
@Test
@ -115,7 +115,29 @@ public class RecipeControllerTest {
public void getSomeRecipes() {
final int LIMIT = 5;
// The number of recipes returned is the same as the entire input list
assertEquals(LIMIT, controller.getRecipes(Optional.of(LIMIT)).getBody().size());
assertEquals(LIMIT, controller.getRecipes(Optional.empty(), Optional.of(LIMIT)).getBody().size());
}
@Test
@Tag("test-from-init-data")
public void getManyRecipesWithLocale() {
// The number of recipes returned is the same as the entire input list
assertEquals(recipes.size(), controller.getRecipes(Optional.of(List.of("en", "nl")), Optional.empty()).getBody().size());
}
@Test
@Tag("test-from-init-data")
public void getNoRecipesWithLocale() {
// should have NO Dutch recipes (thank god)
assertEquals(0, controller.getRecipes(Optional.of(List.of("nl")), Optional.empty()).getBody().size());
}
@Test
@Tag("test-from-init-data")
public void getSomeRecipesWithLocale() {
final int LIMIT = 5;
// The number of recipes returned is the same as the entire input list
assertEquals(LIMIT, controller.getRecipes(Optional.of(List.of("en", "nl")), Optional.of(LIMIT)).getBody().size());
}
@Test