From eb5aedbe9143940d95942c39c37c7cf586c794f6 Mon Sep 17 00:00:00 2001 From: Rithvik Sriram Date: Fri, 19 Dec 2025 13:12:18 +0100 Subject: [PATCH 01/26] added backend server search functionality with unit tests # Conflicts: # server/src/test/java/server/api/RecipeControllerTest.java --- .../java/server/api/RecipeController.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index 37a2a78..52fc593 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -120,4 +120,27 @@ public class RecipeController { messagingTemplate.convertAndSend(Topics.RECIPES, new DeleteRecipeMessage(id)); // Send to WS. return ResponseEntity.ok(true); } + + public ResponseEntity> getRecipes( + @RequestParam(required = false) String search, + @RequestParam(required = false) Integer limit){ + + List recipes = recipeRepository.findAll(); + + if(search != null && !search.trim().isEmpty()){ + String lowercaseSearch = search.toLowerCase(); + recipes = recipes.stream().filter(recipe -> recipe.getName() + .toLowerCase() + .contains(lowercaseSearch)) + .toList(); + } + if(limit != null && limit Date: Fri, 19 Dec 2025 13:25:03 +0100 Subject: [PATCH 02/26] added comments --- server/src/main/java/server/api/RecipeController.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index 52fc593..f28d705 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -121,20 +121,27 @@ public class RecipeController { return ResponseEntity.ok(true); } + /** + * Performs a search based on a case-insensitive parital 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. + * @return - returns a ResponseEntity with a List of Recipes and an HTTP 200 ok status. + */ public ResponseEntity> getRecipes( @RequestParam(required = false) String search, @RequestParam(required = false) Integer limit){ List recipes = recipeRepository.findAll(); - if(search != null && !search.trim().isEmpty()){ + if(search != null && !search.trim().isEmpty()){ // if search isn't null or empty perform the filtering process String lowercaseSearch = search.toLowerCase(); recipes = recipes.stream().filter(recipe -> recipe.getName() .toLowerCase() .contains(lowercaseSearch)) .toList(); } - if(limit != null && limit Date: Thu, 8 Jan 2026 22:38:52 +0100 Subject: [PATCH 03/26] Implemented Optional fields and rewrote tests --- .../java/server/api/RecipeController.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index f28d705..f861f34 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -126,24 +126,32 @@ public class RecipeController { * 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> getRecipes( - @RequestParam(required = false) String search, - @RequestParam(required = false) Integer limit){ + @RequestParam Optional search, + @RequestParam Optional limit, + @RequestParam Optional lang){ List recipes = recipeRepository.findAll(); - if(search != null && !search.trim().isEmpty()){ // if search isn't null or empty perform the filtering process - String lowercaseSearch = search.toLowerCase(); - recipes = recipes.stream().filter(recipe -> recipe.getName() - .toLowerCase() - .contains(lowercaseSearch)) - .toList(); - } - if(limit != null && limit 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); } From 2d13a2b7af932cc5d1986f84c0e8be67a1222c7d Mon Sep 17 00:00:00 2001 From: Rithvik Sriram Date: Thu, 8 Jan 2026 23:19:50 +0100 Subject: [PATCH 04/26] rebased branches and fixed bugs --- server/src/main/java/server/api/RecipeController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index f861f34..1ba4edc 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -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; @@ -28,6 +29,7 @@ import java.util.Optional; @RestController @RequestMapping("/api") public class RecipeController { + private RecipeRepository recipeRepository; private final SimpMessagingTemplate messagingTemplate; private final RecipeService recipeService; @@ -122,7 +124,7 @@ public class RecipeController { } /** - * Performs a search based on a case-insensitive parital match on + * 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. From 74b6f25e24ba98eb9be270543a6ac3cfba816d91 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Fri, 9 Jan 2026 14:54:42 +0100 Subject: [PATCH 05/26] feat: language in recipes --- .../client/utils/DefaultValueFactory.java | 1 + commons/src/main/java/commons/Recipe.java | 23 +++++++++++++-- .../java/server/api/RecipeController.java | 28 +++++++++++++++---- .../server/database/RecipeRepository.java | 9 ++++++ .../java/server/service/RecipeService.java | 9 ++++++ .../java/server/api/RecipeControllerTest.java | 28 +++++++++++++++++-- 6 files changed, 86 insertions(+), 12 deletions(-) diff --git a/client/src/main/java/client/utils/DefaultValueFactory.java b/client/src/main/java/client/utils/DefaultValueFactory.java index 7d7f52d..299ae53 100644 --- a/client/src/main/java/client/utils/DefaultValueFactory.java +++ b/client/src/main/java/client/utils/DefaultValueFactory.java @@ -26,6 +26,7 @@ public class DefaultValueFactory { return new Recipe( null, "Untitled recipe", + "en", List.of(), List.of()); } diff --git a/commons/src/main/java/commons/Recipe.java b/commons/src/main/java/commons/Recipe.java index 987edef..b65d76c 100644 --- a/commons/src/main/java/commons/Recipe.java +++ b/commons/src/main/java/commons/Recipe.java @@ -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 ingredients, List preparationSteps) { + public Recipe(Long id, + String name, + String locale, + List ingredients, + List 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 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 + '}'; diff --git a/server/src/main/java/server/api/RecipeController.java b/server/src/main/java/server/api/RecipeController.java index e2143e7..680da4f 100644 --- a/server/src/main/java/server/api/RecipeController.java +++ b/server/src/main/java/server/api/RecipeController.java @@ -56,19 +56,35 @@ public class RecipeController { } /** - * Mapping for GET /recipes(?limit=) + * Mapping for GET /recipes(?limit=)(&locales=) *

* 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> getRecipes(@RequestParam Optional limit) { + public ResponseEntity> getRecipes( + @RequestParam Optional> locales, + @RequestParam Optional limit + ) { logger.info("GET /recipes called."); - return ResponseEntity.ok( - // Choose the right overload. One has a limit, other doesn't. - limit.map(recipeService::findAll).orElseGet(recipeService::findAll) - ); + + // 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)); + + + return ResponseEntity.ok(recipes); } /** diff --git a/server/src/main/java/server/database/RecipeRepository.java b/server/src/main/java/server/database/RecipeRepository.java index 8657873..f350842 100644 --- a/server/src/main/java/server/database/RecipeRepository.java +++ b/server/src/main/java/server/database/RecipeRepository.java @@ -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 { boolean existsByName(String name); + + List findAllByLocaleIsIn(Collection locales); + Page findAllByLocaleIsIn(Collection locales, Pageable pageable); } \ No newline at end of file diff --git a/server/src/main/java/server/service/RecipeService.java b/server/src/main/java/server/service/RecipeService.java index ac38e3c..14e819a 100644 --- a/server/src/main/java/server/service/RecipeService.java +++ b/server/src/main/java/server/service/RecipeService.java @@ -7,6 +7,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 +37,14 @@ public class RecipeService { return recipeRepository.findAll(PageRequest.of(0, limit)).toList(); } + public List findAllWithLocales(Collection locales) { + return recipeRepository.findAllByLocaleIsIn(locales); + } + + public List findAllWithLocales(Collection 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. diff --git a/server/src/test/java/server/api/RecipeControllerTest.java b/server/src/test/java/server/api/RecipeControllerTest.java index c052688..bede814 100644 --- a/server/src/test/java/server/api/RecipeControllerTest.java +++ b/server/src/test/java/server/api/RecipeControllerTest.java @@ -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 From 97774cf70992fd0365c07a40f0b1555f7f9a14b7 Mon Sep 17 00:00:00 2001 From: Natalia Cholewa Date: Fri, 9 Jan 2026 16:34:42 +0100 Subject: [PATCH 06/26] fix: tests didn't compile oops --- client/src/test/java/client/scenes/PrintExportTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/test/java/client/scenes/PrintExportTest.java b/client/src/test/java/client/scenes/PrintExportTest.java index 3fa93ee..958a994 100644 --- a/client/src/test/java/client/scenes/PrintExportTest.java +++ b/client/src/test/java/client/scenes/PrintExportTest.java @@ -34,7 +34,7 @@ public class PrintExportTest { List 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 From 9aae8f918f8b267222823630fa4f97c5acd2404a Mon Sep 17 00:00:00 2001 From: Aysegul Date: Fri, 9 Jan 2026 21:42:57 +0100 Subject: [PATCH 07/26] added pop up view ingredients + warnings for delete --- .../Ingredient/IngredientController.java | 131 +++++++++++++++ client/src/main/java/client/UI.java | 4 + .../client/scenes/FoodpalApplicationCtrl.java | 153 +++++++++++++++++- .../scenes/recipe/IngredientsPopupCtrl.java | 136 ++++++++++++++++ .../main/java/client/utils/ServerUtils.java | 81 ++++++++++ .../client/scenes/FoodpalApplication.fxml | 14 +- .../scenes/recipe/IngredientsPopup.fxml | 30 ++++ commons/src/main/java/commons/Ingredient.java | 6 + .../java/server/api/IngredientController.java | 36 ++++- 9 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 client/src/main/java/client/Ingredient/IngredientController.java create mode 100644 client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java create mode 100644 client/src/main/resources/client/scenes/recipe/IngredientsPopup.fxml diff --git a/client/src/main/java/client/Ingredient/IngredientController.java b/client/src/main/java/client/Ingredient/IngredientController.java new file mode 100644 index 0000000..14d4197 --- /dev/null +++ b/client/src/main/java/client/Ingredient/IngredientController.java @@ -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 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 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; + } + } +} diff --git a/client/src/main/java/client/UI.java b/client/src/main/java/client/UI.java index 9e1217d..327ca82 100644 --- a/client/src/main/java/client/UI.java +++ b/client/src/main/java/client/UI.java @@ -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; + } } diff --git a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java index cdbd565..616b36b 100644 --- a/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java +++ b/client/src/main/java/client/scenes/FoodpalApplicationCtrl.java @@ -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; @@ -31,22 +33,19 @@ import commons.ws.messages.UpdateRecipeMessage; import jakarta.inject.Inject; import javafx.application.Platform; import javafx.fxml.FXML; -import javafx.scene.control.Alert; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.ListCell; -import javafx.scene.control.ListView; -import javafx.scene.control.ToggleButton; +import javafx.scene.control.*; 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 dataService; private final Logger logger = Logger.getLogger(FoodpalApplicationCtrl.class.getName()); + @FXML private RecipeDetailCtrl recipeDetailController; @@ -60,6 +59,9 @@ public class FoodpalApplicationCtrl implements LocaleAware { @FXML public ListView recipeList; + @FXML + private ListView ingredientListView; + @FXML private Button addRecipeButton; @@ -93,6 +95,7 @@ public class FoodpalApplicationCtrl implements LocaleAware { this.configService = configService; this.dataService = recipeDataService; + setupDataService(); logger.info("WebSocket processor initialized."); initializeWebSocket(); @@ -400,6 +403,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 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() { + @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 +546,8 @@ public class FoodpalApplicationCtrl implements LocaleAware { + + + + + diff --git a/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java b/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java new file mode 100644 index 0000000..5c55684 --- /dev/null +++ b/client/src/main/java/client/scenes/recipe/IngredientsPopupCtrl.java @@ -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 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 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 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(); + } +} diff --git a/client/src/main/java/client/utils/ServerUtils.java b/client/src/main/java/client/utils/ServerUtils.java index 9c89508..6685a46 100644 --- a/client/src/main/java/client/utils/ServerUtils.java +++ b/client/src/main/java/client/utils/ServerUtils.java @@ -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; @@ -207,4 +208,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 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 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 getIngredients() throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(SERVER + "/ingredients")) + .GET() + .build(); + + HttpResponse 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>() {}); + } + + //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 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); + } + + + + } diff --git a/client/src/main/resources/client/scenes/FoodpalApplication.fxml b/client/src/main/resources/client/scenes/FoodpalApplication.fxml index 310e78d..0262c9c 100644 --- a/client/src/main/resources/client/scenes/FoodpalApplication.fxml +++ b/client/src/main/resources/client/scenes/FoodpalApplication.fxml @@ -30,7 +30,7 @@ - + @@ -44,7 +44,8 @@ + + @@ -53,9 +54,14 @@